mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b378af2294 | |||
| 5fec2681da | |||
| 64f51ee40d | |||
| fec0f193a8 | |||
| b689ebd8ca | |||
| 8e74bb7d01 | |||
| c02bc22cce | |||
| 7ea308a52c | |||
| e4d1a38367 | |||
| 98d54da0de | |||
| 4b638ae346 | |||
| abb849e1f6 | |||
| 2ec65e3dfc | |||
| 4e493972e5 | |||
| 4a8b8d5fa7 | |||
| 391fc5fdee | |||
| cbcf7d6a8e | |||
| 94eb1aa07d | |||
| ca66a6b2fa | |||
| 404a5991b3 | |||
| bd5de2e185 | |||
| b8eefe4b78 | |||
| cc8a3d8b5e | |||
| dd061790a8 | |||
| 7fddd99c28 | |||
| 7ab7e6efb7 | |||
| 4464d3c8b4 | |||
| e0f40f9bc1 | |||
| c83a6b4f41 | |||
| 82994c7b7b | |||
| e891de7f49 | |||
| fc2648becf | |||
| ff548eae5c | |||
| 866a7f264b | |||
| 8fcb629bdf | |||
| 99655c65d4 | |||
| 5e176415ab | |||
| 34555bce86 | |||
| dcd7a050bd | |||
| dc0df7c7e9 | |||
| e2c8ee7b54 | |||
| d2a0ddab12 | |||
| 31b254ff09 | |||
| 025af4f9fd | |||
| 221169db51 | |||
| d2a50256b0 | |||
| 6bc80720c9 | |||
| 23106bfce8 | |||
| e8046f0d2f | |||
| ba8ade0244 | |||
| 119eb92f27 | |||
| c26a75af27 | |||
| 10f508a8dd | |||
| d872293551 | |||
| e419aa6c3a | |||
| ba60d4bc0a | |||
| b1dffc3486 | |||
| 4fc6ac1f15 | |||
| 289302fd2e | |||
| 00db4010d0 | |||
| bc14699994 | |||
| 013a7a6d39 | |||
| c3f93a3e9d | |||
| c5cd4d9335 | |||
| c2f84466df | |||
| 00d7239601 | |||
| 5c070d0428 | |||
| 4822261187 | |||
| cda503e7af | |||
| 45cc5ee20f | |||
| ecf8783af0 | |||
| 7b7af55826 | |||
| 5c0fc87504 | |||
| 93cda9327a | |||
| 888add7920 | |||
| 7dfc4fb57d | |||
| 6ef03d3aa7 | |||
| d5510f4454 | |||
| 5b19f7c711 | |||
| f28f41eb76 | |||
| 80b9fb1daa | |||
| 80c547c1fc | |||
| 04c3d81b1f | |||
| f41f93d6c9 | |||
| 74acf9601b |
@@ -6,6 +6,7 @@ __mocks__
|
||||
.DS_Store
|
||||
.env*
|
||||
.eslint*
|
||||
.oxlintrc*
|
||||
.log
|
||||
Makefile
|
||||
Procfile
|
||||
|
||||
@@ -211,6 +211,10 @@ GITHUB_APP_PRIVATE_KEY=
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
# The GitLab integration allows previewing issue and merge request links as rich mentions
|
||||
GITLAB_CLIENT_ID=
|
||||
GITLAB_CLIENT_SECRET=
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed in addition to Slack authentication:
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
|
||||
|
||||
@@ -25,15 +25,14 @@ jobs:
|
||||
node-version: [20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --prefer-offline
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "yarn"
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --prefer-offline
|
||||
|
||||
lint:
|
||||
needs: build
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"globalSetup": "<rootDir>/server/test/globalSetup.js",
|
||||
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
|
||||
+5
-34
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript"],
|
||||
"env": {
|
||||
"builtin": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"build/**",
|
||||
"node_modules/**",
|
||||
@@ -35,6 +31,7 @@
|
||||
"no-empty-pattern": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-explicit-any": "warn",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
@@ -71,31 +68,12 @@
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/*.{js,jsx,ts,tsx}"
|
||||
],
|
||||
"files": ["**/*.{js,jsx,ts,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "reakit/Menu",
|
||||
"importNames": [
|
||||
"useMenuState"
|
||||
],
|
||||
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"eqeqeq": "error",
|
||||
"curly": "error",
|
||||
"no-console": "error",
|
||||
"arrow-body-style": [
|
||||
"error",
|
||||
"as-needed"
|
||||
],
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"no-useless-escape": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/self-closing-comp": [
|
||||
@@ -106,11 +84,10 @@
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
@@ -119,13 +96,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"eslint",
|
||||
"oxc",
|
||||
"react",
|
||||
"typescript",
|
||||
"import"
|
||||
]
|
||||
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
require("dotenv").config({
|
||||
require("@dotenvx/dotenvx").config({
|
||||
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.86.0
|
||||
Licensed Work: Outline 0.86.1
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2029-08-06
|
||||
Change Date: 2029-08-09
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"eslint-plugin-react-hooks"
|
||||
],
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off"
|
||||
},
|
||||
"env": {
|
||||
"jest": true,
|
||||
"browser": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": ["../.oxlintrc.json"],
|
||||
"plugins": ["oxc", "eslint", "typescript", "react"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.{jsx,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["mime-types"],
|
||||
"message": "Do not use the mime-types package in the browser."
|
||||
}
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"name": "reakit/Menu",
|
||||
"importNames": ["useMenuState"],
|
||||
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": ["import"]
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
"jest": true,
|
||||
"browser": true
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
ExportIcon,
|
||||
NewDocumentIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
@@ -22,11 +24,19 @@ import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
newDocumentPath,
|
||||
newTemplatePath,
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import ExportDialog from "~/components/ExportDialog";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
<DynamicCollectionIcon collection={collection} />
|
||||
@@ -70,7 +80,7 @@ export const createCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollection = createAction({
|
||||
export const editCollection = createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Edit")}…` : t("Edit collection"),
|
||||
analyticsName: "Edit collection",
|
||||
@@ -96,7 +106,7 @@ export const editCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollectionPermissions = createAction({
|
||||
export const editCollectionPermissions = createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
analyticsName: "Collection permissions",
|
||||
@@ -128,7 +138,7 @@ export const editCollectionPermissions = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const searchInCollection = createAction({
|
||||
export const searchInCollection = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Search in collection"),
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -146,13 +156,20 @@ export const searchInCollection = createAction({
|
||||
|
||||
return stores.policies.abilities(activeCollectionId).readDocument;
|
||||
},
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = searchPath({
|
||||
collectionId: activeCollectionId,
|
||||
}).split("?");
|
||||
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath({ collectionId: activeCollectionId }));
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const starCollection = createAction({
|
||||
export const starCollection = createActionV2({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -179,7 +196,7 @@ export const starCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarCollection = createAction({
|
||||
export const unstarCollection = createActionV2({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -205,7 +222,7 @@ export const unstarCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeCollection = createAction({
|
||||
export const subscribeCollection = createActionV2({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -218,6 +235,7 @@ export const subscribeCollection = createAction({
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection?.isActive &&
|
||||
!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).subscribe
|
||||
);
|
||||
@@ -235,7 +253,7 @@ export const subscribeCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeCollection = createAction({
|
||||
export const unsubscribeCollection = createActionV2({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -248,6 +266,7 @@ export const unsubscribeCollection = createAction({
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection?.isActive &&
|
||||
!!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).unsubscribe
|
||||
);
|
||||
@@ -265,10 +284,10 @@ export const unsubscribeCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createAction({
|
||||
export const archiveCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
@@ -306,7 +325,7 @@ export const archiveCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreCollection = createAction({
|
||||
export const restoreCollection = createActionV2({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore collection",
|
||||
section: CollectionSection,
|
||||
@@ -331,7 +350,7 @@ export const restoreCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createAction({
|
||||
export const deleteCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -365,7 +384,65 @@ export const deleteCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createAction({
|
||||
export const exportCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Export")}…`,
|
||||
analyticsName: "Export collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ExportIcon />,
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (!currentTeamId || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!stores.policies.abilities(currentTeamId).createExport &&
|
||||
!!stores.policies.abilities(activeCollectionId).export
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Export collection"),
|
||||
content: (
|
||||
<ExportDialog
|
||||
collection={collection}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "new create document",
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -376,13 +453,14 @@ export const createTemplate = createAction({
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
perform: ({ activeCollectionId, event }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
history.push(newTemplatePath(activeCollectionId));
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
|
||||
import history from "~/utils/history";
|
||||
import { createAction } from "..";
|
||||
import { DocumentSection } from "../sections";
|
||||
import { createActionV2 } from "..";
|
||||
import { ActiveDocumentSection } from "../sections";
|
||||
|
||||
export const deleteCommentFactory = ({
|
||||
comment,
|
||||
@@ -15,15 +14,15 @@ export const deleteCommentFactory = ({
|
||||
comment: Comment;
|
||||
onDelete: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete comment",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "trash",
|
||||
dangerous: true,
|
||||
visible: () => stores.policies.abilities(comment.id).delete,
|
||||
perform: ({ t, event }) => {
|
||||
visible: ({ stores }) => stores.policies.abilities(comment.id).delete,
|
||||
perform: ({ t, stores, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
@@ -41,12 +40,12 @@ export const resolveCommentFactory = ({
|
||||
comment: Comment;
|
||||
onResolve: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Mark as resolved"),
|
||||
analyticsName: "Resolve thread",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DoneIcon outline />,
|
||||
visible: () =>
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(comment.id).resolve &&
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async ({ t }) => {
|
||||
@@ -73,12 +72,12 @@ export const unresolveCommentFactory = ({
|
||||
comment: Comment;
|
||||
onUnresolve: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Mark as unresolved"),
|
||||
analyticsName: "Unresolve thread",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DoneIcon outline />,
|
||||
visible: () =>
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(comment.id).unresolve &&
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async () => {
|
||||
@@ -102,15 +101,15 @@ export const viewCommentReactionsFactory = ({
|
||||
}: {
|
||||
comment: Comment;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => `${t("View reactions")}`,
|
||||
analyticsName: "View comment reactions",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SmileyIcon />,
|
||||
visible: () =>
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(comment.id).read &&
|
||||
comment.reactions.length > 0,
|
||||
perform: ({ t, event }) => {
|
||||
perform: ({ t, stores, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
BeakerIcon,
|
||||
@@ -127,6 +128,17 @@ export const clearIndexedDB = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const clearStorage = createAction({
|
||||
name: ({ t }) => t("Clear local storage"),
|
||||
icon: <TrashIcon />,
|
||||
keywords: "cache clear localstorage",
|
||||
section: DeveloperSection,
|
||||
perform: ({ t }) => {
|
||||
Storage.clear();
|
||||
toast.success(t("Local storage cleared"));
|
||||
},
|
||||
});
|
||||
|
||||
export const createTestUsers = createAction({
|
||||
name: "Create 10 test users",
|
||||
icon: <UserIcon />,
|
||||
@@ -201,6 +213,7 @@ export const developer = createAction({
|
||||
createToast,
|
||||
createTestUsers,
|
||||
clearIndexedDB,
|
||||
clearStorage,
|
||||
startTyping,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -26,11 +26,12 @@ import {
|
||||
PublishIcon,
|
||||
CommentIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
LogoutIcon,
|
||||
CaseSensitiveIcon,
|
||||
RestoreIcon,
|
||||
EditIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
@@ -52,7 +53,13 @@ import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createActionV2Group,
|
||||
createActionV2WithChildren,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import {
|
||||
ActiveDocumentSection,
|
||||
DocumentSection,
|
||||
@@ -62,7 +69,6 @@ import env from "~/env";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentInsightsPath,
|
||||
documentHistoryPath,
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
@@ -71,7 +77,12 @@ import {
|
||||
documentPath,
|
||||
urlify,
|
||||
trashPath,
|
||||
documentEditPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
|
||||
import Insights from "~/scenes/Document/components/Insights";
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
@@ -103,6 +114,38 @@ export const openDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const editDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Edit"),
|
||||
analyticsName: "Edit document",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "edit",
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const { auth, documents, policies } = stores;
|
||||
|
||||
const document = activeDocumentId
|
||||
? documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
const can = activeDocumentId
|
||||
? policies.abilities(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!!can?.update && !!auth.user?.separateEditMode && !document?.template
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return documentEditPath(document);
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
@@ -141,7 +184,7 @@ export const createDraftDocument = createAction({
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createAction({
|
||||
export const createDocumentFromTemplate = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
@@ -171,16 +214,24 @@ export const createDocumentFromTemplate = createAction({
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
|
||||
{
|
||||
sidebarContext,
|
||||
}
|
||||
),
|
||||
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId, {
|
||||
templateId: activeDocumentId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createNestedDocument = createAction({
|
||||
export const createNestedDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New nested document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -191,13 +242,19 @@ export const createNestedDocument = createAction({
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument &&
|
||||
stores.policies.abilities(activeDocumentId).createChildDocument,
|
||||
perform: ({ activeDocumentId, sidebarContext }) =>
|
||||
history.push(newNestedDocumentPath(activeDocumentId), {
|
||||
sidebarContext,
|
||||
}),
|
||||
to: ({ activeDocumentId, sidebarContext }) => {
|
||||
const [pathname, search] =
|
||||
newNestedDocumentPath(activeDocumentId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
export const starDocument = createActionV2({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -223,7 +280,7 @@ export const starDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarDocument = createAction({
|
||||
export const unstarDocument = createActionV2({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -249,7 +306,7 @@ export const unstarDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const publishDocument = createAction({
|
||||
export const publishDocument = createActionV2({
|
||||
name: ({ t }) => t("Publish"),
|
||||
analyticsName: "Publish document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -291,7 +348,7 @@ export const publishDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unpublishDocument = createAction({
|
||||
export const unpublishDocument = createActionV2({
|
||||
name: ({ t }) => t("Unpublish"),
|
||||
analyticsName: "Unpublish document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -322,11 +379,27 @@ export const unpublishDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeDocument = createAction({
|
||||
export const subscribeDocument = createActionV2({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SubscribeIcon />,
|
||||
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return stores.collections.get(activeCollectionId)?.isSubscribed
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined;
|
||||
},
|
||||
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
|
||||
},
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
@@ -335,6 +408,7 @@ export const subscribeDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!document?.isActive &&
|
||||
!document?.collection?.isSubscribed &&
|
||||
!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).subscribe
|
||||
@@ -351,11 +425,27 @@ export const subscribeDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeDocument = createAction({
|
||||
export const unsubscribeDocument = createActionV2({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return stores.collections.get(activeCollectionId)?.isSubscribed
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined;
|
||||
},
|
||||
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
|
||||
},
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
@@ -364,9 +454,10 @@ export const unsubscribeDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!document?.collection?.isSubscribed ||
|
||||
(!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe)
|
||||
!!document?.isActive &&
|
||||
(!!document?.collection?.isSubscribed ||
|
||||
(!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe))
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
@@ -382,7 +473,7 @@ export const unsubscribeDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const shareDocument = createAction({
|
||||
export const shareDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Permissions")}…`,
|
||||
analyticsName: "Share document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -415,7 +506,7 @@ export const shareDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsHTML = createAction({
|
||||
export const downloadDocumentAsHTML = createActionV2({
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download document as HTML",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -434,7 +525,7 @@ export const downloadDocumentAsHTML = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsPDF = createAction({
|
||||
export const downloadDocumentAsPDF = createActionV2({
|
||||
name: ({ t }) => t("PDF"),
|
||||
analyticsName: "Download document as PDF",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -442,9 +533,11 @@ export const downloadDocumentAsPDF = createAction({
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED,
|
||||
!!(
|
||||
activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED
|
||||
),
|
||||
perform: ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
@@ -458,7 +551,7 @@ export const downloadDocumentAsPDF = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
export const downloadDocumentAsMarkdown = createActionV2({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -477,7 +570,7 @@ export const downloadDocumentAsMarkdown = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createAction({
|
||||
export const downloadDocument = createActionV2WithChildren({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
analyticsName: "Download document",
|
||||
@@ -493,7 +586,7 @@ export const downloadDocument = createAction({
|
||||
],
|
||||
});
|
||||
|
||||
export const copyDocumentAsMarkdown = createAction({
|
||||
export const copyDocumentAsMarkdown = createActionV2({
|
||||
name: ({ t }) => t("Copy as Markdown"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -512,7 +605,7 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentAsPlainText = createAction({
|
||||
export const copyDocumentAsPlainText = createActionV2({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -531,7 +624,7 @@ export const copyDocumentAsPlainText = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentShareLink = createAction({
|
||||
export const copyDocumentShareLink = createActionV2({
|
||||
name: ({ t }) => t("Copy public link"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard share",
|
||||
@@ -552,7 +645,7 @@ export const copyDocumentShareLink = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentLink = createAction({
|
||||
export const copyDocumentLink = createActionV2({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -570,7 +663,7 @@ export const copyDocumentLink = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocument = createAction({
|
||||
export const copyDocument = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -584,7 +677,7 @@ export const copyDocument = createAction({
|
||||
],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
export const duplicateDocument = createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Duplicate") : t("Duplicate document"),
|
||||
analyticsName: "Duplicate document",
|
||||
@@ -620,7 +713,7 @@ export const duplicateDocument = createAction({
|
||||
* Pin a document to a collection. Pinned documents will be displayed at the top
|
||||
* of the collection for all collection members to see.
|
||||
*/
|
||||
export const pinDocumentToCollection = createAction({
|
||||
export const pinDocumentToCollection = createActionV2({
|
||||
name: ({ activeDocumentId = "", t, stores }) => {
|
||||
const selectedDocument = stores.documents.get(activeDocumentId);
|
||||
const collectionName = selectedDocument
|
||||
@@ -665,7 +758,7 @@ export const pinDocumentToCollection = createAction({
|
||||
* Pin a document to team home. Pinned documents will be displayed at the top
|
||||
* of the home screen for all team members to see.
|
||||
*/
|
||||
export const pinDocumentToHome = createAction({
|
||||
export const pinDocumentToHome = createActionV2({
|
||||
name: ({ t }) => t("Pin to home"),
|
||||
analyticsName: "Pin document to home",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -697,7 +790,7 @@ export const pinDocumentToHome = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const pinDocument = createAction({
|
||||
export const pinDocument = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Pin"),
|
||||
analyticsName: "Pin document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -705,7 +798,7 @@ export const pinDocument = createAction({
|
||||
children: [pinDocumentToCollection, pinDocumentToHome],
|
||||
});
|
||||
|
||||
export const searchInDocument = createAction({
|
||||
export const searchInDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Search in document"),
|
||||
analyticsName: "Search document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -718,12 +811,24 @@ export const searchInDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return !!document?.isActive;
|
||||
},
|
||||
perform: ({ activeDocumentId }) => {
|
||||
history.push(searchPath({ documentId: activeDocumentId }));
|
||||
to: ({ activeDocumentId, sidebarContext }) => {
|
||||
if (!activeDocumentId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [pathname, search] = searchPath({
|
||||
documentId: activeDocumentId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const printDocument = createAction({
|
||||
export const printDocument = createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
analyticsName: "Print document",
|
||||
@@ -735,7 +840,7 @@ export const printDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
export const importDocument = createActionV2({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: DocumentSection,
|
||||
@@ -782,7 +887,7 @@ export const importDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplateFromDocument = createAction({
|
||||
export const createTemplateFromDocument = createActionV2({
|
||||
name: ({ t }) => t("Templatize"),
|
||||
analyticsName: "Templatize document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -844,7 +949,7 @@ export const searchDocumentsForQuery = (query: string) =>
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
export const moveTemplateToWorkspace = createActionV2({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: DocumentSection,
|
||||
@@ -874,7 +979,7 @@ export const moveTemplateToWorkspace = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocumentToCollection = createAction({
|
||||
export const moveDocumentToCollection = createActionV2({
|
||||
name: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return t("Move");
|
||||
@@ -911,7 +1016,7 @@ export const moveDocumentToCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocument = createAction({
|
||||
export const moveDocument = createActionV2({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -930,7 +1035,7 @@ export const moveDocument = createAction({
|
||||
perform: moveDocumentToCollection.perform,
|
||||
});
|
||||
|
||||
export const moveTemplate = createAction({
|
||||
export const moveTemplate = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -949,7 +1054,7 @@ export const moveTemplate = createAction({
|
||||
children: [moveTemplateToWorkspace, moveDocumentToCollection],
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
export const archiveDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -989,7 +1094,102 @@ export const archiveDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteDocument = createAction({
|
||||
export const restoreDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Restore")}`,
|
||||
analyticsName: "Restore document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = document.collectionId
|
||||
? stores.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const can = stores.policies.abilities(document.id);
|
||||
|
||||
return (
|
||||
!!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
},
|
||||
perform: async ({ t, stores, activeDocumentId }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.restore();
|
||||
toast.success(
|
||||
t("{{ documentName }} restored", {
|
||||
documentName: capitalize(document.noun),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreDocumentToCollection = createActionV2WithChildren({
|
||||
name: ({ t }) => `${t("Restore")}…`,
|
||||
analyticsName: "Restore document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const can = stores.policies.abilities(document.id);
|
||||
const collection = document.collectionId
|
||||
? stores.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
},
|
||||
children: ({ t, activeDocumentId, stores }) => {
|
||||
const { collections, documents, policies } = stores;
|
||||
|
||||
const document = activeDocumentId
|
||||
? documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const actions = collections.orderedData.map((collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
return createActionV2({
|
||||
name: collection.name,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
visible: can.createDocument,
|
||||
perform: async () => {
|
||||
await document.restore({ collectionId: collection.id });
|
||||
toast.success(
|
||||
t("{{ documentName }} restored", {
|
||||
documentName: capitalize(document.noun),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return [createActionV2Group({ name: t("Choose a collection"), actions })];
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1023,7 +1223,7 @@ export const deleteDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const permanentlyDeleteDocument = createAction({
|
||||
export const permanentlyDeleteDocument = createActionV2({
|
||||
name: ({ t }) => t("Permanently delete"),
|
||||
analyticsName: "Permanently delete document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1078,7 +1278,7 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentComments = createAction({
|
||||
export const openDocumentComments = createActionV2({
|
||||
name: ({ t }) => t("Comments"),
|
||||
analyticsName: "Open comments",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1101,7 +1301,7 @@ export const openDocumentComments = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentHistory = createAction({
|
||||
export const openDocumentHistory = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("History"),
|
||||
analyticsName: "Open document history",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1110,19 +1310,25 @@ export const openDocumentHistory = createAction({
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return !!activeDocumentId && can.listRevisions;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return;
|
||||
return "";
|
||||
}
|
||||
history.push(documentHistoryPath(document));
|
||||
|
||||
const [pathname, search] = documentHistoryPath(document).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentInsights = createAction({
|
||||
export const openDocumentInsights = createActionV2({
|
||||
name: ({ t }) => t("Insights"),
|
||||
analyticsName: "Open document insights",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1140,50 +1346,22 @@ export const openDocumentInsights = createAction({
|
||||
!document?.isDeleted
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
history.push(documentInsightsPath(document));
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleViewerInsights = createAction({
|
||||
name: ({ t, stores, activeDocumentId }) => {
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
return document?.insightsEnabled
|
||||
? t("Disable viewer insights")
|
||||
: t("Enable viewer insights");
|
||||
},
|
||||
analyticsName: "Toggle viewer insights",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <EyeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return can.updateInsights;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.save({
|
||||
insightsEnabled: !document.insightsEnabled,
|
||||
stores.dialogs.openModal({
|
||||
title: t("Insights"),
|
||||
content: <Insights document={document} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const leaveDocument = createAction({
|
||||
export const leaveDocument = createActionV2({
|
||||
name: ({ t }) => t("Leave document"),
|
||||
analyticsName: "Leave document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1219,6 +1397,27 @@ export const leaveDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const applyTemplateFactory = ({
|
||||
actions,
|
||||
}: {
|
||||
actions: (ActionV2 | ActionV2Group | ActionV2Separator)[];
|
||||
}) =>
|
||||
createActionV2WithChildren({
|
||||
name: ({ t }) => t("Apply template"),
|
||||
analyticsName: "Apply template",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const { policies } = stores;
|
||||
const can = activeDocumentId
|
||||
? policies.abilities(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return !!can?.update;
|
||||
},
|
||||
children: actions,
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
|
||||
@@ -12,13 +12,19 @@ import {
|
||||
BrowserIcon,
|
||||
ShapesIcon,
|
||||
DraftsIcon,
|
||||
BugIcon,
|
||||
} from "outline-icons";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import stores from "~/stores";
|
||||
import SearchQuery from "~/models/SearchQuery";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createExternalLinkActionV2,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
@@ -97,7 +103,7 @@ export const navigateToSettings = createAction({
|
||||
to: settingsPath(),
|
||||
});
|
||||
|
||||
export const navigateToWorkspaceSettings = createAction({
|
||||
export const navigateToWorkspaceSettings = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Settings"),
|
||||
analyticsName: "Navigate to workspace settings",
|
||||
section: NavigationSection,
|
||||
@@ -106,7 +112,7 @@ export const navigateToWorkspaceSettings = createAction({
|
||||
to: settingsPath("details"),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
export const navigateToProfileSettings = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Profile"),
|
||||
analyticsName: "Navigate to profile settings",
|
||||
section: NavigationSection,
|
||||
@@ -124,8 +130,9 @@ export const navigateToTemplateSettings = createAction({
|
||||
to: settingsPath("templates"),
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createAction({
|
||||
name: ({ t }) => t("Notifications"),
|
||||
export const navigateToNotificationSettings = createInternalLinkActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Notification settings") : t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
@@ -133,7 +140,7 @@ export const navigateToNotificationSettings = createAction({
|
||||
to: settingsPath("notifications"),
|
||||
});
|
||||
|
||||
export const navigateToAccountPreferences = createAction({
|
||||
export const navigateToAccountPreferences = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Preferences"),
|
||||
analyticsName: "Navigate to account preferences",
|
||||
section: NavigationSection,
|
||||
@@ -142,28 +149,24 @@ export const navigateToAccountPreferences = createAction({
|
||||
to: settingsPath("preferences"),
|
||||
});
|
||||
|
||||
export const openDocumentation = createAction({
|
||||
export const openDocumentation = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Documentation"),
|
||||
analyticsName: "Open documentation",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
to: {
|
||||
url: UrlHelper.guide,
|
||||
target: "_blank",
|
||||
},
|
||||
url: UrlHelper.guide,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createAction({
|
||||
export const openAPIDocumentation = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("API documentation"),
|
||||
analyticsName: "Open API documentation",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
to: {
|
||||
url: UrlHelper.developers,
|
||||
target: "_blank",
|
||||
},
|
||||
url: UrlHelper.developers,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const toggleSidebar = createAction({
|
||||
@@ -174,41 +177,37 @@ export const toggleSidebar = createAction({
|
||||
perform: () => stores.ui.toggleCollapsedSidebar(),
|
||||
});
|
||||
|
||||
export const openFeedbackUrl = createAction({
|
||||
export const openFeedbackUrl = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Send us feedback"),
|
||||
analyticsName: "Open feedback",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
to: {
|
||||
url: UrlHelper.contact,
|
||||
target: "_blank",
|
||||
},
|
||||
url: UrlHelper.contact,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openBugReportUrl = createAction({
|
||||
export const openBugReportUrl = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Report a bug"),
|
||||
analyticsName: "Open bug report",
|
||||
section: NavigationSection,
|
||||
to: {
|
||||
url: UrlHelper.github,
|
||||
target: "_blank",
|
||||
},
|
||||
iconInContextMenu: false,
|
||||
icon: <BugIcon />,
|
||||
url: UrlHelper.github,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openChangelog = createAction({
|
||||
export const openChangelog = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Changelog"),
|
||||
analyticsName: "Open changelog",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
to: {
|
||||
url: UrlHelper.changelog,
|
||||
target: "_blank",
|
||||
},
|
||||
url: UrlHelper.changelog,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openKeyboardShortcuts = createAction({
|
||||
export const openKeyboardShortcuts = createActionV2({
|
||||
name: ({ t }) => t("Keyboard shortcuts"),
|
||||
analyticsName: "Open keyboard shortcuts",
|
||||
section: NavigationSection,
|
||||
@@ -239,7 +238,7 @@ export const downloadApp = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const logout = createAction({
|
||||
export const logout = createActionV2({
|
||||
name: ({ t }) => t("Log out"),
|
||||
analyticsName: "Log out",
|
||||
section: NavigationSection,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import { createAction } from "..";
|
||||
import { createAction, createActionV2 } from "..";
|
||||
import { NotificationSection } from "../sections";
|
||||
|
||||
export const markNotificationsAsRead = createAction({
|
||||
@@ -12,7 +12,7 @@ export const markNotificationsAsRead = createAction({
|
||||
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
|
||||
});
|
||||
|
||||
export const markNotificationsAsArchived = createAction({
|
||||
export const markNotificationsAsArchived = createActionV2({
|
||||
name: ({ t }) => t("Archive all notifications"),
|
||||
analyticsName: "Mark notifications as archived",
|
||||
section: NotificationSection,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { createAction, createActionV2 } from "~/actions";
|
||||
import { RevisionSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
matchDocumentHistory,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const restoreRevision = createAction({
|
||||
export const restoreRevision = createActionV2({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore revision",
|
||||
icon: <RestoreIcon />,
|
||||
@@ -73,7 +73,7 @@ export const deleteRevision = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyLinkToRevision = createAction({
|
||||
export const copyLinkToRevision = createActionV2({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy link to revision",
|
||||
icon: <LinkIcon />,
|
||||
|
||||
@@ -1,50 +1,48 @@
|
||||
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import { createAction } from "~/actions";
|
||||
import { createActionV2, createActionV2WithChildren } from "~/actions";
|
||||
import { SettingsSection } from "~/actions/sections";
|
||||
|
||||
export const changeToDarkTheme = createAction({
|
||||
export const changeToDarkTheme = createActionV2({
|
||||
name: ({ t }) => t("Dark"),
|
||||
analyticsName: "Change to dark theme",
|
||||
icon: <MoonIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme dark night",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "dark",
|
||||
perform: () => stores.ui.setTheme(Theme.Dark),
|
||||
selected: ({ stores }) => stores.ui.theme === "dark",
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark),
|
||||
});
|
||||
|
||||
export const changeToLightTheme = createAction({
|
||||
export const changeToLightTheme = createActionV2({
|
||||
name: ({ t }) => t("Light"),
|
||||
analyticsName: "Change to light theme",
|
||||
icon: <SunIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme light day",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "light",
|
||||
perform: () => stores.ui.setTheme(Theme.Light),
|
||||
selected: ({ stores }) => stores.ui.theme === "light",
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
|
||||
});
|
||||
|
||||
export const changeToSystemTheme = createAction({
|
||||
export const changeToSystemTheme = createActionV2({
|
||||
name: ({ t }) => t("System"),
|
||||
analyticsName: "Change to system theme",
|
||||
icon: <BrowserIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme system default",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "system",
|
||||
perform: () => stores.ui.setTheme(Theme.System),
|
||||
selected: ({ stores }) => stores.ui.theme === "system",
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.System),
|
||||
});
|
||||
|
||||
export const changeTheme = createAction({
|
||||
export const changeTheme = createActionV2WithChildren({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Appearance") : t("Change theme"),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: function _Icon() {
|
||||
return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
|
||||
},
|
||||
icon: ({ stores }) =>
|
||||
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
|
||||
keywords: "appearance display",
|
||||
section: SettingsSection,
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import Share from "~/models/Share";
|
||||
import { createActionV2, createInternalLinkActionV2 } from "..";
|
||||
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
|
||||
import { ShareSection } from "../sections";
|
||||
import env from "~/env";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const copyShareUrlFactory = ({ share }: { share: Share }) =>
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy share link",
|
||||
section: ShareSection,
|
||||
icon: <CopyIcon />,
|
||||
perform: ({ t }) => {
|
||||
copy(share.url, {
|
||||
debug: env.ENVIRONMENT !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
toast.success(t("Share link copied"));
|
||||
},
|
||||
});
|
||||
|
||||
export const goToShareSourceFactory = ({ share }: { share: Share }) =>
|
||||
createInternalLinkActionV2({
|
||||
name: ({ t }) =>
|
||||
share.collectionId ? t("Go to collection") : t("Go to document"),
|
||||
analyticsName: "Go to share source",
|
||||
section: ShareSection,
|
||||
icon: <ArrowIcon />,
|
||||
to: {
|
||||
pathname: share.sourcePathWithFallback,
|
||||
state: { sidebarContext: "collections" }, // optimistic preference of "collections"
|
||||
},
|
||||
});
|
||||
|
||||
export const revokeShareFactory = ({
|
||||
share,
|
||||
can,
|
||||
}: {
|
||||
share: Share;
|
||||
can: Record<string, boolean>;
|
||||
}) =>
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Revoke link"),
|
||||
analyticsName: "Revoke share",
|
||||
section: ShareSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: !!can.revoke,
|
||||
perform: async ({ t, stores }) => {
|
||||
try {
|
||||
await stores.shares.revoke(share);
|
||||
toast.message(t("Share link revoked"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -5,20 +5,24 @@ import RootStore from "~/stores/RootStore";
|
||||
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
|
||||
import TeamNew from "~/scenes/TeamNew";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import { createAction } from "~/actions";
|
||||
import { ActionContext } from "~/types";
|
||||
import {
|
||||
createActionV2,
|
||||
createActionV2WithChildren,
|
||||
createExternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { ActionContext, ExternalLinkActionV2 } from "~/types";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: function _Icon() {
|
||||
return (
|
||||
stores.auth.availableTeams?.map<ExternalLinkActionV2>((session) =>
|
||||
createExternalLinkActionV2({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: (
|
||||
<StyledTeamLogo
|
||||
alt={session.name}
|
||||
model={{
|
||||
@@ -29,16 +33,15 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
}}
|
||||
size={24}
|
||||
/>
|
||||
);
|
||||
},
|
||||
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
|
||||
to: {
|
||||
),
|
||||
visible: ({ currentTeamId }: ActionContext) =>
|
||||
currentTeamId !== session.id,
|
||||
url: session.url,
|
||||
target: "_self",
|
||||
},
|
||||
})) ?? [];
|
||||
})
|
||||
) ?? [];
|
||||
|
||||
export const switchTeam = createAction({
|
||||
export const switchTeam = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Switch workspace"),
|
||||
placeholder: ({ t }) => t("Select a workspace"),
|
||||
analyticsName: "Switch workspace",
|
||||
@@ -49,7 +52,7 @@ export const switchTeam = createAction({
|
||||
children: switchTeamsList,
|
||||
});
|
||||
|
||||
export const createTeam = createAction({
|
||||
export const createTeam = createActionV2({
|
||||
name: ({ t }) => `${t("New workspace")}…`,
|
||||
analyticsName: "New workspace",
|
||||
keywords: "create change switch workspace organization team",
|
||||
@@ -71,7 +74,7 @@ export const createTeam = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const desktopLoginTeam = createAction({
|
||||
export const desktopLoginTeam = createActionV2({
|
||||
name: ({ t }) => t("Login to workspace"),
|
||||
analyticsName: "Login to workspace",
|
||||
keywords: "change switch workspace organization team",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
UserChangeRoleDialog,
|
||||
UserDeleteDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
import { createAction } from "~/actions";
|
||||
import { createAction, createActionV2 } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
|
||||
export const inviteUser = createAction({
|
||||
@@ -28,7 +28,7 @@ export const inviteUser = createAction({
|
||||
});
|
||||
|
||||
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) =>
|
||||
UserRoleHelper.isRoleHigher(role, user!.role)
|
||||
? `${t("Promote to {{ role }}", {
|
||||
@@ -63,7 +63,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
});
|
||||
|
||||
export const deleteUserActionFactory = (userId: string) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => `${t("Delete user")}…`,
|
||||
analyticsName: "Delete user",
|
||||
keywords: "leave",
|
||||
|
||||
+96
-9
@@ -1,3 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
ActionV2Separator as TActionV2Separator,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
CommandBarAction,
|
||||
ExternalLinkActionV2,
|
||||
InternalLinkActionV2,
|
||||
MenuExternalLink,
|
||||
@@ -21,8 +21,9 @@ import {
|
||||
} from "~/types";
|
||||
import Analytics from "~/utils/Analytics";
|
||||
import history from "~/utils/history";
|
||||
import { Action as KbarAction } from "kbar";
|
||||
|
||||
function resolve<T>(value: any, context: ActionContext): T {
|
||||
export function resolve<T>(value: any, context: ActionContext): T {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ export function actionToMenuItem(
|
||||
export function actionToKBar(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): CommandBarAction[] {
|
||||
): KbarAction[] {
|
||||
if (typeof action.visible === "function" && !action.visible(context)) {
|
||||
return [];
|
||||
}
|
||||
@@ -267,10 +268,11 @@ export function actionV2ToMenuItem(
|
||||
switch (action.type) {
|
||||
case "action": {
|
||||
const title = resolve<string>(action.name, context);
|
||||
const visible = resolve<boolean>(action.visible, context);
|
||||
const visible = resolve<boolean>(action.visible, context) ?? true;
|
||||
const disabled = resolve<boolean>(action.disabled, context);
|
||||
const icon =
|
||||
!!action.icon && action.iconInContextMenu !== false
|
||||
? action.icon
|
||||
? resolve<React.ReactNode>(action.icon, context)
|
||||
: undefined;
|
||||
|
||||
switch (action.variant) {
|
||||
@@ -280,18 +282,24 @@ export function actionV2ToMenuItem(
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
tooltip: resolve<React.ReactChild>(action.tooltip, context),
|
||||
selected: resolve<boolean>(action.selected, context),
|
||||
dangerous: action.dangerous,
|
||||
onClick: () => performActionV2(action, context),
|
||||
};
|
||||
|
||||
case "internal_link":
|
||||
case "internal_link": {
|
||||
const to = resolve<LocationDescriptor>(action.to, context);
|
||||
return {
|
||||
type: "route",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
to: action.to,
|
||||
disabled,
|
||||
to,
|
||||
};
|
||||
}
|
||||
|
||||
case "external_link":
|
||||
return {
|
||||
@@ -299,6 +307,7 @@ export function actionV2ToMenuItem(
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
href: action.target
|
||||
? { url: action.url, target: action.target }
|
||||
: action.url,
|
||||
@@ -316,6 +325,7 @@ export function actionV2ToMenuItem(
|
||||
title,
|
||||
icon,
|
||||
items: subMenuItems,
|
||||
disabled,
|
||||
visible: visible && hasVisibleItems(subMenuItems),
|
||||
};
|
||||
}
|
||||
@@ -342,11 +352,88 @@ export function actionV2ToMenuItem(
|
||||
}
|
||||
}
|
||||
|
||||
export function actionV2ToKBar(
|
||||
action: ActionV2Variant,
|
||||
context: ActionContext
|
||||
): KbarAction[] {
|
||||
const visible = resolve<boolean>(action.visible, context);
|
||||
if (visible === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const name = resolve<string>(action.name, context);
|
||||
const icon = resolve<React.ReactElement>(action.icon, context);
|
||||
const section = resolve<string>(action.section, context);
|
||||
|
||||
const sectionPriority =
|
||||
typeof action.section !== "string" && "priority" in action.section
|
||||
? ((action.section.priority as number) ?? 0)
|
||||
: 0;
|
||||
|
||||
const priority = (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0));
|
||||
|
||||
switch (action.variant) {
|
||||
case "action":
|
||||
case "internal_link":
|
||||
case "external_link": {
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
name,
|
||||
section,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
priority,
|
||||
perform: () => performActionV2(action, context),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case "action_with_children": {
|
||||
const resolvedChildren = resolve<ActionV2Variant[]>(
|
||||
action.children,
|
||||
context
|
||||
);
|
||||
const children = resolvedChildren
|
||||
.map((a) => actionV2ToKBar(a, context))
|
||||
.flat()
|
||||
.filter(Boolean);
|
||||
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
name,
|
||||
section,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
priority,
|
||||
},
|
||||
...children.map((child) => ({
|
||||
...child,
|
||||
parent: child.parent ?? action.id,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
default:
|
||||
throw Error("invalid action variant");
|
||||
}
|
||||
}
|
||||
|
||||
export async function performActionV2(
|
||||
action: ActionV2,
|
||||
action: Exclude<ActionV2Variant, ActionV2WithChildren>,
|
||||
context: ActionContext
|
||||
) {
|
||||
const result = action.perform(context);
|
||||
const perform =
|
||||
action.variant === "action"
|
||||
? () => action.perform(context)
|
||||
: action.variant === "internal_link"
|
||||
? () => history.push(resolve<LocationDescriptor>(action.to, context))
|
||||
: () => window.open(action.url, action.target);
|
||||
|
||||
const result = perform();
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.catch((err: Error) => {
|
||||
|
||||
@@ -36,10 +36,14 @@ export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
|
||||
|
||||
export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const GroupSection = ({ t }: ActionContext) => t("Groups");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
UserSection.priority = 0.5;
|
||||
|
||||
export const ShareSection = ({ t }: ActionContext) => t("Share");
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* oxlint-disable react/prop-types */
|
||||
import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { performAction } from "~/actions";
|
||||
import { performAction, performActionV2, resolve } from "~/actions";
|
||||
import useIsMounted from "~/hooks/useIsMounted";
|
||||
import { Action, ActionContext } from "~/types";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
} from "~/types";
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Show the button in a disabled state */
|
||||
@@ -11,7 +16,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Hide the button entirely if action is not applicable */
|
||||
hideOnActionDisabled?: boolean;
|
||||
/** Action to use on button */
|
||||
action?: Action;
|
||||
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
|
||||
/** Context of action, must be provided with action */
|
||||
context?: ActionContext;
|
||||
/** If tooltip props are provided the button will be wrapped in a tooltip */
|
||||
@@ -40,8 +45,8 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
const actionContext = { ...context, isButton: true };
|
||||
|
||||
if (
|
||||
action?.visible &&
|
||||
!action.visible(actionContext) &&
|
||||
action.visible &&
|
||||
!resolve<boolean>(action.visible, actionContext) &&
|
||||
hideOnActionDisabled
|
||||
) {
|
||||
return null;
|
||||
@@ -63,7 +68,10 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
? (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const response = performAction(action, actionContext);
|
||||
const response =
|
||||
"variant" in action
|
||||
? performActionV2(action, actionContext)
|
||||
: performAction(action, actionContext);
|
||||
if (response?.finally) {
|
||||
setExecuting(true);
|
||||
void response.finally(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable prefer-rest-params */
|
||||
/* oxlint-disable prefer-rest-params */
|
||||
/* global ga */
|
||||
import escape from "lodash/escape";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
matchDocumentInsights,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
@@ -39,9 +38,7 @@ const DocumentComments = lazyWithRetry(
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
const DocumentInsights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
);
|
||||
|
||||
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
|
||||
|
||||
type Props = {
|
||||
@@ -98,12 +95,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
!!matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
}) && can.listRevisions;
|
||||
const showInsights =
|
||||
!!matchPath(location.pathname, {
|
||||
path: matchDocumentInsights,
|
||||
}) && can.listViews;
|
||||
const showComments =
|
||||
!showInsights &&
|
||||
!showHistory &&
|
||||
can.comment &&
|
||||
ui.activeDocumentId &&
|
||||
@@ -115,12 +107,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
initial={false}
|
||||
key={ui.activeDocumentId ? "active" : "inactive"}
|
||||
>
|
||||
{(showHistory || showInsights || showComments) && (
|
||||
{(showHistory || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<SidebarRight>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showInsights && <DocumentInsights />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</SidebarRight>
|
||||
|
||||
@@ -6,55 +6,89 @@ import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { InternalLinkActionV2, MenuInternalLink } from "~/types";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
type TopLevelAction =
|
||||
| InternalLinkActionV2
|
||||
| { type: "menu"; actions: InternalLinkActionV2[] };
|
||||
|
||||
type Props = React.PropsWithChildren<{
|
||||
items: MenuInternalLink[];
|
||||
actions: InternalLinkActionV2[];
|
||||
max?: number;
|
||||
highlightFirstItem?: boolean;
|
||||
}>;
|
||||
|
||||
function Breadcrumb(
|
||||
{ items, highlightFirstItem, children, max = 2 }: Props,
|
||||
{ actions, highlightFirstItem, children, max = 2 }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const totalItems = items.length;
|
||||
const topLevelItems: MenuInternalLink[] = [...items];
|
||||
let overflowItems;
|
||||
const actionContext = useActionContext({ isContextMenu: true });
|
||||
|
||||
const visibleActions = useComputed(
|
||||
() =>
|
||||
actions.filter((action) =>
|
||||
typeof action.visible === "function"
|
||||
? action.visible(actionContext)
|
||||
: (action.visible ?? true)
|
||||
),
|
||||
[actions, actionContext]
|
||||
);
|
||||
const totalVisibleActions = visibleActions.length;
|
||||
|
||||
const topLevelActions: TopLevelAction[] = [...visibleActions];
|
||||
|
||||
// chop middle breadcrumbs and present a "..." menu instead
|
||||
if (totalItems > max) {
|
||||
if (totalVisibleActions > max) {
|
||||
const halfMax = Math.floor(max / 2);
|
||||
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
|
||||
const menuActions = topLevelActions.splice(
|
||||
halfMax,
|
||||
totalVisibleActions - max
|
||||
) as InternalLinkActionV2[];
|
||||
|
||||
topLevelItems.splice(halfMax, 0, {
|
||||
to: "",
|
||||
type: "route",
|
||||
title: <BreadcrumbMenu items={overflowItems as MenuInternalLink[]} />,
|
||||
topLevelActions.splice(halfMax, 0, {
|
||||
type: "menu",
|
||||
actions: menuActions,
|
||||
});
|
||||
}
|
||||
|
||||
const toBreadcrumb = React.useCallback(
|
||||
(action: TopLevelAction, index: number) => {
|
||||
if (action.type === "menu") {
|
||||
return <BreadcrumbMenu key="menu" actions={action.actions} />;
|
||||
}
|
||||
|
||||
const item = actionV2ToMenuItem(
|
||||
action,
|
||||
actionContext
|
||||
) as MenuInternalLink;
|
||||
|
||||
return (
|
||||
<>
|
||||
{item.icon}
|
||||
<Item
|
||||
to={item.to}
|
||||
$withIcon={!!item.icon}
|
||||
$highlight={!!highlightFirstItem && index === 0}
|
||||
>
|
||||
{item.title}
|
||||
</Item>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[actionContext, highlightFirstItem]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex justify="flex-start" align="center" ref={ref}>
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment
|
||||
key={
|
||||
(typeof item.to === "string" ? item.to : item.to.pathname) || index
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
{item.to ? (
|
||||
<Item
|
||||
to={item.to}
|
||||
$withIcon={!!item.icon}
|
||||
$highlight={!!highlightFirstItem && index === 0}
|
||||
>
|
||||
{item.title}
|
||||
</Item>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
|
||||
{topLevelActions.map((action, index) => (
|
||||
<React.Fragment key={action.type === "menu" ? "menu" : `item-${index}`}>
|
||||
{toBreadcrumb(action, index)}
|
||||
{index !== topLevelActions.length - 1 || !!children ? (
|
||||
<Slash />
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{children}
|
||||
|
||||
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
{...rest}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
@@ -148,6 +148,10 @@ function Collaborators(props: Props) {
|
||||
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
|
||||
);
|
||||
|
||||
if (!document.insightsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
|
||||
@@ -3,9 +3,10 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { archivePath, collectionPath } from "~/utils/routeHelpers";
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { ActiveCollectionSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -14,32 +15,24 @@ type Props = {
|
||||
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const collectionNode: MenuInternalLink = {
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
};
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createInternalLinkActionV2({
|
||||
name: t("Archive"),
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: collection.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: collection.name,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
}),
|
||||
],
|
||||
[collection, t]
|
||||
);
|
||||
|
||||
const category: MenuInternalLink | undefined = collection.isArchived
|
||||
? {
|
||||
type: "route",
|
||||
icon: <ArchiveIcon />,
|
||||
title: t("Archive"),
|
||||
to: archivePath(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const output = [];
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
output.push(collectionNode);
|
||||
|
||||
return output;
|
||||
}, [collection, t]);
|
||||
|
||||
return <Breadcrumb items={items} highlightFirstItem />;
|
||||
return <Breadcrumb actions={actions} highlightFirstItem />;
|
||||
};
|
||||
|
||||
@@ -119,7 +119,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
>
|
||||
{(props) => (
|
||||
<InnerContextMenu
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
menuProps={props as any}
|
||||
{...rest}
|
||||
isSubMenu={isSubMenu}
|
||||
|
||||
@@ -11,8 +11,9 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -27,46 +28,12 @@ type Props = {
|
||||
maxDepth?: number;
|
||||
};
|
||||
|
||||
function useCategory(document: Document): MenuInternalLink | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (document.isDeleted) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <TrashIcon />,
|
||||
title: t("Trash"),
|
||||
to: trashPath(),
|
||||
};
|
||||
}
|
||||
|
||||
if (document.isArchived) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <ArchiveIcon />,
|
||||
title: t("Archive"),
|
||||
to: archivePath(),
|
||||
};
|
||||
}
|
||||
|
||||
if (document.template) {
|
||||
return {
|
||||
type: "route",
|
||||
icon: <ShapesIcon />,
|
||||
title: t("Templates"),
|
||||
to: settingsPath("templates"),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function DocumentBreadcrumb(
|
||||
{ document, children, onlyText, reverse = false, maxDepth }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
@@ -78,69 +45,91 @@ function DocumentBreadcrumb(
|
||||
void document.loadRelations({ withoutPolicies: true });
|
||||
}, [document]);
|
||||
|
||||
let collectionNode: MenuInternalLink | undefined;
|
||||
|
||||
if (collection && can.readDocument) {
|
||||
collectionNode = {
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: {
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
};
|
||||
} else if (document.isCollectionDeleted) {
|
||||
collectionNode = {
|
||||
type: "route",
|
||||
title: t("Deleted Collection"),
|
||||
icon: undefined,
|
||||
to: "",
|
||||
};
|
||||
}
|
||||
|
||||
const path = document.pathTo.slice(0, -1);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const output: MenuInternalLink[] = [];
|
||||
|
||||
const actions = React.useMemo(() => {
|
||||
if (depth === 0) {
|
||||
return output;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
if (collectionNode) {
|
||||
output.push(collectionNode);
|
||||
}
|
||||
|
||||
path.forEach((node: NavigationNode) => {
|
||||
const title = node.title || t("Untitled");
|
||||
output.push({
|
||||
type: "route",
|
||||
title: node.icon ? (
|
||||
<>
|
||||
<StyledIcon value={node.icon} color={node.color} /> {title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
),
|
||||
to: {
|
||||
pathname: node.url,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
});
|
||||
});
|
||||
const outputActions = [
|
||||
createInternalLinkActionV2({
|
||||
name: t("Trash"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
visible: document.isDeleted,
|
||||
to: trashPath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: t("Archive"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: document.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: t("Templates"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: document.template,
|
||||
to: settingsPath("templates"),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: collection?.name,
|
||||
section: ActiveDocumentSection,
|
||||
icon: collection ? (
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
) : undefined,
|
||||
visible: !!(collection && can.readDocument),
|
||||
to: collection
|
||||
? {
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}
|
||||
: "",
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: t("Deleted Collection"),
|
||||
section: ActiveDocumentSection,
|
||||
visible: document.isCollectionDeleted,
|
||||
to: "",
|
||||
}),
|
||||
...path.map((node) => {
|
||||
const title = node.title || t("Untitled");
|
||||
return createInternalLinkActionV2({
|
||||
name: node.icon ? (
|
||||
<>
|
||||
<StyledIcon value={node.icon} color={node.color} /> {title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
),
|
||||
section: ActiveDocumentSection,
|
||||
to: {
|
||||
pathname: node.url,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
return reverse
|
||||
? depth !== undefined
|
||||
? output.slice(-depth)
|
||||
: output
|
||||
? outputActions.slice(-depth)
|
||||
: outputActions
|
||||
: depth !== undefined
|
||||
? output.slice(0, depth)
|
||||
: output;
|
||||
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
|
||||
? outputActions.slice(0, depth)
|
||||
: outputActions;
|
||||
}, [
|
||||
t,
|
||||
document,
|
||||
collection,
|
||||
can.readDocument,
|
||||
sidebarContext,
|
||||
path,
|
||||
reverse,
|
||||
depth,
|
||||
]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
@@ -176,7 +165,7 @@ function DocumentBreadcrumb(
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb items={items} ref={ref} highlightFirstItem>
|
||||
<Breadcrumb actions={actions} ref={ref} highlightFirstItem>
|
||||
{children}
|
||||
</Breadcrumb>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import flatten from "lodash/flatten";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -11,7 +10,6 @@ import Button from "~/components/Button";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { flattenTree } from "~/utils/tree";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
|
||||
@@ -32,7 +30,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
|
||||
const nodes = collectionTrees.filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
@@ -78,34 +76,32 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
onSelect={selectPath}
|
||||
defaultValue={document.parentDocumentId || document.collectionId || ""}
|
||||
/>
|
||||
<OptionsContainer>
|
||||
{!document.isTemplate && (
|
||||
<>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={setRecursive}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
{!document.isTemplate && (
|
||||
<OptionsContainer>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={setRecursive}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
)}
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
{selectedPath ? (
|
||||
@@ -127,9 +123,11 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
}
|
||||
|
||||
const OptionsContainer = styled.div`
|
||||
margin: 16px 0 8px 0;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||
padding: 16px 24px 0;
|
||||
margin-bottom: -1px;
|
||||
background: ${(props) => props.theme.modalBackground};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export default observer(DocumentCopy);
|
||||
|
||||
@@ -15,7 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode, NavigationNodeType } from "@shared/types";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
@@ -26,7 +26,8 @@ import InputSearch from "~/components/InputSearch";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ancestors, descendants } from "~/utils/tree";
|
||||
import { ancestors, descendants, flattenTree } from "~/utils/tree";
|
||||
import flatten from "lodash/flatten";
|
||||
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
@@ -80,7 +81,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
|
||||
const searchIndex = React.useMemo(
|
||||
() =>
|
||||
new FuzzySearch(items, ["title"], {
|
||||
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
|
||||
caseSensitive: false,
|
||||
}),
|
||||
[items]
|
||||
@@ -125,11 +126,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
|
||||
return searchTerm
|
||||
? searchIndex.search(searchTerm)
|
||||
: items
|
||||
.concat(
|
||||
items.filter((item) => item.type === NavigationNodeType.Collection)
|
||||
)
|
||||
.flatMap(includeDescendants);
|
||||
: items.flatMap(includeDescendants);
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
@@ -137,6 +134,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
);
|
||||
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
@@ -310,7 +308,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
expanded={isExpanded(index)}
|
||||
icon={renderedIcon}
|
||||
title={title}
|
||||
depth={(node.depth ?? 0) - baseDepth}
|
||||
depth={(node.depth ?? 0) - normalizedBaseDepth}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
|
||||
@@ -33,7 +33,6 @@ type Props = {
|
||||
showParentDocuments?: boolean;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showPin?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
@@ -68,7 +67,6 @@ function DocumentListItem(
|
||||
showParentDocuments,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
@@ -156,10 +154,8 @@ function DocumentListItem(
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
|
||||
@@ -13,6 +13,9 @@ import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
import Flex from "./Flex";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
/** Whether to reload the page if a chunk fails to load. */
|
||||
@@ -23,6 +26,9 @@ type Props = WithTranslation & {
|
||||
component?: React.ComponentType | string;
|
||||
};
|
||||
|
||||
const ERROR_TRACKING_KEY = "error-boundary-tracking";
|
||||
const ERROR_TRACKING_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@observer
|
||||
class ErrorBoundary extends React.Component<Props> {
|
||||
@observable
|
||||
@@ -31,6 +37,13 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
@observable
|
||||
showDetails = false;
|
||||
|
||||
@observable
|
||||
isRepeatedError = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.checkForPreviousErrors();
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
this.error = error;
|
||||
|
||||
@@ -46,9 +59,47 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackError();
|
||||
Logger.error("ErrorBoundary", error);
|
||||
}
|
||||
|
||||
private checkForPreviousErrors = () => {
|
||||
try {
|
||||
const stored = Storage.get(ERROR_TRACKING_KEY);
|
||||
if (!stored) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: number[] = JSON.parse(stored);
|
||||
const cutoff = Date.now() - ERROR_TRACKING_WINDOW_MS;
|
||||
const recentErrors = errors.filter((timestamp) => timestamp > cutoff);
|
||||
|
||||
this.isRepeatedError = recentErrors.length > 0;
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to parse stored errors for error boundary", { err });
|
||||
}
|
||||
};
|
||||
|
||||
private trackError = () => {
|
||||
try {
|
||||
const stored = Storage.get(ERROR_TRACKING_KEY);
|
||||
const errors: number[] = stored ? JSON.parse(stored) : [];
|
||||
const cutoff = Date.now() - ERROR_TRACKING_WINDOW_MS;
|
||||
|
||||
// Filter out old errors and add current one
|
||||
const updatedErrors = [
|
||||
...errors.filter((timestamp) => timestamp > cutoff),
|
||||
Date.now(),
|
||||
];
|
||||
|
||||
Storage.set(ERROR_TRACKING_KEY, JSON.stringify(updatedErrors));
|
||||
|
||||
this.isRepeatedError = updatedErrors.length > 1;
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to track error in error boundary", { err });
|
||||
}
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
@@ -61,6 +112,12 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
window.open(isCloudHosted ? UrlHelper.contact : UrlHelper.github);
|
||||
};
|
||||
|
||||
handleClearCache = async () => {
|
||||
await deleteAllDatabases();
|
||||
Storage.clear();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, component: Component = CenteredContent, showTitle } = this.props;
|
||||
|
||||
@@ -107,29 +164,46 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
</Heading>
|
||||
</>
|
||||
)}
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
|
||||
values={{
|
||||
notified: isReported
|
||||
? ` – ${t("our engineers have been notified")}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{this.showDetails && <Pre>{error.toString()}</Pre>}
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>{t("Reload")}</Button>{" "}
|
||||
|
||||
{this.isRepeatedError ? (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
An error has occurred multiple times recently. If it continues
|
||||
please try clearing the cache or using a different browser.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
|
||||
values={{
|
||||
notified: isReported
|
||||
? ` – ${t("our engineers have been notified")}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{this.showDetails && <Pre>{error.stack}</Pre>}
|
||||
<Flex gap={8} wrap>
|
||||
{this.isRepeatedError && (
|
||||
<Button onClick={this.handleClearCache}>
|
||||
<Trans>Clear cache + reload</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={this.handleReload} neutral={this.isRepeatedError}>
|
||||
{t("Reload")}
|
||||
</Button>
|
||||
{this.showDetails ? (
|
||||
<Button onClick={this.handleReportBug} neutral>
|
||||
<Trans>Report a bug</Trans>…
|
||||
<Trans>Report a bug</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={this.handleShowDetails} neutral>
|
||||
<Trans>Show detail</Trans>…
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
</Flex>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,10 +30,15 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
) {
|
||||
const authorName = author.name;
|
||||
const urlObj = new URL(url);
|
||||
const service =
|
||||
urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.Linear;
|
||||
let service;
|
||||
|
||||
if (urlObj.hostname === "github.com") {
|
||||
service = IntegrationService.GitHub;
|
||||
} else if (urlObj.hostname === "gitlab.com") {
|
||||
service = IntegrationService.GitLab;
|
||||
} else {
|
||||
service = IntegrationService.Linear;
|
||||
}
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuButton, MenuItem } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import { depths, s, hover } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { EmojiSkinTone } from "@shared/types";
|
||||
import { getEmojiVariants } from "@shared/utils/emoji";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
const SkinTonePicker = ({
|
||||
@@ -19,62 +22,61 @@ const SkinTonePicker = ({
|
||||
onChange: (skin: EmojiSkinTone) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handEmojiVariants = useMemo(() => getEmojiVariants({ id: "hand" }), []);
|
||||
|
||||
const menu = useMenuState({
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const handleSkinClick = useCallback(
|
||||
(emojiSkin) => {
|
||||
menu.hide();
|
||||
(emojiSkin: EmojiSkinTone) => {
|
||||
setOpen(false);
|
||||
onChange(emojiSkin);
|
||||
},
|
||||
[menu, onChange]
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
|
||||
<MenuItem {...menu} key={emoji.value}>
|
||||
{(menuprops) => (
|
||||
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
|
||||
Object.values(EmojiSkinTone)
|
||||
.map((skinTone) => {
|
||||
const emoji = handEmojiVariants[skinTone];
|
||||
return emoji ? (
|
||||
<IconButton
|
||||
key={emoji.value}
|
||||
onClick={() => handleSkinClick(skinTone)}
|
||||
>
|
||||
<Emoji width={24} height={24}>
|
||||
{emoji.value}
|
||||
</Emoji>
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
)),
|
||||
[menu, handEmojiVariants, handleSkinClick]
|
||||
) : null;
|
||||
})
|
||||
.filter(Boolean),
|
||||
[handEmojiVariants, handleSkinClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledMenuButton
|
||||
{...props}
|
||||
aria-label={t("Choose default skin tone")}
|
||||
>
|
||||
{handEmojiVariants[skinTone]!.value}
|
||||
</StyledMenuButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<Menu {...menu} aria-label={t("Choose default skin tone")}>
|
||||
{(props) => <MenuContainer {...props}>{menuItems}</MenuContainer>}
|
||||
</Menu>
|
||||
</>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>
|
||||
<StyledMenuButton aria-label={t("Choose default skin tone")}>
|
||||
{handEmojiVariants[skinTone]?.value}
|
||||
</StyledMenuButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
aria-label={t("Choose default skin tone")}
|
||||
width={208}
|
||||
scrollable={false}
|
||||
shrink
|
||||
>
|
||||
<Emojis>{menuItems}</Emojis>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuContainer = styled(Flex)`
|
||||
z-index: ${depths.menu};
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
const Emojis = styled(Flex)`
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
const StyledMenuButton = styled(NudeButton)`
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
import Input, { Props as InputProps } from "./Input";
|
||||
import NudeButton from "./NudeButton";
|
||||
import Relative from "./Sidebar/components/Relative";
|
||||
import Text from "./Text";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./primitives/Popover";
|
||||
|
||||
type Props = Omit<InputProps, "onChange"> & {
|
||||
value: string | undefined;
|
||||
@@ -19,10 +17,6 @@ type Props = Omit<InputProps, "onChange"> & {
|
||||
|
||||
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
return (
|
||||
<Relative>
|
||||
@@ -33,30 +27,26 @@ const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
|
||||
maxLength={7}
|
||||
{...rest}
|
||||
/>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<SwatchButton
|
||||
aria-label={t("Show menu")}
|
||||
{...props}
|
||||
$background={value}
|
||||
/>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Select a color")}>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DelayedMount>
|
||||
<Text>{t("Loading")}…</Text>
|
||||
</DelayedMount>
|
||||
}
|
||||
>
|
||||
<StyledColorPicker
|
||||
disableAlpha
|
||||
color={value}
|
||||
onChange={(color) => onChange(color.hex)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</ContextMenu>
|
||||
<Popover modal={true}>
|
||||
<PopoverTrigger>
|
||||
<SwatchButton aria-label={t("Show menu")} $background={value} />
|
||||
</PopoverTrigger>
|
||||
<StyledContent aria-label={t("Select a color")} align="end">
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DelayedMount>
|
||||
<Text>{t("Loading")}…</Text>
|
||||
</DelayedMount>
|
||||
}
|
||||
>
|
||||
<StyledColorPicker
|
||||
disableAlpha
|
||||
color={value}
|
||||
onChange={(color) => onChange(color.hex)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</StyledContent>
|
||||
</Popover>
|
||||
</Relative>
|
||||
);
|
||||
};
|
||||
@@ -70,6 +60,11 @@ const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>`
|
||||
right: 6px;
|
||||
`;
|
||||
|
||||
const StyledContent = styled(PopoverContent)`
|
||||
width: auto;
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const ColorPicker = lazyWithRetry(
|
||||
() => import("react-color/lib/components/chrome/Chrome")
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
export interface LazyComponent<T extends React.ComponentType<any>> {
|
||||
export interface LazyComponent<T extends React.ComponentType<unknown>> {
|
||||
Component: React.LazyExoticComponent<T>;
|
||||
preload: () => Promise<{ default: T }>;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ interface LazyLoadOptions {
|
||||
* MyComponent.preload();
|
||||
* ```
|
||||
*/
|
||||
export function createLazyComponent<T extends React.ComponentType<any>>(
|
||||
export function createLazyComponent<T extends React.ComponentType<unknown>>(
|
||||
factory: () => Promise<{ default: T }>,
|
||||
options: LazyLoadOptions = {}
|
||||
): LazyComponent<T> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import styled from "styled-components";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import {
|
||||
@@ -15,92 +16,155 @@ import {
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { ActionV2Variant, ActionV2WithChildren, MenuItem } from "~/types";
|
||||
import {
|
||||
ActionContext,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
MenuItem,
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
import { toDropdownMenuItems, toMobileMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
/** Action context to use - new context will be created if not provided */
|
||||
context?: ActionContext;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** Alignment w.r.t trigger - defaults to start */
|
||||
align?: "start" | "end";
|
||||
/** ARIA label for the menu */
|
||||
ariaLabel: string;
|
||||
contentAriaLabel?: string;
|
||||
};
|
||||
/** Additional component to display at the bottom of the top-level menu */
|
||||
append?: React.ReactNode;
|
||||
/** Callback when menu is opened */
|
||||
onOpen?: () => void;
|
||||
/** Callback when menu is closed */
|
||||
onClose?: () => void;
|
||||
// TODO: Invert the dependency chain by forwarding dropdown ref and props to Tooltip component
|
||||
} & React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>;
|
||||
|
||||
export function DropdownMenu({
|
||||
action,
|
||||
children,
|
||||
align = "start",
|
||||
ariaLabel,
|
||||
contentAriaLabel,
|
||||
}: Props) {
|
||||
const isMobile = useMobile();
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
const menuItems = (action.children as ActionV2Variant[]).map((childAction) =>
|
||||
actionV2ToMenuItem(childAction, context)
|
||||
);
|
||||
export const DropdownMenu = observer(
|
||||
React.forwardRef<React.ElementRef<typeof TooltipPrimitive.Trigger>, Props>(
|
||||
(
|
||||
{
|
||||
action,
|
||||
context,
|
||||
children,
|
||||
align = "start",
|
||||
ariaLabel,
|
||||
append,
|
||||
onOpen,
|
||||
onClose,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const isMobile = useMobile();
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
const actionContext =
|
||||
context ??
|
||||
useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
|
||||
const menuItems = useComputed(() => {
|
||||
if (!open) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (action.children as ActionV2Variant[]).map((childAction) =>
|
||||
actionV2ToMenuItem(childAction, actionContext)
|
||||
);
|
||||
}, [open, action.children, actionContext]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
setOpen(open);
|
||||
if (open) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onOpen, onClose]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileDropdown
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
items={menuItems}
|
||||
trigger={children}
|
||||
ariaLabel={ariaLabel}
|
||||
append={append}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const content = toDropdownMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<DropdownMenuRoot open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuRoot>
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileDropdown
|
||||
items={menuItems}
|
||||
trigger={children}
|
||||
ariaLabel={ariaLabel}
|
||||
contentAriaLabel={contentAriaLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const content = toDropdownMenuItems(menuItems);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger aria-label={ariaLabel}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={contentAriaLabel ?? ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
{content}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuRoot>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
type MobileDropdownProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
items: MenuItem[];
|
||||
trigger: React.ReactNode;
|
||||
} & Pick<Props, "ariaLabel" | "contentAriaLabel">;
|
||||
} & Pick<Props, "ariaLabel" | "append">;
|
||||
|
||||
function MobileDropdown({
|
||||
open,
|
||||
onOpenChange,
|
||||
items,
|
||||
trigger,
|
||||
ariaLabel,
|
||||
contentAriaLabel,
|
||||
append,
|
||||
}: MobileDropdownProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [submenuName, setSubmenuName] = React.useState<string>();
|
||||
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
@@ -116,29 +180,52 @@ function MobileDropdown({
|
||||
}, []);
|
||||
|
||||
const closeDrawer = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setSubmenuName(undefined), 500); // needed for a Vaul bug where 'onAnimationEnd' is not called for controlled state.
|
||||
}, [onOpenChange]);
|
||||
|
||||
const resetSubmenu = React.useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setSubmenuName(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const content = toMobileMenuItems(items, closeDrawer);
|
||||
const menuItems = React.useMemo(() => {
|
||||
if (!items.length || !submenuName) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
const submenu = items.find(
|
||||
(item) =>
|
||||
item.type === "submenu" && (item.title as string) === submenuName
|
||||
)! as MenuItemWithChildren;
|
||||
|
||||
return submenu.items;
|
||||
}, [items, submenuName]);
|
||||
|
||||
const content = toMobileMenuItems(menuItems, closeDrawer, setSubmenuName);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onAnimationEnd={resetSubmenu}
|
||||
>
|
||||
<DrawerTrigger aria-label={ariaLabel} asChild>
|
||||
{trigger}
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
ref={contentRef}
|
||||
aria-label={contentAriaLabel ?? ariaLabel}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={undefined}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
<DrawerTitle>{ariaLabel}</DrawerTitle>
|
||||
<StyledScrollable hiddenScrollbars>{content}</StyledScrollable>
|
||||
<StyledScrollable hiddenScrollbars>
|
||||
{content}
|
||||
{!submenuName ? append : null}
|
||||
</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Button from "~/components/Button";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Trigger
|
||||
> & {
|
||||
neutral?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const OverflowMenuButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
({ className, ...rest }, ref) => (
|
||||
<NudeButton ref={ref} className={className} {...rest}>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)
|
||||
({ neutral, className, ...rest }, ref) =>
|
||||
neutral ? (
|
||||
<Button ref={ref} icon={<MoreIcon />} neutral borderOnHover {...rest} />
|
||||
) : (
|
||||
<NudeButton ref={ref} className={className} {...rest}>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)
|
||||
);
|
||||
OverflowMenuButton.displayName = "OverflowMenuButton";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import {
|
||||
DropdownMenuButton,
|
||||
DropdownMenuExternalLink,
|
||||
@@ -5,6 +6,9 @@ import {
|
||||
DropdownMenuInternalLink,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
} from "~/components/primitives/DropdownMenu";
|
||||
import {
|
||||
MenuButton,
|
||||
@@ -13,6 +17,8 @@ import {
|
||||
MenuExternalLink,
|
||||
MenuLabel,
|
||||
MenuSeparator,
|
||||
MenuDisclosure,
|
||||
SelectedIconWrapper,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
@@ -46,6 +52,8 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
tooltip={item.tooltip}
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
onClick={item.onClick}
|
||||
/>
|
||||
@@ -76,6 +84,25 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
/>
|
||||
);
|
||||
|
||||
case "submenu": {
|
||||
const submenuItems = toDropdownMenuItems(item.items);
|
||||
|
||||
if (!submenuItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownSubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<DropdownSubMenuTrigger
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<DropdownSubMenuContent>{submenuItems}</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
);
|
||||
}
|
||||
|
||||
case "group": {
|
||||
const groupItems = toDropdownMenuItems(item.items);
|
||||
|
||||
@@ -101,7 +128,11 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export function toMobileMenuItems(items: MenuItem[], closeMenu: () => void) {
|
||||
export function toMobileMenuItems(
|
||||
items: MenuItem[],
|
||||
closeMenu: () => void,
|
||||
openSubmenu: (submenuName: string) => void
|
||||
) {
|
||||
const filteredItems = filterMenuItems(items);
|
||||
|
||||
if (!filteredItems.length) {
|
||||
@@ -137,6 +168,11 @@ export function toMobileMenuItems(items: MenuItem[], closeMenu: () => void) {
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
{item.selected !== undefined && (
|
||||
<SelectedIconWrapper aria-hidden>
|
||||
{item.selected ? <CheckmarkIcon /> : null}
|
||||
</SelectedIconWrapper>
|
||||
)}
|
||||
</MenuButton>
|
||||
);
|
||||
|
||||
@@ -169,8 +205,38 @@ export function toMobileMenuItems(items: MenuItem[], closeMenu: () => void) {
|
||||
</MenuExternalLink>
|
||||
);
|
||||
|
||||
case "submenu": {
|
||||
const submenuItems = toMobileMenuItems(
|
||||
item.items,
|
||||
closeMenu,
|
||||
openSubmenu
|
||||
);
|
||||
|
||||
if (!submenuItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
openSubmenu(item.title as string);
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
<MenuDisclosure />
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
case "group": {
|
||||
const groupItems = toMobileMenuItems(item.items, closeMenu);
|
||||
const groupItems = toMobileMenuItems(
|
||||
item.items,
|
||||
closeMenu,
|
||||
openSubmenu
|
||||
);
|
||||
|
||||
if (!groupItems?.length) {
|
||||
return null;
|
||||
|
||||
@@ -35,7 +35,7 @@ function Notifications(
|
||||
const context = useActionContext();
|
||||
const { notifications } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const isEmpty = notifications.orderedData.length === 0;
|
||||
const isEmpty = notifications.active.length === 0;
|
||||
|
||||
// Update the notification count in the dock icon, if possible.
|
||||
React.useEffect(() => {
|
||||
@@ -80,7 +80,7 @@ function Notifications(
|
||||
<PaginatedList<Notification>
|
||||
fetch={notifications.fetchPage}
|
||||
options={{ archived: false }}
|
||||
items={notifications.orderedData}
|
||||
items={notifications.active}
|
||||
renderItem={(item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
|
||||
@@ -20,7 +20,7 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
const scrollableRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
void notifications.fetchPage({});
|
||||
void notifications.fetchPage({ archived: false });
|
||||
}, [notifications]);
|
||||
|
||||
const handleRequestClose = React.useCallback(() => {
|
||||
|
||||
@@ -46,7 +46,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
document={item}
|
||||
showPin={!!options?.collectionId}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("PaginatedList", () => {
|
||||
i18n,
|
||||
tReady: true,
|
||||
t: ((key: string) => key) as TFunction,
|
||||
} as any;
|
||||
} as unknown;
|
||||
|
||||
it("with no items renders nothing", () => {
|
||||
const result = render(
|
||||
|
||||
@@ -34,11 +34,11 @@ interface Props<T extends PaginatedItem>
|
||||
* @param options Pagination and other query options
|
||||
*/
|
||||
fetch?: (
|
||||
options: Record<string, any> | undefined
|
||||
options: Record<string, unknown> | undefined
|
||||
) => Promise<unknown[] | undefined> | undefined;
|
||||
|
||||
/** Additional options to pass to the fetch function */
|
||||
options?: Record<string, any>;
|
||||
options?: Record<string, unknown>;
|
||||
|
||||
/** Optional header content to display above the list */
|
||||
heading?: React.ReactNode;
|
||||
@@ -77,7 +77,9 @@ interface Props<T extends PaginatedItem>
|
||||
* Function to render section headings (typically date-based)
|
||||
* @param name The heading text or element to render
|
||||
*/
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
renderHeading?: (
|
||||
name: React.ReactElement<unknown> | string
|
||||
) => React.ReactNode;
|
||||
|
||||
/**
|
||||
* Handler for escape key press
|
||||
@@ -206,7 +208,7 @@ const PaginatedList = <T extends PaginatedItem>({
|
||||
if (fetch) {
|
||||
void fetchResults();
|
||||
}
|
||||
}, [fetch]);
|
||||
}, [fetch, fetchResults]);
|
||||
|
||||
// Handle updates to fetch or options
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -25,7 +25,7 @@ import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
import SharedWithMe from "./components/SharedWithMe";
|
||||
import SidebarAction from "./components/SidebarAction";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import Starred from "./components/Starred";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
@@ -63,34 +63,31 @@ function AppSidebar() {
|
||||
<DragPlaceholder />
|
||||
|
||||
<TeamMenu>
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
title={team.name}
|
||||
image={
|
||||
<TeamLogo
|
||||
model={team}
|
||||
size={24}
|
||||
alt={t("Logo")}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={
|
||||
<TeamLogo
|
||||
model={team}
|
||||
size={24}
|
||||
alt={t("Logo")}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
>
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
)}
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
</TeamMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
|
||||
@@ -19,7 +19,7 @@ import NotificationIcon from "../Notifications/NotificationIcon";
|
||||
import NotificationsPopover from "../Notifications/NotificationsPopover";
|
||||
import { TooltipProvider } from "../TooltipContext";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
|
||||
const ANIMATION_MS = 250;
|
||||
@@ -231,29 +231,23 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
{user && (
|
||||
<AccountMenu>
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
position="bottom"
|
||||
image={
|
||||
<Avatar
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
<SidebarButton
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
/>
|
||||
</NotificationsPopover>
|
||||
</SidebarButton>
|
||||
)}
|
||||
<SidebarButton
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
position="bottom"
|
||||
image={
|
||||
<Avatar
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
<SidebarButton position="bottom" image={<NotificationIcon />} />
|
||||
</NotificationsPopover>
|
||||
</SidebarButton>
|
||||
</AccountMenu>
|
||||
)}
|
||||
<ResizeBorder
|
||||
|
||||
@@ -33,10 +33,13 @@ import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
collection?: Collection;
|
||||
membership?: UserMembership | GroupMembership;
|
||||
activeDocument: Document | null | undefined;
|
||||
prefetchDocument?: (documentId: string) => Promise<Document | void>;
|
||||
isDraft?: boolean;
|
||||
@@ -49,6 +52,7 @@ function InnerDocumentLink(
|
||||
{
|
||||
node,
|
||||
collection,
|
||||
membership,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
isDraft,
|
||||
@@ -87,20 +91,27 @@ function InnerDocumentLink(
|
||||
isActiveDocument,
|
||||
]);
|
||||
|
||||
const showChildren = React.useMemo(
|
||||
() =>
|
||||
!!(
|
||||
hasChildDocuments &&
|
||||
activeDocument &&
|
||||
collection &&
|
||||
(collection
|
||||
.pathToDocument(activeDocument.id)
|
||||
.map((entry) => entry.id)
|
||||
.includes(node.id) ||
|
||||
isActiveDocument)
|
||||
),
|
||||
[hasChildDocuments, activeDocument, isActiveDocument, node, collection]
|
||||
);
|
||||
const showChildren = React.useMemo(() => {
|
||||
if (!hasChildDocuments || !activeDocument) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathToDocument =
|
||||
collection?.pathToDocument(activeDocument.id) ??
|
||||
membership?.pathToDocument(activeDocument.id);
|
||||
|
||||
return !!(
|
||||
pathToDocument?.map((entry) => entry.id).includes(node.id) ||
|
||||
isActiveDocument
|
||||
);
|
||||
}, [
|
||||
hasChildDocuments,
|
||||
activeDocument,
|
||||
isActiveDocument,
|
||||
node,
|
||||
collection,
|
||||
membership,
|
||||
]);
|
||||
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
|
||||
|
||||
@@ -404,6 +415,7 @@ function InnerDocumentLink(
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
@@ -421,7 +433,7 @@ function InnerDocumentLink(
|
||||
const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>`
|
||||
transition: opacity 250ms ease;
|
||||
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.1 : 1)};
|
||||
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
|
||||
pointer-events: ${(props) => (props.$isMoving ? "none" : "inherit")};
|
||||
`;
|
||||
|
||||
const DocumentLink = observer(React.forwardRef(InnerDocumentLink));
|
||||
|
||||
@@ -135,7 +135,7 @@ function DraggableCollectionLink({
|
||||
const Draggable = styled("div")<{ $isDragging: boolean }>`
|
||||
transition: opacity 250ms ease;
|
||||
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
|
||||
pointer-events: ${(props) => (props.$isDragging ? "none" : "auto")};
|
||||
pointer-events: ${(props) => (props.$isDragging ? "none" : "inherit")};
|
||||
`;
|
||||
|
||||
export default observer(DraggableCollectionLink);
|
||||
|
||||
@@ -18,13 +18,17 @@ import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
|
||||
function SharedWithMe() {
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const { ui, userMemberships, groupMemberships } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const history = useHistory();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
|
||||
usePaginatedRequest<GroupMembership>(groupMemberships.fetchAll);
|
||||
|
||||
@@ -44,6 +48,54 @@ function SharedWithMe() {
|
||||
}
|
||||
}, [error, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const isContextInSharedSection =
|
||||
locationSidebarContext === "shared" ||
|
||||
locationSidebarContext?.startsWith("group");
|
||||
|
||||
if (!ui.activeDocumentId || isContextInSharedSection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isActiveDocSharedDirectly = user.documentMemberships.find(
|
||||
(m) => m.pathToDocument(ui.activeDocumentId!).length > 0
|
||||
);
|
||||
|
||||
if (isActiveDocSharedDirectly) {
|
||||
history.push({
|
||||
...history.location,
|
||||
state: {
|
||||
...(history.location.state as Record<string, unknown>),
|
||||
sidebarContext: "shared",
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const groupWithActiveDocument = user.groupsWithDocumentMemberships.find(
|
||||
(group) =>
|
||||
group.documentMemberships.some(
|
||||
(m) => m.pathToDocument(ui.activeDocumentId!).length > 0
|
||||
)
|
||||
);
|
||||
|
||||
if (groupWithActiveDocument) {
|
||||
history.push({
|
||||
...history.location,
|
||||
state: {
|
||||
...(history.location.state as Record<string, unknown>),
|
||||
sidebarContext: groupSidebarContext(groupWithActiveDocument.id),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [
|
||||
ui.activeDocumentId,
|
||||
locationSidebarContext,
|
||||
user.documentMemberships,
|
||||
user.groupsWithDocumentMemberships,
|
||||
]);
|
||||
|
||||
if (
|
||||
!user.documentMemberships.length &&
|
||||
!user.groupsWithDocumentMemberships.length
|
||||
|
||||
@@ -40,21 +40,20 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
const sidebarContext = useSidebarContext();
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
|
||||
const isActiveDocumentInPath = ui.activeDocumentId
|
||||
? membership.pathToDocument(ui.activeDocumentId).length > 0
|
||||
: false;
|
||||
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(
|
||||
membership.documentId === ui.activeDocumentId &&
|
||||
locationSidebarContext === sidebarContext
|
||||
isActiveDocumentInPath && locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
membership.documentId === ui.activeDocumentId &&
|
||||
locationSidebarContext === sidebarContext
|
||||
) {
|
||||
if (isActiveDocumentInPath && locationSidebarContext === sidebarContext) {
|
||||
setExpanded();
|
||||
}
|
||||
}, [
|
||||
membership.documentId,
|
||||
ui.activeDocumentId,
|
||||
isActiveDocumentInPath,
|
||||
sidebarContext,
|
||||
locationSidebarContext,
|
||||
setExpanded,
|
||||
@@ -63,6 +62,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
React.useEffect(() => {
|
||||
if (documentId) {
|
||||
void documents.fetch(documentId);
|
||||
void membership.fetchDocuments();
|
||||
}
|
||||
}, [documentId, documents]);
|
||||
|
||||
@@ -118,9 +118,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
const node = document.asNavigationNode;
|
||||
const childDocuments = node.children;
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
const childDocuments = membership.documents ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -139,7 +137,9 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={
|
||||
hasChildDocuments && !isDragging ? expanded : undefined
|
||||
childDocuments.length > 0 && !isDragging
|
||||
? expanded
|
||||
: undefined
|
||||
}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
icon={icon}
|
||||
@@ -180,8 +180,9 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
activeDocument={documents.active}
|
||||
isDraft={node.isDraft}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import styled, { useTheme, css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, truncateMultiline } from "@shared/styles";
|
||||
import { isMobile } from "@shared/utils/browser";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { UnreadBadge } from "~/components/UnreadBadge";
|
||||
@@ -272,8 +272,8 @@ const Link = styled(NavLink)<{
|
||||
const Label = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 4.8em;
|
||||
line-height: 24px;
|
||||
${truncateMultiline(3)}
|
||||
|
||||
* {
|
||||
unicode-bidi: plaintext;
|
||||
|
||||
@@ -9,7 +9,7 @@ function Toasts() {
|
||||
|
||||
return (
|
||||
<StyledToaster
|
||||
theme={ui.resolvedTheme as any}
|
||||
theme={ui.resolvedTheme as unknown}
|
||||
closeButton
|
||||
toastOptions={{
|
||||
duration: 5000,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { depths, s } from "@shared/styles";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
import { Overlay } from "./components/Overlay";
|
||||
import { m } from "framer-motion";
|
||||
import useMeasure from "react-use-measure";
|
||||
|
||||
/** Root Drawer component - all the other components are rendered inside it. */
|
||||
const Drawer = (props: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
@@ -22,15 +24,25 @@ const DrawerContent = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
const [measureRef, bounds] = useMeasure();
|
||||
|
||||
return (
|
||||
<DrawerPrimitive.Portal>
|
||||
<DrawerPrimitive.Overlay asChild>
|
||||
<Overlay />
|
||||
</DrawerPrimitive.Overlay>
|
||||
<StyledContent ref={ref} {...rest}>
|
||||
{children}
|
||||
</StyledContent>
|
||||
<DrawerPrimitive.Content ref={ref} asChild>
|
||||
<StyledContent
|
||||
animate={{
|
||||
height: bounds.height,
|
||||
transition: { bounce: 0, duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<StyledInnerContent ref={measureRef} {...rest}>
|
||||
{children}
|
||||
</StyledInnerContent>
|
||||
</StyledContent>
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
@@ -64,7 +76,7 @@ const DrawerTitle = React.forwardRef<
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(DrawerPrimitive.Content)`
|
||||
const StyledContent = styled(m.div)`
|
||||
z-index: ${depths.menu};
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -75,12 +87,13 @@ const StyledContent = styled(DrawerPrimitive.Content)`
|
||||
min-height: 44px;
|
||||
max-height: 90vh;
|
||||
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
`;
|
||||
|
||||
animation-duration: 0.3s;
|
||||
const StyledInnerContent = styled.div`
|
||||
padding: 6px;
|
||||
`;
|
||||
|
||||
const TitleWrapper = styled(Flex)`
|
||||
|
||||
@@ -4,18 +4,25 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuDisclosure,
|
||||
MenuExternalLink,
|
||||
MenuHeader,
|
||||
MenuInternalLink,
|
||||
MenuLabel,
|
||||
MenuSeparator,
|
||||
MenuSubTrigger,
|
||||
SelectedIconWrapper,
|
||||
} from "./components/Menu";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownSubMenu = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||
@@ -37,7 +44,13 @@ const DropdownMenuContent = React.forwardRef<
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content ref={ref} {...rest} asChild>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
{...rest}
|
||||
sideOffset={4}
|
||||
collisionPadding={6}
|
||||
asChild
|
||||
>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
@@ -45,6 +58,50 @@ const DropdownMenuContent = React.forwardRef<
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
type DropdownSubMenuTriggerProps = BaseDropdownItemProps &
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>;
|
||||
|
||||
const DropdownSubMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
DropdownSubMenuTriggerProps
|
||||
>((props, ref) => {
|
||||
const { label, icon, disabled, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger ref={ref} {...rest} asChild>
|
||||
<MenuSubTrigger disabled={disabled}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
<MenuDisclosure />
|
||||
</MenuSubTrigger>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
DropdownSubMenuTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownSubMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
{...rest}
|
||||
collisionPadding={6}
|
||||
asChild
|
||||
>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DropdownMenuPrimitive.SubContent>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DropdownSubMenuContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
type DropdownMenuGroupProps = {
|
||||
label: string;
|
||||
items: React.ReactNode[];
|
||||
@@ -76,6 +133,8 @@ type BaseDropdownItemProps = {
|
||||
|
||||
type DropdownMenuButtonProps = BaseDropdownItemProps & {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
tooltip?: React.ReactChild;
|
||||
selected?: boolean;
|
||||
dangerous?: boolean;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
@@ -86,16 +145,38 @@ const DropdownMenuButton = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
DropdownMenuButtonProps
|
||||
>((props, ref) => {
|
||||
const { label, icon, disabled, dangerous, onClick, ...rest } = props;
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
tooltip,
|
||||
disabled,
|
||||
selected,
|
||||
dangerous,
|
||||
onClick,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item ref={ref} {...rest} asChild>
|
||||
const button = (
|
||||
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<MenuButton disabled={disabled} $dangerous={dangerous} onClick={onClick}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : null}
|
||||
</SelectedIconWrapper>
|
||||
)}
|
||||
</MenuButton>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip content={tooltip} placement="bottom">
|
||||
<div>{button}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{button}</>
|
||||
);
|
||||
});
|
||||
DropdownMenuButton.displayName = "DropdownMenuButton";
|
||||
|
||||
@@ -113,7 +194,7 @@ const DropdownMenuInternalLink = React.forwardRef<
|
||||
const { label, icon, disabled, to, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item ref={ref} {...rest} asChild>
|
||||
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<MenuInternalLink to={to} disabled={disabled}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
@@ -138,7 +219,7 @@ const DropdownMenuExternalLink = React.forwardRef<
|
||||
const { label, icon, disabled, href, target, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item ref={ref} {...rest} asChild>
|
||||
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<MenuExternalLink href={href} target={target} disabled={disabled}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
@@ -174,7 +255,7 @@ const StyledScrollable = styled(Scrollable)`
|
||||
min-width: 180px;
|
||||
max-width: 276px;
|
||||
min-height: 44px;
|
||||
max-height: 75vh;
|
||||
max-height: min(85vh, var(--radix-dropdown-menu-content-available-height));
|
||||
font-weight: normal;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
@@ -204,4 +285,7 @@ export {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuTrigger,
|
||||
DropdownSubMenuContent,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { ellipsis } from "polished";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
@@ -37,6 +38,10 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
opacity: ${(props) => (props.disabled ? ".5" : 1)};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0; // Disable default outline on Firefox
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
!props.disabled &&
|
||||
`
|
||||
@@ -82,6 +87,10 @@ export const MenuExternalLink = styled.a`
|
||||
${BaseMenuItemCSS}
|
||||
`;
|
||||
|
||||
export const MenuSubTrigger = styled.div<BaseMenuItemProps>`
|
||||
${BaseMenuItemCSS}
|
||||
`;
|
||||
|
||||
export const MenuSeparator = styled.hr`
|
||||
margin: 6px 0;
|
||||
`;
|
||||
@@ -103,6 +112,13 @@ export const MenuHeader = styled.h3`
|
||||
margin: 1em 12px 0.5em;
|
||||
`;
|
||||
|
||||
export const MenuDisclosure = styled(ExpandedIcon)`
|
||||
transform: rotate(270deg);
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
color: ${s("textTertiary")};
|
||||
`;
|
||||
|
||||
export const MenuIconWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -111,3 +127,11 @@ export const MenuIconWrapper = styled.span`
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const SelectedIconWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: -6px;
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
@@ -333,7 +333,7 @@ export default function FindAndReplace({
|
||||
setShowReplace(false);
|
||||
editor.commands.clearSearch();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localOpen]);
|
||||
|
||||
const disabled = totalResults === 0;
|
||||
|
||||
@@ -70,7 +70,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query });
|
||||
res.data.documents.map(documents.add);
|
||||
}, [query])
|
||||
}, [query, documents.add])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,6 +79,22 @@ const LinkEditor: React.FC<Props> = ({
|
||||
}
|
||||
}, [trimmedQuery, request]);
|
||||
|
||||
const save = React.useCallback(
|
||||
(href: string, title?: string) => {
|
||||
href = href.trim();
|
||||
|
||||
if (href.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
discardRef.current = true;
|
||||
href = sanitizeUrl(href) ?? "";
|
||||
|
||||
onSelectLink({ href, title, from, to });
|
||||
},
|
||||
[onSelectLink, from, to]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "k" && event.metaKey) {
|
||||
@@ -107,20 +123,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
|
||||
save(trimmedQuery, trimmedQuery);
|
||||
};
|
||||
}, [trimmedQuery, initialValue]);
|
||||
|
||||
const save = (href: string, title?: string) => {
|
||||
href = href.trim();
|
||||
|
||||
if (href.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
discardRef.current = true;
|
||||
href = sanitizeUrl(href) ?? "";
|
||||
|
||||
onSelectLink({ href, title, from, to });
|
||||
};
|
||||
}, [trimmedQuery, initialValue, handleRemoveLink, save]);
|
||||
|
||||
const moveSelectionToEnd = () => {
|
||||
const { state, dispatch } = view;
|
||||
@@ -195,7 +198,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLink = () => {
|
||||
const handleRemoveLink = React.useCallback(() => {
|
||||
discardRef.current = true;
|
||||
|
||||
const { state, dispatch } = view;
|
||||
@@ -203,9 +206,12 @@ const LinkEditor: React.FC<Props> = ({
|
||||
dispatch(state.tr.removeMark(from, to, mark));
|
||||
}
|
||||
|
||||
onRemoveLink?.();
|
||||
if (onRemoveLink) {
|
||||
onRemoveLink();
|
||||
}
|
||||
|
||||
view.focus();
|
||||
};
|
||||
}, [view, mark, from, to, onRemoveLink]);
|
||||
|
||||
const isInternal = isInternalUrl(query);
|
||||
const hasResults = !!results.length;
|
||||
|
||||
@@ -184,7 +184,16 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
setItems(items);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
|
||||
}, [
|
||||
t,
|
||||
actorId,
|
||||
loading,
|
||||
search,
|
||||
users,
|
||||
documents,
|
||||
maxResultsInSection,
|
||||
collections,
|
||||
]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
|
||||
@@ -87,7 +87,7 @@ function useIsActive(state: EditorState) {
|
||||
|
||||
const slice = selection.content();
|
||||
const fragment = slice.content;
|
||||
const nodes = (fragment as any).content;
|
||||
const nodes = (fragment as unknown).content;
|
||||
|
||||
return some(nodes, (n) => n.content.size);
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ export default class PasteHandler extends Extension {
|
||||
simplifyDiff: true,
|
||||
}).mapping;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
// oxlint-disable-next-line no-console
|
||||
console.warn("Failed to recreate transform: ", err);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -448,7 +448,7 @@ export class Editor extends React.PureComponent<
|
||||
step.mark.type.name === this.schema.marks.comment.name
|
||||
);
|
||||
|
||||
const self = this; // eslint-disable-line
|
||||
const self = this; // oxlint-disable-line
|
||||
const view = new EditorView(this.elementRef.current, {
|
||||
handleDOMEvents: {
|
||||
blur: this.handleEditorBlur,
|
||||
@@ -504,12 +504,24 @@ export class Editor extends React.PureComponent<
|
||||
return;
|
||||
}
|
||||
|
||||
function isVisible(element: HTMLElement | null) {
|
||||
for (let e = element; e; e = e.parentElement) {
|
||||
const s = getComputedStyle(e);
|
||||
if (s.display === "none" || s.opacity === "0") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
this.mutationObserver?.disconnect();
|
||||
this.mutationObserver = observe(
|
||||
hash,
|
||||
(element) => {
|
||||
element.scrollIntoView();
|
||||
if (isVisible(element)) {
|
||||
element.scrollIntoView();
|
||||
}
|
||||
},
|
||||
this.elementRef.current || undefined
|
||||
);
|
||||
|
||||
@@ -73,13 +73,13 @@ export default function useCollectionTrees(): NavigationNode[] {
|
||||
parent: null,
|
||||
};
|
||||
|
||||
return addParent(addCollectionId(addDepth(addType(collectionNode))));
|
||||
return addParent(addCollectionId(addDepth(addType(collectionNode), 1)));
|
||||
};
|
||||
|
||||
const key = collections.orderedData.map((o) => o.documents?.length).join("-");
|
||||
const collectionTrees = useMemo(
|
||||
() => collections.orderedData.map(getCollectionTree),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
[collections.orderedData, key]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useRegisterActions } from "kbar";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { actionToKBar } from "~/actions";
|
||||
import { Action } from "~/types";
|
||||
import { actionToKBar, actionV2ToKBar } from "~/actions";
|
||||
import { Action, ActionV2Variant } from "~/types";
|
||||
import useActionContext from "./useActionContext";
|
||||
|
||||
/**
|
||||
@@ -12,7 +12,7 @@ import useActionContext from "./useActionContext";
|
||||
* @param actions actions to make available
|
||||
*/
|
||||
export default function useCommandBarActions(
|
||||
actions: Action[],
|
||||
actions: (Action | ActionV2Variant)[],
|
||||
additionalDeps: React.DependencyList = []
|
||||
) {
|
||||
const location = useLocation();
|
||||
@@ -21,7 +21,11 @@ export default function useCommandBarActions(
|
||||
});
|
||||
|
||||
const registerable = flattenDeep(
|
||||
actions.map((action) => actionToKBar(action, context))
|
||||
actions.map((action) =>
|
||||
"variant" in action
|
||||
? actionV2ToKBar(action, context)
|
||||
: actionToKBar(action, context)
|
||||
)
|
||||
);
|
||||
|
||||
useRegisterActions(registerable, [
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { formatNumber } from "~/utils/language";
|
||||
import useUserLocale from "./useUserLocale";
|
||||
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
|
||||
|
||||
/**
|
||||
* Hook that returns a function to format numbers based on the user's locale.
|
||||
*
|
||||
* @returns A function that formats numbers
|
||||
*/
|
||||
export function useFormatNumber() {
|
||||
const language = useUserLocale();
|
||||
return (input: number) =>
|
||||
language
|
||||
? formatNumber(input, unicodeCLDRtoBCP47(language))
|
||||
: input.toString();
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export const useLocaleTime = ({
|
||||
"MMMM do, yyyy h:mm a";
|
||||
// @ts-expect-error fallback to formatLocaleLong
|
||||
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
|
||||
const [_, setMinutesMounted] = useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const [, setMinutesMounted] = useState(0);
|
||||
const callback = useRef<() => void>();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
useMenuState as reakitUseMenuState,
|
||||
MenuStateReturn,
|
||||
} from "reakit/Menu";
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DocumentsSection } from "~/actions/sections";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ActionV2 } from "~/types";
|
||||
import { useComputed } from "./useComputed";
|
||||
|
||||
type Props = {
|
||||
/** The document to which the templates will be applied */
|
||||
@@ -22,7 +23,7 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook provides a memoized list of menu items for both collection-specific
|
||||
* This hook provides a memoized list of actions for both collection-specific
|
||||
* templates and workspace-wide templates. It filters templates based on whether
|
||||
* they are published and organizes them into appropriate sections.
|
||||
*
|
||||
@@ -56,32 +57,34 @@ export function useTemplateMenuActions({ document, onSelectTemplate }: Props) {
|
||||
[user, onSelectTemplate]
|
||||
);
|
||||
|
||||
const templates = documents.templates.filter(
|
||||
(template) => template.publishedAt
|
||||
);
|
||||
return useComputed(() => {
|
||||
if (!onSelectTemplate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const collectionTemplatesActions = templates
|
||||
.filter(
|
||||
(template) =>
|
||||
!template.isWorkspaceTemplate &&
|
||||
template.collectionId === document.collectionId
|
||||
)
|
||||
.map(templateToAction);
|
||||
const templates = documents.templates.filter(
|
||||
(template) => template.publishedAt
|
||||
);
|
||||
|
||||
const workspaceTemplatesActions = templates
|
||||
.filter((tmpl) => tmpl.isWorkspaceTemplate)
|
||||
.map(templateToAction);
|
||||
const collectionTemplatesActions = templates
|
||||
.filter(
|
||||
(template) =>
|
||||
!template.isWorkspaceTemplate &&
|
||||
template.collectionId === document.collectionId
|
||||
)
|
||||
.map(templateToAction);
|
||||
|
||||
if (!onSelectTemplate) {
|
||||
return [];
|
||||
}
|
||||
const workspaceTemplatesActions = templates
|
||||
.filter((tmpl) => tmpl.isWorkspaceTemplate)
|
||||
.map(templateToAction);
|
||||
|
||||
return [
|
||||
...collectionTemplatesActions,
|
||||
ActionV2Separator,
|
||||
createActionV2Group({
|
||||
name: t("Workspace"),
|
||||
actions: workspaceTemplatesActions,
|
||||
}),
|
||||
];
|
||||
return [
|
||||
...collectionTemplatesActions,
|
||||
ActionV2Separator,
|
||||
createActionV2Group({
|
||||
name: t("Workspace"),
|
||||
actions: workspaceTemplatesActions,
|
||||
}),
|
||||
];
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import Document from "~/models/Document";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
type Props = {
|
||||
/** The document to which the templates will be applied */
|
||||
document: Document;
|
||||
/** Callback to handle when a template is selected */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook provides a memoized list of menu items for both collection-specific
|
||||
* templates and workspace-wide templates. It filters templates based on whether
|
||||
* they are published and organizes them into appropriate sections.
|
||||
*
|
||||
* Collection-specific templates are displayed first, followed by workspace templates
|
||||
* with a separator in between (if both types exist).
|
||||
*
|
||||
* @returns An array of MenuItem objects representing templates that can be applied
|
||||
* to the current document. Returns an empty array if no callback is provided.
|
||||
*/
|
||||
export function useTemplateMenuItems({ document, onSelectTemplate }: Props) {
|
||||
const user = useCurrentUser();
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const templateToMenuItem = useCallback(
|
||||
(template: Document): MenuItem => ({
|
||||
type: "button",
|
||||
title: TextHelper.replaceTemplateVariables(
|
||||
template.titleWithDefault,
|
||||
user
|
||||
),
|
||||
icon: template.icon ? (
|
||||
<Icon value={template.icon} color={template.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
onClick: () => onSelectTemplate?.(template),
|
||||
}),
|
||||
[user, onSelectTemplate]
|
||||
);
|
||||
|
||||
const templates = documents.templates.filter(
|
||||
(template) => template.publishedAt
|
||||
);
|
||||
|
||||
const collectionItems = templates
|
||||
.filter(
|
||||
(template) =>
|
||||
!template.isWorkspaceTemplate &&
|
||||
template.collectionId === document.collectionId
|
||||
)
|
||||
.map(templateToMenuItem);
|
||||
|
||||
const workspaceTemplates = templates
|
||||
.filter((tmpl) => tmpl.isWorkspaceTemplate)
|
||||
.map(templateToMenuItem);
|
||||
|
||||
const workspaceItems: MenuItem[] = useMemo(
|
||||
() =>
|
||||
workspaceTemplates.length
|
||||
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
|
||||
: [],
|
||||
[t, workspaceTemplates]
|
||||
);
|
||||
|
||||
if (!onSelectTemplate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collectionItems
|
||||
? workspaceItems.length
|
||||
? [
|
||||
...collectionItems,
|
||||
{ type: "separator" } as MenuItem,
|
||||
...workspaceItems,
|
||||
]
|
||||
: collectionItems
|
||||
: workspaceItems;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export default function useThrottledCallback<T extends (...args: any[]) => any>(
|
||||
) {
|
||||
const handler = React.useMemo(
|
||||
() => throttle<T>(fn, wait, options),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
dependencies
|
||||
);
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
// oxlint-disable-next-line import/no-unresolved
|
||||
import "vite/modulepreload-polyfill";
|
||||
import { LazyMotion } from "framer-motion";
|
||||
import { KBarProvider } from "kbar";
|
||||
|
||||
+10
-28
@@ -1,9 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import {
|
||||
navigateToProfileSettings,
|
||||
navigateToAccountPreferences,
|
||||
@@ -16,56 +13,41 @@ import {
|
||||
logout,
|
||||
} from "~/actions/definitions/navigation";
|
||||
import { changeTheme } from "~/actions/definitions/settings";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import separator from "~/menus/separator";
|
||||
import { ActionV2Separator } from "~/actions";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const AccountMenu: React.FC = ({ children }: Props) => {
|
||||
const menu = useMenuState({
|
||||
placement: "bottom-end",
|
||||
modal: true,
|
||||
});
|
||||
const { ui } = useStores();
|
||||
const { theme } = ui;
|
||||
const previousTheme = usePrevious(theme);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (theme !== previousTheme) {
|
||||
menu.hide();
|
||||
}
|
||||
}, [menu, theme, previousTheme]);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
openKeyboardShortcuts,
|
||||
openDocumentation,
|
||||
openAPIDocumentation,
|
||||
separator(),
|
||||
ActionV2Separator,
|
||||
openChangelog,
|
||||
openFeedbackUrl,
|
||||
openBugReportUrl,
|
||||
changeTheme,
|
||||
navigateToProfileSettings,
|
||||
navigateToAccountPreferences,
|
||||
separator(),
|
||||
ActionV2Separator,
|
||||
logout,
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>{children}</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Account")}>
|
||||
<Template {...menu} items={undefined} actions={actions} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu action={rootAction} align="end" ariaLabel={t("Account")}>
|
||||
{children}
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { InternalLinkActionV2 } from "~/types";
|
||||
|
||||
type Props = {
|
||||
items: MenuInternalLink[];
|
||||
actions: InternalLinkActionV2[];
|
||||
};
|
||||
|
||||
export default function BreadcrumbMenu({ items }: Props) {
|
||||
export default function BreadcrumbMenu({ actions }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
placement: "bottom",
|
||||
});
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show path to document")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Path to document")}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("Show path to document")}>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
|
||||
type Props = {
|
||||
onMembers: () => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
function CollectionGroupMemberMenu({ onMembers, onRemove }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Group member options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "button",
|
||||
title: t("Members"),
|
||||
onClick: onMembers,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Remove"),
|
||||
dangerous: true,
|
||||
onClick: onRemove,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CollectionGroupMemberMenu);
|
||||
+103
-162
@@ -1,9 +1,7 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
NewDocumentIcon,
|
||||
ImportIcon,
|
||||
ExportIcon,
|
||||
AlphabeticalSortIcon,
|
||||
AlphabeticalReverseSortIcon,
|
||||
ManualSortIcon,
|
||||
@@ -12,16 +10,17 @@ import {
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { toast } from "sonner";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import Collection from "~/models/Collection";
|
||||
import ContextMenu, { Placement } from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import ExportDialog from "~/components/ExportDialog";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import {
|
||||
ActionV2Separator,
|
||||
createActionV2,
|
||||
createActionV2WithChildren,
|
||||
} from "~/actions";
|
||||
import {
|
||||
deleteCollection,
|
||||
editCollection,
|
||||
@@ -34,21 +33,20 @@ import {
|
||||
restoreCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
createDocument,
|
||||
exportCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
import { ActiveCollectionSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
placement?: Placement;
|
||||
modal?: boolean;
|
||||
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
|
||||
align?: "start" | "end";
|
||||
neutral?: boolean;
|
||||
onRename?: () => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
@@ -56,19 +54,13 @@ type Props = {
|
||||
|
||||
function CollectionMenu({
|
||||
collection,
|
||||
label,
|
||||
modal = true,
|
||||
placement,
|
||||
align,
|
||||
neutral,
|
||||
onRename,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const menu = useMenuState({
|
||||
modal,
|
||||
placement,
|
||||
});
|
||||
const team = useCurrentTeam();
|
||||
const { documents, dialogs, subscriptions } = useStores();
|
||||
const { documents, subscriptions } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const file = React.useRef<HTMLInputElement>(null);
|
||||
@@ -90,42 +82,16 @@ function CollectionMenu({
|
||||
}
|
||||
}, [subscriptionLoading, subscriptionLoaded, loadSubscription]);
|
||||
|
||||
const handleExport = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Export collection"),
|
||||
content: (
|
||||
<ExportDialog
|
||||
collection={collection}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [collection, dialogs, t]);
|
||||
|
||||
const handleNewDocument = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
history.push(newDocumentPath(collection.id));
|
||||
},
|
||||
[history, collection.id]
|
||||
);
|
||||
|
||||
const stopPropagation = React.useCallback((ev: React.SyntheticEvent) => {
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleImportDocument = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// simulate a click on the file upload input element
|
||||
if (file.current) {
|
||||
file.current.click();
|
||||
}
|
||||
},
|
||||
[file]
|
||||
);
|
||||
const handleImportDocument = React.useCallback(() => {
|
||||
// simulate a click on the file upload input element
|
||||
if (file.current) {
|
||||
file.current.click();
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
const handleFilePicked = React.useCallback(
|
||||
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -153,18 +119,17 @@ function CollectionMenu({
|
||||
);
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(field: string, direction = "asc") => {
|
||||
menu.hide();
|
||||
return collection.save({
|
||||
(field: string, direction = "asc") =>
|
||||
collection.save({
|
||||
sort: {
|
||||
field,
|
||||
direction,
|
||||
},
|
||||
});
|
||||
},
|
||||
[collection, menu]
|
||||
}),
|
||||
[collection]
|
||||
);
|
||||
|
||||
const can = usePolicy(collection);
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeCollectionId: collection.id,
|
||||
@@ -172,48 +137,12 @@ function CollectionMenu({
|
||||
|
||||
const sortAlphabetical = collection.sort.field === "title";
|
||||
const sortDir = collection.sort.direction;
|
||||
const can = usePolicy(collection);
|
||||
const canUserInTeam = usePolicy(team);
|
||||
const items: MenuItem[] = React.useMemo(
|
||||
() => [
|
||||
actionToMenuItem(restoreCollection, context),
|
||||
actionToMenuItem(starCollection, context),
|
||||
actionToMenuItem(unstarCollection, context),
|
||||
actionToMenuItem(subscribeCollection, context),
|
||||
actionToMenuItem(unsubscribeCollection, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("New document"),
|
||||
visible: can.createDocument,
|
||||
onClick: handleNewDocument,
|
||||
icon: <NewDocumentIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Import document"),
|
||||
visible: can.createDocument,
|
||||
onClick: handleImportDocument,
|
||||
icon: <ImportIcon />,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Rename")}…`,
|
||||
visible: !!can.update && !!onRename,
|
||||
onClick: () => onRename?.(),
|
||||
icon: <InputIcon />,
|
||||
},
|
||||
actionToMenuItem(editCollection, context),
|
||||
actionToMenuItem(editCollectionPermissions, context),
|
||||
actionToMenuItem(createTemplate, context),
|
||||
{
|
||||
type: "submenu",
|
||||
title: t("Sort in sidebar"),
|
||||
|
||||
const sortAction = React.useMemo(
|
||||
() =>
|
||||
createActionV2WithChildren({
|
||||
name: t("Sort in sidebar"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: can.update,
|
||||
icon: sortAlphabetical ? (
|
||||
sortDir === "asc" ? (
|
||||
@@ -224,61 +153,79 @@ function CollectionMenu({
|
||||
) : (
|
||||
<ManualSortIcon />
|
||||
),
|
||||
items: [
|
||||
{
|
||||
type: "button",
|
||||
title: t("A-Z sort"),
|
||||
onClick: () => handleChangeSort("title", "asc"),
|
||||
children: [
|
||||
createActionV2({
|
||||
name: t("A-Z sort"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: can.update,
|
||||
selected: sortAlphabetical && sortDir === "asc",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Z-A sort"),
|
||||
onClick: () => handleChangeSort("title", "desc"),
|
||||
perform: () => handleChangeSort("title", "asc"),
|
||||
}),
|
||||
createActionV2({
|
||||
name: t("Z-A sort"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: can.update,
|
||||
selected: sortAlphabetical && sortDir === "desc",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Manual sort"),
|
||||
onClick: () => handleChangeSort("index"),
|
||||
perform: () => handleChangeSort("title", "desc"),
|
||||
}),
|
||||
createActionV2({
|
||||
name: t("Manual sort"),
|
||||
section: ActiveCollectionSection,
|
||||
visible: can.update,
|
||||
selected: !sortAlphabetical,
|
||||
},
|
||||
perform: () => handleChangeSort("index"),
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && canUserInTeam.createExport && can.export),
|
||||
onClick: handleExport,
|
||||
icon: <ExportIcon />,
|
||||
},
|
||||
actionToMenuItem(archiveCollection, context),
|
||||
actionToMenuItem(searchInCollection, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(deleteCollection, context),
|
||||
}),
|
||||
[t, can.update, sortAlphabetical, sortDir, handleChangeSort]
|
||||
);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
restoreCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
ActionV2Separator,
|
||||
createDocument,
|
||||
createActionV2({
|
||||
name: t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ImportIcon />,
|
||||
visible: can.createDocument,
|
||||
perform: handleImportDocument,
|
||||
}),
|
||||
ActionV2Separator,
|
||||
createActionV2({
|
||||
name: `${t("Rename")}…`,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <InputIcon />,
|
||||
visible: !!can.update && !!onRename,
|
||||
perform: () => requestAnimationFrame(() => onRename?.()),
|
||||
}),
|
||||
editCollection,
|
||||
editCollectionPermissions,
|
||||
createTemplate,
|
||||
sortAction,
|
||||
exportCollection,
|
||||
archiveCollection,
|
||||
searchInCollection,
|
||||
ActionV2Separator,
|
||||
deleteCollection,
|
||||
],
|
||||
[
|
||||
t,
|
||||
onRename,
|
||||
collection,
|
||||
can.createDocument,
|
||||
can.update,
|
||||
can.export,
|
||||
handleNewDocument,
|
||||
sortAction,
|
||||
handleImportDocument,
|
||||
context,
|
||||
sortAlphabetical,
|
||||
canUserInTeam.createExport,
|
||||
handleExport,
|
||||
handleChangeSort,
|
||||
onRename,
|
||||
]
|
||||
);
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -295,25 +242,19 @@ function CollectionMenu({
|
||||
/>
|
||||
</label>
|
||||
</VisuallyHidden.Root>
|
||||
{label ? (
|
||||
<MenuButton {...menu} onPointerEnter={handlePointerEnter}>
|
||||
{label}
|
||||
</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton
|
||||
aria-label={t("Show menu")}
|
||||
{...menu}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
context={context}
|
||||
align={align}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
aria-label={t("Collection")}
|
||||
ariaLabel={t("Collection menu")}
|
||||
>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
<OverflowMenuButton
|
||||
neutral={neutral}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+47
-70
@@ -1,26 +1,24 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, EditIcon } from "outline-icons";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Comment from "~/models/Comment";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import {
|
||||
deleteCommentFactory,
|
||||
resolveCommentFactory,
|
||||
unresolveCommentFactory,
|
||||
viewCommentReactionsFactory,
|
||||
} from "~/actions/definitions/comments";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { commentPath, urlify } from "~/utils/routeHelpers";
|
||||
import { ActionV2Separator, createActionV2 } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
type Props = {
|
||||
/** The comment to associate with the menu */
|
||||
@@ -42,13 +40,9 @@ function CommentMenu({
|
||||
onUpdate,
|
||||
className,
|
||||
}: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(comment);
|
||||
const context = useActionContext({ isContextMenu: true });
|
||||
const document = documents.get(comment.documentId);
|
||||
|
||||
const handleCopyLink = useCallback(() => {
|
||||
@@ -58,65 +52,48 @@ function CommentMenu({
|
||||
}
|
||||
}, [t, document, comment]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
createActionV2({
|
||||
name: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: ActiveDocumentSection,
|
||||
visible: can.update && !comment.isResolved,
|
||||
perform: onEdit,
|
||||
}),
|
||||
resolveCommentFactory({
|
||||
comment,
|
||||
onResolve: () => onUpdate({ resolved: true }),
|
||||
}),
|
||||
unresolveCommentFactory({
|
||||
comment,
|
||||
onUnresolve: () => onUpdate({ resolved: false }),
|
||||
}),
|
||||
viewCommentReactionsFactory({
|
||||
comment,
|
||||
}),
|
||||
createActionV2({
|
||||
name: t("Copy link"),
|
||||
icon: <CopyIcon />,
|
||||
section: ActiveDocumentSection,
|
||||
perform: handleCopyLink,
|
||||
}),
|
||||
ActionV2Separator,
|
||||
deleteCommentFactory({ comment, onDelete }),
|
||||
],
|
||||
[t, comment, can.update, onEdit, onUpdate, onDelete, handleCopyLink]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EventBoundary>
|
||||
<OverflowMenuButton
|
||||
aria-label={t("Show menu")}
|
||||
className={className}
|
||||
{...menu}
|
||||
/>
|
||||
</EventBoundary>
|
||||
{menu.visible && (
|
||||
<ContextMenu {...menu} aria-label={t("Comment options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
onClick: onEdit,
|
||||
visible: can.update && !comment.isResolved,
|
||||
},
|
||||
actionToMenuItem(
|
||||
resolveCommentFactory({
|
||||
comment,
|
||||
onResolve: () => onUpdate({ resolved: true }),
|
||||
}),
|
||||
context
|
||||
),
|
||||
actionToMenuItem(
|
||||
unresolveCommentFactory({
|
||||
comment,
|
||||
onUnresolve: () => onUpdate({ resolved: false }),
|
||||
}),
|
||||
context
|
||||
),
|
||||
actionToMenuItem(
|
||||
viewCommentReactionsFactory({
|
||||
comment,
|
||||
}),
|
||||
context
|
||||
),
|
||||
{
|
||||
type: "button",
|
||||
icon: <CopyIcon />,
|
||||
title: t("Copy link"),
|
||||
onClick: handleCopyLink,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(
|
||||
deleteCommentFactory({ comment, onDelete }),
|
||||
context
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
ariaLabel={t("Comment options")}
|
||||
>
|
||||
<OverflowMenuButton className={className} />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+185
-384
@@ -1,33 +1,17 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import noop from "lodash/noop";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
EditIcon,
|
||||
InputIcon,
|
||||
RestoreIcon,
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
} from "outline-icons";
|
||||
import { InputIcon, SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { SubscriptionType, UserPreference } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import Document from "~/models/Document";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import Switch from "~/components/Switch";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import { ActionV2Separator, createActionV2 } from "~/actions";
|
||||
import {
|
||||
pinDocument,
|
||||
createTemplateFromDocument,
|
||||
@@ -55,35 +39,36 @@ import {
|
||||
searchInDocument,
|
||||
leaveDocument,
|
||||
moveTemplate,
|
||||
restoreDocument,
|
||||
restoreDocumentToCollection,
|
||||
editDocument,
|
||||
applyTemplateFactory,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTemplateMenuItems } from "~/hooks/useTemplateMenuItems";
|
||||
import { MenuItem, MenuItemButton } from "~/types";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { MenuContext, useMenuContext } from "./MenuContext";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import { useTemplateMenuActions } from "~/hooks/useTemplateMenuActions";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { MenuSeparator } from "~/components/primitives/components/Menu";
|
||||
|
||||
type Props = {
|
||||
/** Document for which the menu is to be shown */
|
||||
document: Document;
|
||||
isRevision?: boolean;
|
||||
/** Alignment w.r.t trigger - defaults to start */
|
||||
align?: "start" | "end";
|
||||
/** Trigger's variant - renders nude variant if unset */
|
||||
neutral?: boolean;
|
||||
/** Pass true if the document is currently being displayed */
|
||||
showDisplayOptions?: boolean;
|
||||
/** Whether to display menu as a modal */
|
||||
modal?: boolean;
|
||||
/** Whether to include the option of toggling embeds as menu item */
|
||||
showToggleEmbeds?: boolean;
|
||||
showPin?: boolean;
|
||||
/** Label for menu button */
|
||||
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
|
||||
/** Invoked when the "Find and replace" menu item is clicked */
|
||||
onFindAndReplace?: () => void;
|
||||
/** Callback when a template is selected to apply its content to the document */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
@@ -93,16 +78,24 @@ type Props = {
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
type MenuTriggerProps = {
|
||||
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
|
||||
onTrigger: () => void;
|
||||
};
|
||||
|
||||
const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
|
||||
function DocumentMenu({
|
||||
document,
|
||||
align,
|
||||
neutral,
|
||||
showToggleEmbeds,
|
||||
showDisplayOptions,
|
||||
onSelectTemplate,
|
||||
onRename,
|
||||
onOpen,
|
||||
onClose,
|
||||
onFindAndReplace,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const isMobile = useMobile();
|
||||
const can = usePolicy(document);
|
||||
|
||||
const { subscriptions, pins } = useStores();
|
||||
const { model: document, menuState } = useMenuContext<Document>();
|
||||
|
||||
const {
|
||||
loading: auxDataLoading,
|
||||
@@ -134,106 +127,6 @@ const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
|
||||
}
|
||||
}, [auxDataLoading, auxDataLoaded, auxDataRequest, document]);
|
||||
|
||||
return label ? (
|
||||
<MenuButton
|
||||
{...menuState}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onClick={onTrigger}
|
||||
>
|
||||
{label}
|
||||
</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton
|
||||
aria-label={t("Show document menu")}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onClick={onTrigger}
|
||||
{...menuState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type MenuContentProps = {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onFindAndReplace?: () => void;
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
onRename?: () => void;
|
||||
showDisplayOptions?: boolean;
|
||||
showToggleEmbeds?: boolean;
|
||||
};
|
||||
|
||||
const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
onOpen,
|
||||
onClose,
|
||||
onFindAndReplace,
|
||||
onSelectTemplate,
|
||||
onRename,
|
||||
showDisplayOptions,
|
||||
showToggleEmbeds,
|
||||
}) {
|
||||
const user = useCurrentUser();
|
||||
const { model: document, menuState } = useMenuContext<Document>();
|
||||
const can = usePolicy(document);
|
||||
const { t } = useTranslation();
|
||||
const { policies, collections } = useStores();
|
||||
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId: document.collectionId ?? undefined,
|
||||
});
|
||||
|
||||
const isMobile = useMobile();
|
||||
|
||||
const handleRestore = React.useCallback(
|
||||
async (
|
||||
ev: React.SyntheticEvent,
|
||||
options?: {
|
||||
collectionId: string;
|
||||
}
|
||||
) => {
|
||||
await document.restore(options);
|
||||
toast.success(
|
||||
t("{{ documentName }} restored", {
|
||||
documentName: capitalize(document.noun),
|
||||
})
|
||||
);
|
||||
},
|
||||
[t, document]
|
||||
);
|
||||
|
||||
const restoreItems = React.useMemo(
|
||||
() => [
|
||||
...collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
if (can.createDocument) {
|
||||
filtered.push({
|
||||
type: "button",
|
||||
onClick: (ev) =>
|
||||
handleRestore(ev, {
|
||||
collectionId: collection.id,
|
||||
}),
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
title: collection.name,
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, []),
|
||||
],
|
||||
[collections.orderedData, handleRestore, policies]
|
||||
);
|
||||
|
||||
const templateMenuItems = useTemplateMenuItems({
|
||||
document,
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
const handleEmbedsToggle = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked) {
|
||||
@@ -255,255 +148,163 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
[user, document]
|
||||
);
|
||||
|
||||
return !isEmpty(can) ? (
|
||||
<ContextMenu
|
||||
{...menuState}
|
||||
aria-label={t("Document options")}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Template
|
||||
{...menuState}
|
||||
items={[
|
||||
{
|
||||
type: "button",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
!!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive),
|
||||
onClick: (ev) => handleRestore(ev),
|
||||
icon: <RestoreIcon />,
|
||||
},
|
||||
{
|
||||
type: "submenu",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive) &&
|
||||
restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
top: -40,
|
||||
},
|
||||
icon: <RestoreIcon />,
|
||||
hover: true,
|
||||
items: [
|
||||
{
|
||||
type: "heading",
|
||||
title: t("Choose a collection"),
|
||||
},
|
||||
...restoreItems,
|
||||
],
|
||||
},
|
||||
actionToMenuItem(starDocument, context),
|
||||
actionToMenuItem(unstarDocument, context),
|
||||
{
|
||||
...actionToMenuItem(subscribeDocument, context),
|
||||
disabled: collection?.isSubscribed,
|
||||
tooltip: collection?.isSubscribed
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined,
|
||||
} as MenuItemButton,
|
||||
{
|
||||
...actionToMenuItem(unsubscribeDocument, context),
|
||||
disabled: collection?.isSubscribed,
|
||||
tooltip: collection?.isSubscribed
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined,
|
||||
} as MenuItemButton,
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Find and replace")}…`,
|
||||
visible: !!onFindAndReplace && isMobile,
|
||||
onClick: () => onFindAndReplace?.(),
|
||||
icon: <SearchIcon />,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
title: t("Edit"),
|
||||
to: documentEditPath(document),
|
||||
visible:
|
||||
!!can.update && user.separateEditMode && !document.template,
|
||||
icon: <EditIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Rename")}…`,
|
||||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||||
onClick: () => onRename?.(),
|
||||
icon: <InputIcon />,
|
||||
},
|
||||
actionToMenuItem(shareDocument, context),
|
||||
actionToMenuItem(createNestedDocument, context),
|
||||
actionToMenuItem(importDocument, context),
|
||||
actionToMenuItem(createTemplateFromDocument, context),
|
||||
actionToMenuItem(duplicateDocument, context),
|
||||
actionToMenuItem(publishDocument, context),
|
||||
actionToMenuItem(unpublishDocument, context),
|
||||
actionToMenuItem(archiveDocument, context),
|
||||
actionToMenuItem(moveDocument, context),
|
||||
actionToMenuItem(moveTemplate, context),
|
||||
{
|
||||
type: "submenu",
|
||||
title: t("Apply template"),
|
||||
icon: <ShapesIcon />,
|
||||
items: templateMenuItems,
|
||||
},
|
||||
actionToMenuItem(pinDocument, context),
|
||||
actionToMenuItem(createDocumentFromTemplate, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(openDocumentComments, context),
|
||||
actionToMenuItem(openDocumentHistory, context),
|
||||
actionToMenuItem(openDocumentInsights, context),
|
||||
actionToMenuItem(downloadDocument, context),
|
||||
actionToMenuItem(copyDocument, context),
|
||||
actionToMenuItem(printDocument, context),
|
||||
actionToMenuItem(searchInDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(deleteDocument, context),
|
||||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||||
actionToMenuItem(leaveDocument, context),
|
||||
]}
|
||||
/>
|
||||
{(showDisplayOptions || showToggleEmbeds) && can.update && (
|
||||
<>
|
||||
<Separator />
|
||||
<DisplayOptions>
|
||||
{showToggleEmbeds && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Enable embeds")}
|
||||
labelPosition="left"
|
||||
checked={!document.embedsDisabled}
|
||||
onChange={handleEmbedsToggle}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
{showDisplayOptions && !isMobile && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Full width")}
|
||||
labelPosition="left"
|
||||
checked={document.fullWidth}
|
||||
onChange={handleFullWidthToggle}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
</DisplayOptions>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
) : null;
|
||||
});
|
||||
|
||||
function DocumentMenu({
|
||||
document,
|
||||
modal = true,
|
||||
showToggleEmbeds,
|
||||
showDisplayOptions,
|
||||
onSelectTemplate,
|
||||
label,
|
||||
onRename,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { collections, documents } = useStores();
|
||||
const menuState = useMenuState({
|
||||
modal,
|
||||
unstable_preventOverflow: true,
|
||||
unstable_fixed: true,
|
||||
unstable_flip: true,
|
||||
});
|
||||
const history = useHistory();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [isMenuVisible, showMenu] = useBoolean(false);
|
||||
const file = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
const stopPropagation = React.useCallback((ev: React.SyntheticEvent) => {
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleFilePicked = React.useCallback(
|
||||
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
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
const importedDocument = await documents.import(
|
||||
file,
|
||||
document.id,
|
||||
collection.id,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(importedDocument.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
ev.target.value = "";
|
||||
}
|
||||
const handleInsightsToggle = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
void document.save({ insightsEnabled: checked });
|
||||
},
|
||||
[history, collection, documents, document.id]
|
||||
[document]
|
||||
);
|
||||
|
||||
const templateMenuActions = useTemplateMenuActions({
|
||||
document,
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
restoreDocument,
|
||||
restoreDocumentToCollection,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
createActionV2({
|
||||
name: `${t("Find and replace")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: !!onFindAndReplace && isMobile,
|
||||
perform: () => onFindAndReplace?.(),
|
||||
}),
|
||||
ActionV2Separator,
|
||||
editDocument,
|
||||
createActionV2({
|
||||
name: `${t("Rename")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <InputIcon />,
|
||||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||||
perform: () => requestAnimationFrame(() => onRename?.()),
|
||||
}),
|
||||
shareDocument,
|
||||
createNestedDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory({ actions: templateMenuActions }),
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
ActionV2Separator,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
ActionV2Separator,
|
||||
deleteDocument,
|
||||
permanentlyDeleteDocument,
|
||||
leaveDocument,
|
||||
],
|
||||
[
|
||||
t,
|
||||
isMobile,
|
||||
templateMenuActions,
|
||||
can.update,
|
||||
user.separateEditMode,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId: document.collectionId ?? undefined,
|
||||
});
|
||||
|
||||
const toggleSwitches = React.useMemo<React.ReactNode>(() => {
|
||||
if (!can.update || !(showDisplayOptions || showToggleEmbeds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<DisplayOptions>
|
||||
{can.updateInsights && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Enable viewer insights")}
|
||||
labelPosition="left"
|
||||
checked={document.insightsEnabled}
|
||||
onChange={handleInsightsToggle}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
{showToggleEmbeds && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Enable embeds")}
|
||||
labelPosition="left"
|
||||
checked={!document.embedsDisabled}
|
||||
onChange={handleEmbedsToggle}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
{showDisplayOptions && !isMobile && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Full width")}
|
||||
labelPosition="left"
|
||||
checked={document.fullWidth}
|
||||
onChange={handleFullWidthToggle}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
</DisplayOptions>
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
t,
|
||||
can.update,
|
||||
document.embedsDisabled,
|
||||
document.fullWidth,
|
||||
document.insightsEnabled,
|
||||
isMobile,
|
||||
showDisplayOptions,
|
||||
showToggleEmbeds,
|
||||
handleEmbedsToggle,
|
||||
handleFullWidthToggle,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisuallyHidden.Root>
|
||||
<label>
|
||||
{t("Import document")}
|
||||
<input
|
||||
type="file"
|
||||
ref={file}
|
||||
onChange={handleFilePicked}
|
||||
onClick={stopPropagation}
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</label>
|
||||
</VisuallyHidden.Root>
|
||||
<MenuContext.Provider value={{ model: document, menuState }}>
|
||||
<MenuTrigger label={label} onTrigger={showMenu} />
|
||||
{isMenuVisible ? (
|
||||
<MenuContent
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
onRename={onRename}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
showDisplayOptions={showDisplayOptions}
|
||||
showToggleEmbeds={showToggleEmbeds}
|
||||
/>
|
||||
) : null}
|
||||
</MenuContext.Provider>
|
||||
</>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
context={context}
|
||||
align={align}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
ariaLabel={t("Document options")}
|
||||
append={toggleSwitches}
|
||||
>
|
||||
<OverflowMenuButton
|
||||
neutral={neutral}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { useMemo } from "react";
|
||||
import { createActionV2 } from "~/actions";
|
||||
import { GroupSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
onRemove: () => void;
|
||||
@@ -11,26 +13,25 @@ type Props = {
|
||||
|
||||
function GroupMemberMenu({ onRemove }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
createActionV2({
|
||||
name: t("Remove"),
|
||||
section: GroupSection,
|
||||
dangerous: true,
|
||||
perform: onRemove,
|
||||
}),
|
||||
],
|
||||
[t, onRemove]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Group member options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "button",
|
||||
dangerous: true,
|
||||
title: t("Remove"),
|
||||
onClick: onRemove,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("Group member options")}>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+65
-53
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Group from "~/models/Group";
|
||||
import {
|
||||
@@ -8,12 +8,17 @@ import {
|
||||
EditGroupDialog,
|
||||
ViewGroupMembersDialog,
|
||||
} from "~/scenes/Settings/components/GroupDialogs";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
ActionV2Separator,
|
||||
createActionV2,
|
||||
createExternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { GroupSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
@@ -22,9 +27,6 @@ type Props = {
|
||||
function GroupMenu({ group }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleViewMembers = useCallback(() => {
|
||||
@@ -52,52 +54,62 @@ function GroupMenu({ group }: Props) {
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
createActionV2({
|
||||
name: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(group && can.read),
|
||||
perform: handleViewMembers,
|
||||
}),
|
||||
ActionV2Separator,
|
||||
createActionV2({
|
||||
name: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(group && can.update),
|
||||
perform: handleEditGroup,
|
||||
}),
|
||||
createActionV2({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(group && can.delete),
|
||||
dangerous: true,
|
||||
perform: handleDeleteGroup,
|
||||
}),
|
||||
ActionV2Separator,
|
||||
createExternalLinkActionV2({
|
||||
name: group.externalId ?? "",
|
||||
section: GroupSection,
|
||||
visible: !!group.externalId,
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
],
|
||||
[
|
||||
t,
|
||||
group,
|
||||
can.read,
|
||||
can.update,
|
||||
can.delete,
|
||||
handleViewMembers,
|
||||
handleEditGroup,
|
||||
handleDeleteGroup,
|
||||
]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Group options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
onClick: handleViewMembers,
|
||||
visible: !!(group && can.read),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
onClick: handleEditGroup,
|
||||
visible: !!(group && can.update),
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
onClick: handleDeleteGroup,
|
||||
visible: !!(group && can.delete),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
href: "",
|
||||
title: group.externalId,
|
||||
disabled: true,
|
||||
visible: !!group.externalId,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
ariaLabel={t("Group options")}
|
||||
>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+31
-35
@@ -3,12 +3,13 @@ import { CrossIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Import from "~/models/Import";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { MenuItem } from "~/types";
|
||||
import { createActionV2 } from "~/actions";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
const Section = "Imports";
|
||||
|
||||
type Props = {
|
||||
/** Import to which actions will be applied. */
|
||||
@@ -23,40 +24,35 @@ export const ImportMenu = observer(
|
||||
({ importModel, onCancel, onDelete }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(importModel);
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
type: "button",
|
||||
title: t("Cancel"),
|
||||
visible: can.cancel,
|
||||
icon: <CrossIcon />,
|
||||
dangerous: true,
|
||||
onClick: onCancel,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Delete"),
|
||||
visible: can.delete,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
onClick: onDelete,
|
||||
},
|
||||
] satisfies MenuItem[],
|
||||
[t, can.delete, can.cancel, onCancel, onDelete]
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createActionV2({
|
||||
name: t("Cancel"),
|
||||
section: Section,
|
||||
visible: !!can.cancel,
|
||||
icon: <CrossIcon />,
|
||||
dangerous: true,
|
||||
perform: onCancel,
|
||||
}),
|
||||
createActionV2({
|
||||
name: t("Delete"),
|
||||
section: Section,
|
||||
visible: !!can.delete,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
perform: onDelete,
|
||||
}),
|
||||
],
|
||||
[t, can.cancel, can.delete, onCancel, onDelete]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Import menu options")}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("Import menu options")}>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { t } from "i18next";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import { toggleViewerInsights } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
const InsightsMenu: React.FC = () => {
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const menu = useMenuState();
|
||||
const context = useActionContext();
|
||||
const items: MenuItem[] = [actionToMenuItem(toggleViewerInsights, context)];
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button {...props}>
|
||||
<MoreIcon />
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} menuRef={menuRef} aria-label={t("Notification")}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
&:${hover},
|
||||
&:active {
|
||||
color: ${s("text")};
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default InsightsMenu;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import User from "~/models/User";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
function MemberMenu({ user, onRemove }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const menu = useMenuState({
|
||||
modal: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Member options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "button",
|
||||
title: currentUser.id === user.id ? t("Leave") : t("Remove"),
|
||||
dangerous: true,
|
||||
onClick: onRemove,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MemberMenu;
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { MenuStateReturn } from "reakit";
|
||||
import Model from "~/models/base/Model";
|
||||
|
||||
export type MenuContext<T extends Model> = {
|
||||
/** Model for which the menu is to be designed. */
|
||||
model: T;
|
||||
/** Menu state */
|
||||
menuState: MenuStateReturn;
|
||||
};
|
||||
|
||||
export const MenuContext = React.createContext<MenuContext<Model>>(
|
||||
{} as MenuContext<Model>
|
||||
);
|
||||
|
||||
export const useMenuContext = <T extends Model>() =>
|
||||
React.useContext<MenuContext<T>>(
|
||||
MenuContext as unknown as React.Context<MenuContext<T>>
|
||||
);
|
||||
@@ -1,40 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import Document from "~/models/Document";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
import { newDocumentPath, newNestedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Button from "~/components/Button";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
|
||||
type Props = {
|
||||
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
|
||||
document: Document;
|
||||
};
|
||||
|
||||
function NewChildDocumentMenu({ document, label }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
function NewChildDocumentMenu({ document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const canCollection = usePolicy(document.collectionId);
|
||||
const { collections } = useStores();
|
||||
|
||||
const items: MenuItem[] = [];
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const collectionName = collection ? collection.name : t("collection");
|
||||
|
||||
if (canCollection.createDocument) {
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const collectionName = collection ? collection.name : t("collection");
|
||||
items.push({
|
||||
type: "route",
|
||||
title: (
|
||||
<span>
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createInternalLinkActionV2({
|
||||
name: (
|
||||
<Trans
|
||||
defaults="New document in <em>{{ collectionName }}</em>"
|
||||
values={{
|
||||
@@ -44,37 +40,51 @@ function NewChildDocumentMenu({ document, label }: Props) {
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
to: newDocumentPath(document.collectionId),
|
||||
});
|
||||
}
|
||||
),
|
||||
section: ActiveDocumentSection,
|
||||
visible: !!canCollection.createDocument,
|
||||
to: newDocumentPath(document.collectionId),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: (
|
||||
<Trans
|
||||
defaults="New document in <em>{{ collectionName }}</em>"
|
||||
values={{
|
||||
collectionName: document.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
section: ActiveDocumentSection,
|
||||
visible: true,
|
||||
to: newNestedDocumentPath(document.id),
|
||||
}),
|
||||
],
|
||||
[
|
||||
collectionName,
|
||||
canCollection.createDocument,
|
||||
document.id,
|
||||
document.titleWithDefault,
|
||||
document.collectionId,
|
||||
]
|
||||
);
|
||||
|
||||
items.push({
|
||||
type: "route",
|
||||
title: (
|
||||
<span>
|
||||
<Trans
|
||||
defaults="New document in <em>{{ collectionName }}</em>"
|
||||
values={{
|
||||
collectionName: document.title,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
to: newNestedDocumentPath(document.id),
|
||||
});
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("New child document")}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
<Tooltip content={t("New document")} shortcut="n" placement="bottom">
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
ariaLabel={t("New child document")}
|
||||
>
|
||||
<Button icon={<PlusIcon />} neutral>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,61 +1,29 @@
|
||||
import { t } from "i18next";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { actionToMenuItem, performAction } from "~/actions";
|
||||
import { navigateToNotificationSettings } from "~/actions/definitions/navigation";
|
||||
import { markNotificationsAsArchived } from "~/actions/definitions/notifications";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import { MenuItem } from "~/types";
|
||||
import { useMemo } from "react";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
const NotificationMenu: React.FC = () => {
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const menu = useMenuState();
|
||||
const context = useActionContext();
|
||||
const items: MenuItem[] = React.useMemo(
|
||||
() => [
|
||||
actionToMenuItem(markNotificationsAsArchived, context),
|
||||
{
|
||||
type: "button",
|
||||
title: t("Notification settings"),
|
||||
onClick: () => performAction(navigateToNotificationSettings, context),
|
||||
},
|
||||
],
|
||||
[context]
|
||||
const actions = useMemo(
|
||||
() => [markNotificationsAsArchived, navigateToNotificationSettings],
|
||||
[]
|
||||
);
|
||||
|
||||
useOnClickOutside(
|
||||
menuRef,
|
||||
(event) => {
|
||||
if (menu.visible) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
menu.hide();
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button {...props}>
|
||||
<MoreIcon />
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} menuRef={menuRef} aria-label={t("Notification")}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("Notifications")}>
|
||||
<Button>
|
||||
<MoreIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -63,7 +31,8 @@ const Button = styled(NudeButton)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
&:${hover},
|
||||
&:active {
|
||||
&:active,
|
||||
&[data-state="open"] {
|
||||
color: ${s("text")};
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { createActionV2 } from "~/actions";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
type Props = {
|
||||
/** The OAuthAuthentication to associate with the menu */
|
||||
@@ -15,9 +15,6 @@ type Props = {
|
||||
};
|
||||
|
||||
function OAuthAuthenticationMenu({ oauthAuthentication }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const { dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -42,15 +39,24 @@ function OAuthAuthenticationMenu({ oauthAuthentication }: Props) {
|
||||
});
|
||||
}, [t, dialogs, oauthAuthentication]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
createActionV2({
|
||||
name: t("Revoke"),
|
||||
section: "OAuth",
|
||||
dangerous: true,
|
||||
perform: handleRevoke,
|
||||
}),
|
||||
],
|
||||
[t, handleRevoke]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu}>
|
||||
<MenuItem {...menu} onClick={handleRevoke} dangerous>
|
||||
{t("Revoke")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("Show menu")}>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OAuthClient from "~/models/oauth/OAuthClient";
|
||||
import OAuthClientDeleteDialog from "~/scenes/Settings/components/OAuthClientDeleteDialog";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
ActionV2Separator,
|
||||
createActionV2,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
const Section = "OAuth";
|
||||
|
||||
type Props = {
|
||||
/** The oauthClient to associate with the menu */
|
||||
@@ -18,9 +24,6 @@ type Props = {
|
||||
};
|
||||
|
||||
function OAuthClientMenu({ oauthClient, showEdit }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const { dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -36,32 +39,31 @@ function OAuthClientMenu({ oauthClient, showEdit }: Props) {
|
||||
});
|
||||
}, [t, dialogs, oauthClient]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
createInternalLinkActionV2({
|
||||
name: `${t("Edit")}…`,
|
||||
section: Section,
|
||||
visible: showEdit,
|
||||
to: settingsPath("applications", oauthClient.id),
|
||||
}),
|
||||
ActionV2Separator,
|
||||
createActionV2({
|
||||
name: `${t("Delete")}…`,
|
||||
section: Section,
|
||||
dangerous: true,
|
||||
perform: handleDelete,
|
||||
}),
|
||||
],
|
||||
[t, showEdit, oauthClient.id, handleDelete]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "route",
|
||||
title: `${t("Edit")}…`,
|
||||
visible: showEdit,
|
||||
to: settingsPath("applications", oauthClient.id),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
dangerous: true,
|
||||
title: `${t("Delete")}…`,
|
||||
onClick: handleDelete,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("Show menu")}>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+22
-28
@@ -1,51 +1,45 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "~/models/Document";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import { ActionV2Separator } from "~/actions";
|
||||
import {
|
||||
copyLinkToRevision,
|
||||
restoreRevision,
|
||||
} from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import separator from "./separator";
|
||||
import { useMemo } from "react";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
revisionId: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function RevisionMenu({ document, className }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
function RevisionMenu({ document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
});
|
||||
|
||||
const actions = useMemo(
|
||||
() => [restoreRevision, ActionV2Separator, copyLinkToRevision],
|
||||
[]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton
|
||||
className={className}
|
||||
aria-label={t("Show menu")}
|
||||
{...menu}
|
||||
/>
|
||||
<ContextMenu {...menu} aria-label={t("Revision options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
actionToMenuItem(restoreRevision, context),
|
||||
separator(),
|
||||
actionToMenuItem(copyLinkToRevision, context),
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
context={context}
|
||||
align="end"
|
||||
ariaLabel={t("Revision options")}
|
||||
>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+25
-66
@@ -1,87 +1,46 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import Share from "~/models/Share";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ActionV2Separator } from "~/actions";
|
||||
import {
|
||||
copyShareUrlFactory,
|
||||
goToShareSourceFactory,
|
||||
revokeShareFactory,
|
||||
} from "~/actions/definitions/shares";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
type Props = {
|
||||
share: Share;
|
||||
};
|
||||
|
||||
function ShareMenu({ share }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const { shares } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const can = usePolicy(share);
|
||||
|
||||
const handleGoToSource = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
history.push({
|
||||
pathname: share.sourcePathWithFallback,
|
||||
state: { sidebarContext: "collections" }, // optimistic preference of "collections"
|
||||
});
|
||||
},
|
||||
[history, share]
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
copyShareUrlFactory({ share }),
|
||||
goToShareSourceFactory({ share }),
|
||||
ActionV2Separator,
|
||||
revokeShareFactory({ share, can }),
|
||||
],
|
||||
[share, can]
|
||||
);
|
||||
|
||||
const handleRevoke = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
|
||||
try {
|
||||
await shares.revoke(share);
|
||||
toast.message(t("Share link revoked"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[t, shares, share]
|
||||
);
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
toast.success(t("Share link copied"));
|
||||
}, [t]);
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Share options")}>
|
||||
<CopyToClipboard text={share.url} onCopy={handleCopy}>
|
||||
<MenuItem {...menu} icon={<CopyIcon />}>
|
||||
{t("Copy link")}
|
||||
</MenuItem>
|
||||
</CopyToClipboard>
|
||||
<MenuItem {...menu} onClick={handleGoToSource} icon={<ArrowIcon />}>
|
||||
{share.collectionId ? t("Go to collection") : t("Go to document")}
|
||||
</MenuItem>
|
||||
{can.revoke && (
|
||||
<>
|
||||
<hr />
|
||||
<MenuItem
|
||||
{...menu}
|
||||
onClick={handleRevoke}
|
||||
icon={<TrashIcon />}
|
||||
dangerous
|
||||
>
|
||||
{t("Revoke link")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
ariaLabel={t("Share options")}
|
||||
>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,87 +2,86 @@ import { observer } from "mobx-react";
|
||||
import { TableOfContentsIcon } from "outline-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { createActionV2, createActionV2Group } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { MenuItem } from "~/types";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
function TableOfContentsMenu() {
|
||||
const { headings } = useDocumentContext();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
unstable_preventOverflow: true,
|
||||
unstable_fixed: true,
|
||||
unstable_flip: true,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const minHeading = headings.reduce(
|
||||
(memo, heading) => (heading.level < memo ? heading.level : memo),
|
||||
Infinity
|
||||
);
|
||||
|
||||
const items: MenuItem[] = useMemo(() => {
|
||||
const i = [
|
||||
{
|
||||
type: "heading",
|
||||
title: t("Contents"),
|
||||
},
|
||||
...headings.map((heading) => ({
|
||||
type: "button",
|
||||
onClick: () =>
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(
|
||||
() => (window.location.hash = `#${heading.id}`)
|
||||
)
|
||||
),
|
||||
title: <HeadingWrapper>{t(heading.title)}</HeadingWrapper>,
|
||||
level: heading.level - minHeading,
|
||||
})),
|
||||
] as MenuItem[];
|
||||
|
||||
if (i.length === 1) {
|
||||
i.push({
|
||||
type: "link",
|
||||
href: "#",
|
||||
title: (
|
||||
<HeadingWrapper>
|
||||
{t("Headings you add to the document will appear here")}
|
||||
</HeadingWrapper>
|
||||
const headingActions = useMemo(
|
||||
() =>
|
||||
headings
|
||||
.filter((heading) => heading.level < 4)
|
||||
.map((heading) =>
|
||||
createActionV2({
|
||||
name: (
|
||||
<HeadingWrapper $level={heading.level - minHeading}>
|
||||
{t(heading.title)}
|
||||
</HeadingWrapper>
|
||||
),
|
||||
section: ActiveDocumentSection,
|
||||
perform: () =>
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(
|
||||
() => (window.location.hash = `#${heading.id}`)
|
||||
)
|
||||
),
|
||||
})
|
||||
),
|
||||
disabled: true,
|
||||
});
|
||||
[t, headings, minHeading]
|
||||
);
|
||||
|
||||
const actions = useMemo(() => {
|
||||
let childActions = headingActions;
|
||||
|
||||
if (!childActions.length) {
|
||||
childActions = [
|
||||
createActionV2({
|
||||
name: (
|
||||
<HeadingWrapper>
|
||||
{t("Headings you add to the document will appear here")}
|
||||
</HeadingWrapper>
|
||||
),
|
||||
section: ActiveDocumentSection,
|
||||
disabled: true,
|
||||
perform: () => {},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
return i;
|
||||
}, [t, headings, minHeading]);
|
||||
return [
|
||||
createActionV2Group({
|
||||
name: t("Contents"),
|
||||
actions: childActions,
|
||||
}),
|
||||
];
|
||||
}, [t, headingActions]);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
icon={<TableOfContentsIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Table of contents")}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("Table of contents")}>
|
||||
<Button icon={<TableOfContentsIcon />} borderOnHover neutral />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const HeadingWrapper = styled.div`
|
||||
const HeadingWrapper = styled.div<{ $level?: number }>`
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
margin-left: ${({ $level }) => `${12 * ($level ?? 0)}px`};
|
||||
`;
|
||||
|
||||
export default observer(TableOfContentsMenu);
|
||||
|
||||
+9
-28
@@ -1,9 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import {
|
||||
navigateToWorkspaceSettings,
|
||||
logout,
|
||||
@@ -14,33 +11,18 @@ import {
|
||||
desktopLoginTeam,
|
||||
} from "~/actions/definitions/teams";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import separator from "~/menus/separator";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { ActionV2Separator } from "~/actions";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const TeamMenu: React.FC = ({ children }: Props) => {
|
||||
const menu = useMenuState({
|
||||
unstable_offset: [4, -4],
|
||||
placement: "bottom-start",
|
||||
modal: true,
|
||||
});
|
||||
const stores = useStores();
|
||||
const { theme } = stores.ui;
|
||||
const previousTheme = usePrevious(theme);
|
||||
const { t } = useTranslation();
|
||||
const context = useActionContext({ isContextMenu: true });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (theme !== previousTheme) {
|
||||
menu.hide();
|
||||
}
|
||||
}, [menu, theme, previousTheme]);
|
||||
|
||||
// NOTE: it's useful to memoize on the team id and session because the action
|
||||
// menu is not cached at all.
|
||||
const actions = React.useMemo(
|
||||
@@ -48,20 +30,19 @@ const TeamMenu: React.FC = ({ children }: Props) => {
|
||||
...switchTeamsList(context),
|
||||
createTeam,
|
||||
desktopLoginTeam,
|
||||
separator(),
|
||||
ActionV2Separator,
|
||||
navigateToWorkspaceSettings,
|
||||
logout,
|
||||
],
|
||||
[context]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>{children}</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Account")}>
|
||||
<Template {...menu} items={undefined} actions={actions} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu action={rootAction} align="start" ariaLabel={t("Account")}>
|
||||
{children}
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+132
-148
@@ -4,21 +4,24 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import {
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
UserChangeEmailDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import {
|
||||
ActionV2Separator,
|
||||
createActionV2,
|
||||
createActionV2WithChildren,
|
||||
} from "~/actions";
|
||||
import {
|
||||
deleteUserActionFactory,
|
||||
updateUserRoleActionFactory,
|
||||
} from "~/actions/definitions/users";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -29,156 +32,137 @@ type Props = {
|
||||
function UserMenu({ user }: Props) {
|
||||
const { users, dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const can = usePolicy(user);
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
|
||||
const handleChangeName = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
dialogs.openModal({
|
||||
title: t("Change name"),
|
||||
content: (
|
||||
<UserChangeNameDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
},
|
||||
[dialogs, t, user]
|
||||
const handleChangeName = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Change name"),
|
||||
content: (
|
||||
<UserChangeNameDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, user]);
|
||||
|
||||
const handleChangeEmail = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, user]);
|
||||
|
||||
const handleSuspend = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Suspend user"),
|
||||
content: (
|
||||
<UserSuspendDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, user]);
|
||||
|
||||
const handleRevoke = React.useCallback(async () => {
|
||||
await users.delete(user);
|
||||
}, [users, user]);
|
||||
|
||||
const handleResendInvite = React.useCallback(async () => {
|
||||
try {
|
||||
await users.resendInvite(user);
|
||||
toast.success(t(`Invite was resent to ${user.name}`));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err.message ?? t(`An error occurred while sending the invite`)
|
||||
);
|
||||
}
|
||||
}, [users, user, t]);
|
||||
|
||||
const handleActivate = React.useCallback(async () => {
|
||||
await users.activate(user);
|
||||
}, [users, user]);
|
||||
|
||||
const changeRoleActions = React.useMemo(
|
||||
() =>
|
||||
[UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
|
||||
updateUserRoleActionFactory(user, role)
|
||||
),
|
||||
[user]
|
||||
);
|
||||
|
||||
const handleChangeEmail = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog
|
||||
user={user}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
[dialogs, t, user]
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createActionV2WithChildren({
|
||||
name: t("Change role"),
|
||||
section: UserSection,
|
||||
visible: can.demote || can.promote,
|
||||
children: changeRoleActions,
|
||||
}),
|
||||
createActionV2({
|
||||
name: `${t("Change name")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: handleChangeName,
|
||||
}),
|
||||
createActionV2({
|
||||
name: `${t("Change email")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: handleChangeEmail,
|
||||
}),
|
||||
createActionV2({
|
||||
name: t("Resend invite"),
|
||||
section: UserSection,
|
||||
visible: can.resendInvite,
|
||||
perform: handleResendInvite,
|
||||
}),
|
||||
ActionV2Separator,
|
||||
createActionV2({
|
||||
name: `${t("Revoke invite")}…`,
|
||||
section: UserSection,
|
||||
visible: user.isInvited,
|
||||
dangerous: true,
|
||||
perform: handleRevoke,
|
||||
}),
|
||||
createActionV2({
|
||||
name: t("Activate user"),
|
||||
section: UserSection,
|
||||
visible: !user.isInvited && user.isSuspended,
|
||||
perform: handleActivate,
|
||||
}),
|
||||
createActionV2({
|
||||
name: `${t("Suspend user")}…`,
|
||||
section: UserSection,
|
||||
visible: !user.isInvited && !user.isSuspended,
|
||||
dangerous: true,
|
||||
perform: handleSuspend,
|
||||
}),
|
||||
ActionV2Separator,
|
||||
deleteUserActionFactory(user.id),
|
||||
],
|
||||
[
|
||||
t,
|
||||
can.demote,
|
||||
can.promote,
|
||||
can.update,
|
||||
can.resendInvite,
|
||||
user.id,
|
||||
user.isInvited,
|
||||
user.isSuspended,
|
||||
changeRoleActions,
|
||||
handleChangeName,
|
||||
handleChangeEmail,
|
||||
handleResendInvite,
|
||||
handleRevoke,
|
||||
handleActivate,
|
||||
handleSuspend,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSuspend = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
dialogs.openModal({
|
||||
title: t("Suspend user"),
|
||||
content: (
|
||||
<UserSuspendDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
},
|
||||
[dialogs, t, user]
|
||||
);
|
||||
|
||||
const handleRevoke = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
await users.delete(user);
|
||||
},
|
||||
[users, user]
|
||||
);
|
||||
|
||||
const handleResendInvite = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
|
||||
try {
|
||||
await users.resendInvite(user);
|
||||
toast.success(t(`Invite was resent to ${user.name}`));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err.message ?? t(`An error occurred while sending the invite`)
|
||||
);
|
||||
}
|
||||
},
|
||||
[users, user, t]
|
||||
);
|
||||
|
||||
const handleActivate = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
await users.activate(user);
|
||||
},
|
||||
[users, user]
|
||||
);
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("User options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "submenu",
|
||||
title: t("Change role"),
|
||||
visible: can.demote || can.promote,
|
||||
items: [UserRole.Admin, UserRole.Member, UserRole.Viewer].map(
|
||||
(role) =>
|
||||
actionToMenuItem(
|
||||
updateUserRoleActionFactory(user, role),
|
||||
context
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Change name")}…`,
|
||||
onClick: handleChangeName,
|
||||
visible: can.update,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Change email")}…`,
|
||||
onClick: handleChangeEmail,
|
||||
visible: can.update,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Resend invite"),
|
||||
onClick: handleResendInvite,
|
||||
visible: can.resendInvite,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Revoke invite")}…`,
|
||||
dangerous: true,
|
||||
onClick: handleRevoke,
|
||||
visible: user.isInvited,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Activate user"),
|
||||
onClick: handleActivate,
|
||||
visible: !user.isInvited && user.isSuspended,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Suspend user")}…`,
|
||||
dangerous: true,
|
||||
onClick: handleSuspend,
|
||||
visible: !user.isInvited && !user.isSuspended,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(deleteUserActionFactory(user.id), context),
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
<DropdownMenu action={rootAction} align="end" ariaLabel={t("User options")}>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable */
|
||||
/* oxlint-disable */
|
||||
import stores from "~/stores";
|
||||
|
||||
describe("Collection model", () => {
|
||||
|
||||
+29
-1
@@ -665,6 +665,28 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all children of the document.
|
||||
* This is determined by the collection structure, or the user/group memberships in case it's a shared document.
|
||||
*
|
||||
* @returns An array of NavigationNode objects.
|
||||
*/
|
||||
@computed
|
||||
get children(): NavigationNode[] {
|
||||
const { userMemberships, groupMemberships } = this.store.rootStore;
|
||||
const collection = this.collection;
|
||||
|
||||
const membership =
|
||||
userMemberships.getByDocumentId(this.id) ??
|
||||
groupMemberships.getByDocumentId(this.id);
|
||||
|
||||
return (
|
||||
collection?.getChildrenForDocument(this.id) ??
|
||||
membership?.getChildrenForDocument(this.id) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the markdown representation of the document derived from the ProseMirror data.
|
||||
*
|
||||
@@ -677,7 +699,13 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
nodes: extensionManager.nodes,
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
const markdown = serializer.serialize(Node.fromJSON(schema, this.data), {
|
||||
|
||||
const doc = Node.fromJSON(
|
||||
schema,
|
||||
ProsemirrorHelper.attachmentsToAbsoluteUrls(this.data)
|
||||
);
|
||||
|
||||
const markdown = serializer.serialize(doc, {
|
||||
softBreak: true,
|
||||
});
|
||||
return markdown;
|
||||
|
||||
@@ -3,14 +3,14 @@ import { CollectionPermission, DocumentPermission } from "@shared/types";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import Group from "./Group";
|
||||
import Model from "./base/Model";
|
||||
import { AfterRemove } from "./decorators/Lifecycle";
|
||||
import Relation from "./decorators/Relation";
|
||||
import NavigableModel from "./base/NavigableModel";
|
||||
|
||||
/**
|
||||
* Represents a groups's membership to a collection or document.
|
||||
*/
|
||||
class GroupMembership extends Model {
|
||||
class GroupMembership extends NavigableModel {
|
||||
static modelName = "GroupMembership";
|
||||
|
||||
/** The group ID that this membership is granted to. */
|
||||
@@ -45,6 +45,23 @@ class GroupMembership extends Model {
|
||||
@observable
|
||||
permission: CollectionPermission | DocumentPermission;
|
||||
|
||||
// methods
|
||||
|
||||
/**
|
||||
* Fetches the child documents structure from the server.
|
||||
*/
|
||||
async fetchDocuments(options: { force?: boolean } = {}) {
|
||||
if (!this.documentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await super.fetchDocuments({
|
||||
path: "/documents.documents",
|
||||
params: { id: this.documentId },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
// hooks
|
||||
|
||||
@AfterRemove
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { NotificationData, NotificationEventType } from "@shared/types";
|
||||
import {
|
||||
collectionPath,
|
||||
commentPath,
|
||||
@@ -73,6 +73,11 @@ class Notification extends Model {
|
||||
*/
|
||||
event: NotificationEventType;
|
||||
|
||||
/**
|
||||
* Additional data associated with the notification.
|
||||
*/
|
||||
data: NotificationData;
|
||||
|
||||
/**
|
||||
* Mark the notification as read or unread
|
||||
*
|
||||
@@ -121,6 +126,10 @@ class Notification extends Model {
|
||||
return t("left a comment on");
|
||||
case NotificationEventType.ResolveComment:
|
||||
return t("resolved a comment on");
|
||||
case NotificationEventType.ReactionsCreate:
|
||||
return t("reacted {{ emoji }} to your comment on", {
|
||||
emoji: this.data.emoji,
|
||||
});
|
||||
case NotificationEventType.AddUserToDocument:
|
||||
return t("shared");
|
||||
case NotificationEventType.AddUserToCollection:
|
||||
@@ -173,7 +182,8 @@ class Notification extends Model {
|
||||
}
|
||||
case NotificationEventType.MentionedInComment:
|
||||
case NotificationEventType.ResolveComment:
|
||||
case NotificationEventType.CreateComment: {
|
||||
case NotificationEventType.CreateComment:
|
||||
case NotificationEventType.ReactionsCreate: {
|
||||
return this.document && this.comment
|
||||
? commentPath(this.document, this.comment)
|
||||
: this.document?.path;
|
||||
|
||||
@@ -3,12 +3,12 @@ import { DocumentPermission } from "@shared/types";
|
||||
import type UserMembershipsStore from "~/stores/UserMembershipsStore";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterRemove } from "./decorators/Lifecycle";
|
||||
import Relation from "./decorators/Relation";
|
||||
import NavigableModel from "./base/NavigableModel";
|
||||
|
||||
class UserMembership extends Model {
|
||||
class UserMembership extends NavigableModel {
|
||||
static modelName = "UserMembership";
|
||||
|
||||
/** The sort order of the membership (In users sidebar) */
|
||||
@@ -50,6 +50,23 @@ class UserMembership extends Model {
|
||||
|
||||
store: UserMembershipsStore;
|
||||
|
||||
// methods
|
||||
|
||||
/**
|
||||
* Fetches the child documents structure from the server.
|
||||
*/
|
||||
async fetchDocuments(options: { force?: boolean } = {}) {
|
||||
if (!this.documentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await super.fetchDocuments({
|
||||
path: "/documents.documents",
|
||||
params: { id: this.documentId },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next membership for the same user in the list, or undefined if this is the last.
|
||||
*/
|
||||
|
||||
@@ -208,7 +208,7 @@ export default abstract class Model {
|
||||
|
||||
for (const property in this) {
|
||||
if (
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
// oxlint-disable-next-line no-prototype-builtins
|
||||
this.hasOwnProperty(property) &&
|
||||
!["persistedAttributes", "store", "isSaving", "isNew"].includes(
|
||||
property
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { computed, observable, runInAction } from "mobx";
|
||||
import { JSONObject, type NavigationNode } from "@shared/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import ParanoidModel from "./ParanoidModel";
|
||||
|
||||
export default abstract class NavigableModel extends ParanoidModel {
|
||||
private isFetching = false;
|
||||
|
||||
@observable
|
||||
node?: NavigationNode;
|
||||
|
||||
/**
|
||||
* Fetches the child documents structure from the server.
|
||||
*/
|
||||
async fetchDocuments(options: {
|
||||
path: string;
|
||||
params: JSONObject;
|
||||
force?: boolean;
|
||||
}) {
|
||||
if (this.isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.documents && options.force !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isFetching = true;
|
||||
const res = await client.post(options.path, options.params);
|
||||
|
||||
runInAction(`${NavigableModel.modelName}#fetchDocuments`, () => {
|
||||
this.node = res.data;
|
||||
});
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Child documents structure of the document shared with this membership.
|
||||
*/
|
||||
@computed
|
||||
get documents(): NavigationNode[] | undefined {
|
||||
return this.node?.children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the document path from the original document shared with this membership.
|
||||
*/
|
||||
pathToDocument(documentId: string) {
|
||||
let path: NavigationNode[] | undefined = [];
|
||||
const document = this.store.rootStore.documents.get(documentId);
|
||||
if (!document) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const travelNodes = (
|
||||
nodes: NavigationNode[],
|
||||
previousPath: NavigationNode[]
|
||||
) => {
|
||||
nodes.forEach((node) => {
|
||||
const newPath = [...previousPath, node];
|
||||
|
||||
if (node.id === documentId) {
|
||||
path = newPath;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
document.parentDocumentId &&
|
||||
node.id === document.parentDocumentId
|
||||
) {
|
||||
path = [...newPath, document.asNavigationNode];
|
||||
return;
|
||||
}
|
||||
|
||||
return travelNodes(node.children, newPath);
|
||||
});
|
||||
};
|
||||
|
||||
if (this.node) {
|
||||
travelNodes([this.node], path);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the child documents structure for the document.
|
||||
*/
|
||||
getChildrenForDocument(documentId: string) {
|
||||
let result: NavigationNode[] = [];
|
||||
|
||||
const travelNodes = (nodes: NavigationNode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.id === documentId) {
|
||||
result = node.children;
|
||||
return;
|
||||
}
|
||||
|
||||
return travelNodes(node.children);
|
||||
});
|
||||
};
|
||||
|
||||
if (this.node) {
|
||||
travelNodes([this.node]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user