mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
66 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 |
@@ -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"
|
||||
},
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["mime-types"],
|
||||
"message": "Do not use the mime-types package in the browser."
|
||||
}
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"name": "reakit/Menu",
|
||||
|
||||
@@ -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,7 +26,6 @@ import {
|
||||
PublishIcon,
|
||||
CommentIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
LogoutIcon,
|
||||
@@ -70,7 +69,6 @@ import env from "~/env";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentInsightsPath,
|
||||
documentHistoryPath,
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
@@ -84,6 +82,7 @@ import {
|
||||
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"),
|
||||
@@ -1329,7 +1328,7 @@ export const openDocumentHistory = createInternalLinkActionV2({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentInsights = createInternalLinkActionV2({
|
||||
export const openDocumentInsights = createActionV2({
|
||||
name: ({ t }) => t("Insights"),
|
||||
analyticsName: "Open document insights",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1347,51 +1346,17 @@ export const openDocumentInsights = createInternalLinkActionV2({
|
||||
!document?.isDeleted
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [pathname, search] = documentInsightsPath(document).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleViewerInsights = createActionV2({
|
||||
name: ({ t, stores, activeDocumentId }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
return document?.insightsEnabled
|
||||
? t("Disable viewer insights")
|
||||
: t("Enable viewer insights");
|
||||
},
|
||||
analyticsName: "Toggle viewer insights",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <EyeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return can.updateInsights;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.save({
|
||||
insightsEnabled: !document.insightsEnabled,
|
||||
stores.dialogs.openModal({
|
||||
title: t("Insights"),
|
||||
content: <Insights document={document} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -268,7 +268,7 @@ 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
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
@@ -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]}
|
||||
/>
|
||||
|
||||
@@ -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,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> {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+13
-1
@@ -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,7 +73,7 @@ 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("-");
|
||||
|
||||
@@ -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);
|
||||
const [, setMinutesMounted] = useState(0);
|
||||
const callback = useRef<() => void>();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,6 +148,13 @@ function DocumentMenu({
|
||||
[user, document]
|
||||
);
|
||||
|
||||
const handleInsightsToggle = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
void document.save({ insightsEnabled: checked });
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
const templateMenuActions = useTemplateMenuActions({
|
||||
document,
|
||||
onSelectTemplate,
|
||||
@@ -231,6 +238,18 @@ function DocumentMenu({
|
||||
<>
|
||||
<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
|
||||
@@ -263,6 +282,7 @@ function DocumentMenu({
|
||||
can.update,
|
||||
document.embedsDisabled,
|
||||
document.fullWidth,
|
||||
document.insightsEnabled,
|
||||
isMobile,
|
||||
showDisplayOptions,
|
||||
showToggleEmbeds,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { t } from "i18next";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { toggleViewerInsights } from "~/actions/definitions/documents";
|
||||
import { useMemo } from "react";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
const InsightsMenu: React.FC = () => {
|
||||
const actions = useMemo(() => [toggleViewerInsights], []);
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<DropdownMenu action={rootAction} align="end" ariaLabel={t("Insights")}>
|
||||
<Button>
|
||||
<MoreIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
&:${hover},
|
||||
&:active,
|
||||
&[data-state="open"] {
|
||||
color: ${s("text")};
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default InsightsMenu;
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -93,11 +93,7 @@ function AuthenticatedRoutes() {
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={Document}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/insights`}
|
||||
component={Document}
|
||||
/>
|
||||
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreIcon, PlusIcon } from "outline-icons";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import Collection from "~/models/Collection";
|
||||
|
||||
@@ -3,18 +3,20 @@ import { observer, useObserver } from "mobx-react";
|
||||
import { CommentIcon } from "outline-icons";
|
||||
import { useRef, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useRouteMatch } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import { openDocumentInsights } from "~/actions/definitions/documents";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Fade from "~/components/Fade";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
/* The document to display meta data for */
|
||||
@@ -27,7 +29,6 @@ type Props = {
|
||||
function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
const { views, comments, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const team = useCurrentTeam();
|
||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||
@@ -35,10 +36,12 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].userId;
|
||||
const viewsLoadedOnMount = useRef(totalViewers > 0);
|
||||
const can = usePolicy(document);
|
||||
const actionContext = useActionContext({
|
||||
activeDocumentId: document.id,
|
||||
});
|
||||
|
||||
const Wrapper = viewsLoadedOnMount.current ? Fragment : Fade;
|
||||
|
||||
const insightsPath = documentInsightsPath(document);
|
||||
const commentsCount = comments.unresolvedCommentsInDocumentCount(document.id);
|
||||
const commentingEnabled = !!team.getPreference(TeamPreference.Commenting);
|
||||
|
||||
@@ -67,14 +70,8 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
!document.isTemplate ? (
|
||||
<Wrapper>
|
||||
•
|
||||
<Link
|
||||
to={{
|
||||
pathname:
|
||||
match.url === insightsPath
|
||||
? documentPath(document)
|
||||
: insightsPath,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
<InsightsButton
|
||||
onClick={() => openDocumentInsights.perform(actionContext)}
|
||||
>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
@@ -82,7 +79,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
: `${totalViewers} ${
|
||||
totalViewers === 1 ? t("person") : t("people")
|
||||
}`}
|
||||
</Link>
|
||||
</InsightsButton>
|
||||
</Wrapper>
|
||||
) : null}
|
||||
</Meta>
|
||||
@@ -94,6 +91,20 @@ const CommentLink = styled(Link)`
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const InsightsButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
margin: -12px 0 2em 0;
|
||||
|
||||
@@ -55,7 +55,7 @@ type Props = Omit<EditorProps, "editorStyle"> & {
|
||||
* The main document editor includes an editable title with metadata below it,
|
||||
* and support for commenting.
|
||||
*/
|
||||
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
|
||||
const titleRef = React.useRef<RefHandle>(null);
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { TableOfContentsIcon, EditIcon, MoreIcon } from "outline-icons";
|
||||
import { TableOfContentsIcon, EditIcon } from "outline-icons";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -1,55 +1,33 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useTextSelection from "~/hooks/useTextSelection";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import InsightsMenu from "~/menus/InsightsMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
import type Document from "~/models/Document";
|
||||
import { useFormatNumber } from "~/hooks/useFormatNumber";
|
||||
|
||||
function Insights() {
|
||||
const { views, documents } = useStores();
|
||||
type Props = {
|
||||
document: Document;
|
||||
};
|
||||
|
||||
function Insights({ document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const selectedText = useTextSelection();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const { editor } = useDocumentContext();
|
||||
const text = editor?.getPlainText();
|
||||
const text = document.toPlainText();
|
||||
const stats = useTextStats(text ?? "", selectedText);
|
||||
const can = usePolicy(document);
|
||||
const documentViews = document ? views.inDocument(document.id) : [];
|
||||
|
||||
const onCloseInsights = () => {
|
||||
if (document) {
|
||||
history.push({
|
||||
pathname: documentPath(document),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useKeyDown("Escape", onCloseInsights);
|
||||
const formatNumber = useFormatNumber();
|
||||
|
||||
return (
|
||||
<Sidebar title={t("Insights")} onClose={onCloseInsights}>
|
||||
<div>
|
||||
{document ? (
|
||||
<Flex
|
||||
column
|
||||
@@ -58,69 +36,86 @@ function Insights() {
|
||||
justify="space-between"
|
||||
>
|
||||
<div>
|
||||
<Content column>
|
||||
{document.sourceMetadata && (
|
||||
<>
|
||||
<Heading>{t("Source")}</Heading>
|
||||
{
|
||||
<Text as="p" type="secondary" size="small">
|
||||
<Flex column>
|
||||
<Text as="h2" size="large">
|
||||
{t("Source")}
|
||||
</Text>
|
||||
<Text as="p" type="secondary" size="small">
|
||||
<List>
|
||||
<li>
|
||||
{t("Created")}{" "}
|
||||
<Time dateTime={document.createdAt} addSuffix />
|
||||
</li>
|
||||
<li>
|
||||
{t(`Last updated`)}{" "}
|
||||
<Time dateTime={document.updatedAt} addSuffix />
|
||||
</li>
|
||||
{document.sourceMetadata && (
|
||||
<li>
|
||||
{t("Imported from {{ source }}", {
|
||||
source:
|
||||
document.sourceName ??
|
||||
`“${document.sourceMetadata.fileName}”`,
|
||||
})}
|
||||
</Text>
|
||||
}
|
||||
</>
|
||||
)}
|
||||
<Heading>{t("Stats")}</Heading>
|
||||
</li>
|
||||
)}
|
||||
</List>
|
||||
</Text>
|
||||
|
||||
<Text as="h2" size="large">
|
||||
{t("Stats")}
|
||||
</Text>
|
||||
<Text as="p" type="secondary" size="small">
|
||||
<List>
|
||||
{stats.total.words > 0 && (
|
||||
<li>
|
||||
{t(`{{ count }} minute read`, {
|
||||
count: stats.total.readingTime,
|
||||
{t(`{{ number }} minute read`, {
|
||||
number: formatNumber(stats.total.readingTime),
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
{t(`{{ count }} words`, { count: stats.total.words })}
|
||||
</li>
|
||||
<li>
|
||||
{t(`{{ count }} characters`, {
|
||||
count: stats.total.characters,
|
||||
{t(`{{ number }} words`, {
|
||||
count: stats.total.words,
|
||||
number: formatNumber(stats.total.words),
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
{t(`{{ number }} emoji`, { number: stats.total.emoji })}
|
||||
{t(`{{ number }} characters`, {
|
||||
count: stats.total.characters,
|
||||
number: formatNumber(stats.total.characters),
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
{t(`{{ number }} emoji`, {
|
||||
number: formatNumber(stats.total.emoji),
|
||||
})}
|
||||
</li>
|
||||
{stats.selected.characters === 0 ? (
|
||||
<li>{t("No text selected")}</li>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
{t(`{{ count }} words selected`, {
|
||||
{t(`{{ number }} words selected`, {
|
||||
count: stats.selected.words,
|
||||
number: formatNumber(stats.selected.words),
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
{t(`{{ count }} characters selected`, {
|
||||
{t(`{{ number }} characters selected`, {
|
||||
count: stats.selected.characters,
|
||||
number: formatNumber(stats.selected.characters),
|
||||
})}
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</Text>
|
||||
</Content>
|
||||
</Flex>
|
||||
|
||||
<Content column>
|
||||
<Heading>{t("Contributors")}</Heading>
|
||||
<Text as="p" type="secondary" size="small">
|
||||
{t(`Created`)} <Time dateTime={document.createdAt} addSuffix />.
|
||||
<br />
|
||||
{t(`Last updated`)}{" "}
|
||||
<Time dateTime={document.updatedAt} addSuffix />.
|
||||
<Flex column>
|
||||
<Text as="h2" size="large">
|
||||
{t("Contributors")}
|
||||
</Text>
|
||||
<ListSpacing>
|
||||
{document.sourceMetadata?.createdByName && (
|
||||
@@ -166,49 +161,11 @@ function Insights() {
|
||||
)}
|
||||
/>
|
||||
</ListSpacing>
|
||||
</Content>
|
||||
{(document.insightsEnabled || can.updateInsights) && (
|
||||
<Content column>
|
||||
<Heading>
|
||||
<Flex justify="space-between">
|
||||
{t("Viewed by")}
|
||||
{can.updateInsights && <InsightsMenu />}
|
||||
</Flex>
|
||||
</Heading>
|
||||
{document.insightsEnabled ? (
|
||||
<>
|
||||
<Text as="p" type="secondary" size="small">
|
||||
{documentViews.length <= 1
|
||||
? t("No one else has viewed yet")
|
||||
: t(
|
||||
`Viewed {{ count }} times by {{ teamMembers }} people`,
|
||||
{
|
||||
count: documentViews.reduce(
|
||||
(memo, view) => memo + view.count,
|
||||
0
|
||||
),
|
||||
teamMembers: documentViews.length,
|
||||
}
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
{documentViews.length > 1 && (
|
||||
<ListSpacing>
|
||||
<DocumentViews document={document} />
|
||||
</ListSpacing>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text as="p" type="secondary" size="small">
|
||||
{t("Viewer insights are disabled.")}
|
||||
</Text>
|
||||
)}
|
||||
</Content>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,7 +175,7 @@ const ListSpacing = styled("div")`
|
||||
`;
|
||||
|
||||
const List = styled("ul")`
|
||||
margin: 0;
|
||||
margin: 0 0 1em;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
@@ -231,13 +188,4 @@ const List = styled("ul")`
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
padding: 0 16px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const Heading = styled("h3")`
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
export default observer(Insights);
|
||||
|
||||
@@ -51,7 +51,10 @@ type MessageEvent = {
|
||||
};
|
||||
};
|
||||
|
||||
function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
function MultiplayerEditor(
|
||||
{ onSynced, ...props }: Props,
|
||||
ref: React.Ref<unknown>
|
||||
) {
|
||||
const documentId = props.id;
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -2,8 +2,9 @@ import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { sharedModelPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -47,23 +48,24 @@ const PublicBreadcrumb: React.FC<Props> = ({
|
||||
sharedTree,
|
||||
children,
|
||||
}: Props) => {
|
||||
const items: MenuInternalLink[] = React.useMemo(
|
||||
const actions = React.useMemo(
|
||||
() =>
|
||||
pathToDocument(sharedTree, documentId)
|
||||
.slice(1, -1)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
icon: item.icon ? (
|
||||
<Icon value={item.icon} color={item.color} />
|
||||
) : undefined,
|
||||
title: item.title,
|
||||
type: "route",
|
||||
to: sharedModelPath(shareId, item.url),
|
||||
})),
|
||||
.map((item) =>
|
||||
createInternalLinkActionV2({
|
||||
name: item.title,
|
||||
section: ActiveDocumentSection,
|
||||
icon: item.icon ? (
|
||||
<Icon value={item.icon} color={item.color} />
|
||||
) : undefined,
|
||||
to: sharedModelPath(shareId, item.url),
|
||||
})
|
||||
),
|
||||
[sharedTree, shareId, documentId]
|
||||
);
|
||||
|
||||
return <Breadcrumb items={items}>{children}</Breadcrumb>;
|
||||
return <Breadcrumb actions={actions}>{children}</Breadcrumb>;
|
||||
};
|
||||
|
||||
export default PublicBreadcrumb;
|
||||
|
||||
@@ -28,10 +28,7 @@ function References({ document }: Props) {
|
||||
}, [documents, document.id]);
|
||||
|
||||
const backlinks = document.backlinks;
|
||||
const collection = document.collection;
|
||||
const children = collection
|
||||
? collection.getChildrenForDocument(document.id)
|
||||
: [];
|
||||
const children = document.children;
|
||||
const showBacklinks = !!backlinks.length;
|
||||
const showChildDocuments = !!children.length;
|
||||
const shouldFade = useRef(!showBacklinks && !showChildDocuments);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import flatten from "lodash/flatten";
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
@@ -13,7 +12,6 @@ import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { flattenTree } from "~/utils/tree";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -36,12 +34,7 @@ function DocumentMove({ document }: Props) {
|
||||
.map(filterSourceDocument),
|
||||
});
|
||||
|
||||
// Filter out the document itself and its existing parent doc, if any.
|
||||
const nodes = flatten(collectionTrees.map(flattenTree))
|
||||
.filter(
|
||||
(node) =>
|
||||
node.id !== document.id && node.id !== document.parentDocumentId
|
||||
)
|
||||
const nodes = collectionTrees
|
||||
.map(filterSourceDocument)
|
||||
// Filter out collections that we don't have permission to create documents in.
|
||||
.filter((node) =>
|
||||
@@ -100,7 +93,7 @@ function DocumentMove({ document }: Props) {
|
||||
<Trans
|
||||
defaults="Move to <em>{{ location }}</em>"
|
||||
values={{
|
||||
location: selectedPath.title,
|
||||
location: selectedPath.title || t("Untitled"),
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import flatten from "lodash/flatten";
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
@@ -13,7 +12,6 @@ import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { flattenTree } from "~/utils/tree";
|
||||
|
||||
type Props = {
|
||||
/** Document to publish */
|
||||
@@ -27,7 +25,7 @@ function DocumentPublish({ document }: Props) {
|
||||
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
|
||||
const publishOptions = useMemo(
|
||||
() =>
|
||||
flatten(collectionTrees.map(flattenTree)).filter((node) =>
|
||||
collectionTrees.filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
@@ -132,10 +133,10 @@ function Authorize() {
|
||||
{t("Required OAuth parameters are missing")}
|
||||
<Pre>
|
||||
{missingParams.map((param) => (
|
||||
<>
|
||||
<React.Fragment key={param}>
|
||||
{param}
|
||||
<br />
|
||||
</>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Pre>
|
||||
</Text>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, InternetIcon, ReplaceIcon } from "outline-icons";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useEffect, useCallback, useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
@@ -29,6 +29,8 @@ import { ActionRow } from "./components/ActionRow";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import ImageInput from "./components/ImageInput";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { NavigationSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
oauthClient: OAuthClient;
|
||||
@@ -77,6 +79,18 @@ const Application = observer(function Application({ oauthClient }: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
const breadcrumbActions = useMemo(
|
||||
() => [
|
||||
createInternalLinkActionV2({
|
||||
name: t("Applications"),
|
||||
section: NavigationSection,
|
||||
icon: <InternetIcon />,
|
||||
to: settingsPath("applications"),
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
@@ -118,18 +132,7 @@ const Application = observer(function Application({ oauthClient }: Props) {
|
||||
return (
|
||||
<Scene
|
||||
title={oauthClient.name}
|
||||
left={
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
type: "route",
|
||||
title: t("Applications"),
|
||||
to: settingsPath("applications"),
|
||||
icon: <InternetIcon />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
left={<Breadcrumb actions={breadcrumbActions} />}
|
||||
actions={<OAuthClientMenu oauthClient={oauthClient} showEdit={false} />}
|
||||
>
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
EditIcon,
|
||||
EmailIcon,
|
||||
PublishIcon,
|
||||
SmileyIcon,
|
||||
StarredIcon,
|
||||
UserIcon,
|
||||
} from "outline-icons";
|
||||
@@ -77,6 +78,14 @@ function Notifications() {
|
||||
"Receive a notification when a comment thread you were involved in is resolved"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: NotificationEventType.ReactionsCreate,
|
||||
icon: <SmileyIcon />,
|
||||
title: t("Reaction added"),
|
||||
description: t(
|
||||
"Receive a notification when someone reacts to your comment"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: NotificationEventType.CreateCollection,
|
||||
icon: <CollectionIcon />,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -50,10 +51,10 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
{apiKey.scope && (
|
||||
<Tooltip
|
||||
content={apiKey.scope.map((s) => (
|
||||
<>
|
||||
<React.Fragment key={s}>
|
||||
{s}
|
||||
<br />
|
||||
</>
|
||||
</React.Fragment>
|
||||
))}
|
||||
>
|
||||
<Text type="tertiary">{t("Restricted scope")}</Text>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SettingsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { NavigationSection } from "~/actions/sections";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Scene from "~/components/Scene";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
@@ -11,22 +13,20 @@ export function IntegrationScene({
|
||||
}: React.ComponentProps<typeof Scene>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const breadcrumbActions = React.useMemo(
|
||||
() => [
|
||||
createInternalLinkActionV2({
|
||||
name: t("Integrations"),
|
||||
section: NavigationSection,
|
||||
icon: <SettingsIcon />,
|
||||
to: settingsPath("integrations"),
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
left={
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
type: "route",
|
||||
title: t("Integrations"),
|
||||
icon: <SettingsIcon />,
|
||||
to: settingsPath("integrations"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
<Scene left={<Breadcrumb actions={breadcrumbActions} />} {...rest}>
|
||||
{children}
|
||||
</Scene>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import compact from "lodash/compact";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
|
||||
import Share from "~/models/Share";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
@@ -13,9 +12,8 @@ import {
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import Time from "~/components/Time";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import ShareMenu from "~/menus/ShareMenu";
|
||||
import { formatNumber } from "~/utils/language";
|
||||
import { useFormatNumber } from "~/hooks/useFormatNumber";
|
||||
|
||||
const ROW_HEIGHT = 50;
|
||||
|
||||
@@ -25,7 +23,7 @@ type Props = Omit<TableProps<Share>, "columns" | "rowHeight"> & {
|
||||
|
||||
export function SharesTable({ data, canManage, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const language = useUserLocale();
|
||||
const formatNumber = useFormatNumber();
|
||||
const hasDomain = data.some((share) => share.domain);
|
||||
|
||||
const columns = useMemo<TableColumn<Share>[]>(
|
||||
@@ -101,13 +99,7 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
|
||||
id: "views",
|
||||
header: t("Views"),
|
||||
accessor: (share) => share.views,
|
||||
component: (share) => (
|
||||
<>
|
||||
{language
|
||||
? formatNumber(share.views, unicodeCLDRtoBCP47(language))
|
||||
: share.views}
|
||||
</>
|
||||
),
|
||||
component: (share) => formatNumber(share.views),
|
||||
width: "150px",
|
||||
},
|
||||
canManage
|
||||
@@ -123,7 +115,7 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
|
||||
}
|
||||
: undefined,
|
||||
]),
|
||||
[t, language, hasDomain, canManage]
|
||||
[t, hasDomain, canManage]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,8 +8,6 @@ import { observable, action, computed, runInAction } from "mobx";
|
||||
import {
|
||||
SubscriptionType,
|
||||
type DateFilter,
|
||||
type NavigationNode,
|
||||
type PublicTeam,
|
||||
type StatusFilter,
|
||||
} from "@shared/types";
|
||||
import { subtractDate } from "@shared/utils/date";
|
||||
|
||||
@@ -140,4 +140,20 @@ export default class GroupMembershipsStore extends Store<GroupMembership> {
|
||||
*/
|
||||
inDocument = (documentId: string) =>
|
||||
this.orderedData.filter((cgm) => cgm.documentId === documentId);
|
||||
|
||||
/**
|
||||
* Returns the group membership associated with the document.
|
||||
*/
|
||||
getByDocumentId = (documentId: string): GroupMembership | undefined => {
|
||||
const membership = this.find({ documentId });
|
||||
|
||||
if (membership) {
|
||||
return membership;
|
||||
}
|
||||
|
||||
const document = this.rootStore.documents.get(documentId);
|
||||
return document?.parentDocumentId
|
||||
? this.getByDocumentId(document.parentDocumentId)
|
||||
: undefined;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +34,13 @@ class IntegrationsStore extends Store<Integration> {
|
||||
(integration) => integration.service === IntegrationService.Linear
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get gitlab(): Integration<IntegrationType.Embed>[] {
|
||||
return this.orderedData.filter(
|
||||
(integration) => integration.service === IntegrationService.GitLab
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default IntegrationsStore;
|
||||
|
||||
@@ -73,8 +73,7 @@ export default class NotificationsStore extends Store<Notification> {
|
||||
*/
|
||||
@computed
|
||||
get approximateUnreadCount(): number {
|
||||
return this.orderedData.filter((notification) => !notification.viewedAt)
|
||||
.length;
|
||||
return this.active.filter((notification) => !notification.viewedAt).length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,4 +86,12 @@ export default class NotificationsStore extends Store<Notification> {
|
||||
(item) => (item.viewedAt ? 1 : -1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns only the active (non-archived) notifications.
|
||||
*/
|
||||
@computed
|
||||
get active(): Notification[] {
|
||||
return this.orderedData.filter((n) => !n.archivedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,4 +101,20 @@ export default class UserMembershipsStore extends Store<UserMembership> {
|
||||
return a.index < b.index ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user membership associated with the document.
|
||||
*/
|
||||
getByDocumentId = (documentId: string): UserMembership | undefined => {
|
||||
const membership = this.find({ documentId });
|
||||
|
||||
if (membership) {
|
||||
return membership;
|
||||
}
|
||||
|
||||
const document = this.rootStore.documents.get(documentId);
|
||||
return document?.parentDocumentId
|
||||
? this.getByDocumentId(document.parentDocumentId)
|
||||
: undefined;
|
||||
};
|
||||
}
|
||||
|
||||
+2
-2
@@ -125,7 +125,7 @@ export type Action = {
|
||||
* Perform the action – note this should generally not be called directly, use `performAction`
|
||||
* instead. Errors will be caught and displayed to the user as a toast message.
|
||||
*/
|
||||
perform?: (context: ActionContext) => any;
|
||||
perform?: (context: ActionContext) => unknown;
|
||||
to?: string | { url: string; target?: string };
|
||||
children?: ((context: ActionContext) => Action[]) | Action[];
|
||||
};
|
||||
@@ -154,7 +154,7 @@ export type ActionV2 = BaseActionV2 & {
|
||||
tooltip?:
|
||||
| ((context: ActionContext) => React.ReactChild | undefined)
|
||||
| React.ReactChild;
|
||||
perform: (context: ActionContext) => any;
|
||||
perform: (context: ActionContext) => unknown;
|
||||
};
|
||||
|
||||
export type InternalLinkActionV2 = BaseActionV2 & {
|
||||
|
||||
+15
-14
@@ -47,7 +47,7 @@ class ApiClient {
|
||||
this.shareId = shareId;
|
||||
};
|
||||
|
||||
fetch = async <T = any>(
|
||||
fetch = async <T = unknown>(
|
||||
path: string,
|
||||
method: string,
|
||||
data: JSONObject | FormData | undefined,
|
||||
@@ -75,17 +75,18 @@ class ApiClient {
|
||||
} else if (method === "POST" || method === "PUT") {
|
||||
if (data instanceof FormData || typeof data === "string") {
|
||||
body = data;
|
||||
}
|
||||
|
||||
// Only stringify data if its a normal object and
|
||||
// not if it's [object FormData], in addition to
|
||||
// toggling Content-Type to application/json
|
||||
if (
|
||||
typeof data === "object" &&
|
||||
(data || "").toString() === "[object Object]"
|
||||
) {
|
||||
} else {
|
||||
isJson = true;
|
||||
body = JSON.stringify(data);
|
||||
|
||||
// Only stringify data if its a normal object and
|
||||
// not if it's [object FormData], in addition to
|
||||
// toggling Content-Type to application/json
|
||||
if (
|
||||
typeof data === "object" &&
|
||||
(data || "").toString() === "[object Object]"
|
||||
) {
|
||||
body = JSON.stringify(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +180,7 @@ class ApiClient {
|
||||
const error: {
|
||||
message?: string;
|
||||
error?: string;
|
||||
data?: Record<string, any>;
|
||||
data?: Record<string, unknown>;
|
||||
} = {};
|
||||
|
||||
try {
|
||||
@@ -243,13 +244,13 @@ class ApiClient {
|
||||
throw err;
|
||||
};
|
||||
|
||||
get = <T = any>(
|
||||
get = <T = unknown>(
|
||||
path: string,
|
||||
data: JSONObject | undefined,
|
||||
options?: FetchOptions
|
||||
) => this.fetch<T>(path, "GET", data, options);
|
||||
|
||||
post = <T = any>(
|
||||
post = <T = unknown>(
|
||||
path: string,
|
||||
data?: JSONObject | FormData | undefined,
|
||||
options?: FetchOptions
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ type LogCategory =
|
||||
| "plugins"
|
||||
| "policies";
|
||||
|
||||
type Extra = Record<string, any>;
|
||||
type Extra = Record<string, unknown>;
|
||||
|
||||
class Logger {
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,8 @@ import { locales, unicodeCLDRtoBCP47 } from "@shared/utils/date";
|
||||
import Desktop from "./Desktop";
|
||||
|
||||
/**
|
||||
* Formats a number using the user's locale where possible.
|
||||
* Formats a number using the user's locale where possible. Use `useFormatNumber` hook
|
||||
* instead of this function in React components, to automatically use the user's locale.
|
||||
*
|
||||
* @param number The number to format
|
||||
* @param locale The locale to use for formatting (BCP47 format)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
type ComponentPromise<T extends React.ComponentType<any>> = Promise<{
|
||||
type ComponentPromise<T extends React.ComponentType<unknown>> = Promise<{
|
||||
default: T;
|
||||
}>;
|
||||
|
||||
@@ -12,7 +12,7 @@ type ComponentPromise<T extends React.ComponentType<any>> = Promise<{
|
||||
* @param interval The interval between retries in milliseconds, defaults to 1000.
|
||||
* @returns A lazy component.
|
||||
*/
|
||||
export default function lazyWithRetry<T extends React.ComponentType<any>>(
|
||||
export default function lazyWithRetry<T extends React.ComponentType<unknown>>(
|
||||
component: () => ComponentPromise<T>,
|
||||
retries?: number,
|
||||
interval?: number
|
||||
@@ -20,7 +20,7 @@ export default function lazyWithRetry<T extends React.ComponentType<any>>(
|
||||
return React.lazy(() => retry(component, retries, interval));
|
||||
}
|
||||
|
||||
function retry<T extends React.ComponentType<any>>(
|
||||
function retry<T extends React.ComponentType<unknown>>(
|
||||
fn: () => ComponentPromise<T>,
|
||||
retriesLeft = 3,
|
||||
interval = 1000
|
||||
|
||||
@@ -37,6 +37,17 @@ export const isURLMentionable = ({
|
||||
);
|
||||
}
|
||||
|
||||
case IntegrationService.GitLab: {
|
||||
const settings =
|
||||
integration.settings as IntegrationSettings<IntegrationType.Embed>;
|
||||
|
||||
return (
|
||||
hostname === "gitlab.com" &&
|
||||
settings.gitlab?.project.path_with_namespace ===
|
||||
pathParts.slice(1, -2).join("/") // ensure installed project path matches with the provided url.
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -67,6 +78,16 @@ export const determineMentionType = ({
|
||||
return type === "issue" ? MentionType.Issue : undefined;
|
||||
}
|
||||
|
||||
case IntegrationService.GitLab: {
|
||||
const type = pathParts[pathParts.length - 2];
|
||||
if (type === "issues") {
|
||||
return MentionType.Issue;
|
||||
} else if (type === "merge_requests") {
|
||||
return MentionType.PullRequest;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,10 +63,6 @@ export function documentEditPath(doc: Document): string {
|
||||
return `${documentPath(doc)}/edit`;
|
||||
}
|
||||
|
||||
export function documentInsightsPath(doc: Document): string {
|
||||
return `${documentPath(doc)}/insights`;
|
||||
}
|
||||
|
||||
export function documentHistoryPath(
|
||||
doc: Document,
|
||||
revisionId?: string
|
||||
@@ -154,5 +150,3 @@ export const matchDocumentSlug =
|
||||
export const matchDocumentEdit = `/doc/${matchDocumentSlug}/edit`;
|
||||
|
||||
export const matchDocumentHistory = `/doc/${matchDocumentSlug}/history/:revisionId?`;
|
||||
|
||||
export const matchDocumentInsights = `/doc/${matchDocumentSlug}/insights`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
Outline is composed of a backend and frontend codebase in this monorepo. As both are written in TypeScript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and types. Prettier formatting and ESLint are enforced by CI.
|
||||
Outline is composed of a backend and frontend codebase in this monorepo. As both are written in TypeScript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and types. Prettier formatting and Oxlint are enforced by CI.
|
||||
|
||||
## Frontend
|
||||
|
||||
|
||||
+27
-22
@@ -51,11 +51,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.859.0",
|
||||
"@aws-sdk/lib-storage": "3.859.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.859.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.859.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.858.0",
|
||||
"@aws-sdk/client-s3": "3.864.0",
|
||||
"@aws-sdk/lib-storage": "3.864.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.864.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.864.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.864.0",
|
||||
"@babel/core": "^7.27.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.28.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
@@ -70,6 +70,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dotenvx/dotenvx": "^1.48.4",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@fast-csv/parse": "^5.0.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
@@ -87,7 +88,7 @@
|
||||
"@node-oauth/oauth2-server": "^5.2.0",
|
||||
"@notionhq/client": "^2.3.0",
|
||||
"@octokit/auth-app": "^6.1.4",
|
||||
"@octokit/webhooks": "^13.8.0",
|
||||
"@octokit/webhooks": "^13.9.1",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
@@ -119,26 +120,25 @@
|
||||
"class-validator": "^0.14.2",
|
||||
"command-score": "^0.1.2",
|
||||
"compressorjs": "^1.2.1",
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie": "^0.7.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"core-js": "^3.37.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"datadog-metrics": "^0.12.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.40.0",
|
||||
"dd-trace": "^5.62.0",
|
||||
"diff": "^5.2.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"email-providers": "^1.14.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"es6-error": "^4.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fetch-retry": "^5.0.6",
|
||||
"fetch-with-proxy": "^3.0.1",
|
||||
"form-data": "^4.0.4",
|
||||
"fractional-index": "^1.0.0",
|
||||
"framer-motion": "^4.1.17",
|
||||
"fs-extra": "^11.2.0",
|
||||
"fs-extra": "^11.3.1",
|
||||
"fuzzy-search": "^3.2.1",
|
||||
"glob": "^8.1.0",
|
||||
"http-errors": "2.0.0",
|
||||
@@ -166,18 +166,18 @@
|
||||
"koa-useragent": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mailparser": "^3.7.4",
|
||||
"mammoth": "^1.9.1",
|
||||
"mammoth": "^1.10.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^3.0.0",
|
||||
"mermaid": "11.9.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"mime-types": "^3.0.1",
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
"mobx-utils": "^4.0.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^6.10.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"octokit": "^3.2.2",
|
||||
"outline-icons": "^3.12.1",
|
||||
"oy-vey": "^0.12.1",
|
||||
@@ -205,6 +205,7 @@
|
||||
"prosemirror-tables": "^1.7.1",
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"prosemirror-view": "^1.40.1",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
"query-string": "^7.1.3",
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
"react": "^17.0.2",
|
||||
@@ -230,7 +231,7 @@
|
||||
"redlock": "^5.0.0-beta.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"refractor": "^3.6.0",
|
||||
"request-filtering-agent": "^1.1.2",
|
||||
"request-filtering-agent": "^2.0.1",
|
||||
"resolve-path": "^1.4.0",
|
||||
"rfc6902": "^5.1.2",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
@@ -255,15 +256,16 @@
|
||||
"throng": "^5.0.0",
|
||||
"tiny-cookie": "^2.5.1",
|
||||
"tmp": "^0.2.4",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"turndown": "^7.2.0",
|
||||
"ukkonen": "^2.1.0",
|
||||
"umzug": "^3.8.2",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.15.0",
|
||||
"validator": "13.15.15",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"vite-plugin-pwa": "^1.0.2",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
@@ -293,6 +295,7 @@
|
||||
"@types/glob": "^8.0.1",
|
||||
"@types/google.analytics": "^0.0.46",
|
||||
"@types/invariant": "^2.2.37",
|
||||
"@types/ioredis-mock": "^8.2.6",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/katex": "^0.16.7",
|
||||
@@ -308,7 +311,7 @@
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/markdown-it-container": "^2.0.9",
|
||||
"@types/markdown-it-emoji": "^3.0.1",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/natural-sort": "^0.0.24",
|
||||
"@types/node": "20.17.30",
|
||||
"@types/node-fetch": "^2.6.9",
|
||||
@@ -316,6 +319,7 @@
|
||||
"@types/passport-oauth2": "^1.4.17",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/png-chunks-extract": "^1.0.2",
|
||||
"@types/proxy-from-env": "^1.0.4",
|
||||
"@types/quoted-printable": "^1.0.2",
|
||||
"@types/react": "^17.0.34",
|
||||
"@types/react-avatar-editor": "^13.0.4",
|
||||
@@ -339,7 +343,7 @@
|
||||
"@types/tmp": "^0.2.6",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/utf8": "^3.0.3",
|
||||
"@types/validator": "^13.15.0",
|
||||
"@types/validator": "^13.15.2",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
|
||||
@@ -348,8 +352,10 @@
|
||||
"browserslist-to-esbuild": "^1.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"discord-api-types": "^0.37.119",
|
||||
"eslint": "^9.33.0",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^8.13.0",
|
||||
"ioredis-mock": "^8.9.0",
|
||||
"jest-cli": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
@@ -360,10 +366,9 @@
|
||||
"prettier": "^3.6.2",
|
||||
"react-refresh": "^0.17.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.1.0",
|
||||
"rollup-plugin-webpack-stats": "^2.1.3",
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"typescript": "^5.9.2",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
},
|
||||
"resolutions": {
|
||||
@@ -377,6 +382,6 @@
|
||||
"qs": "6.9.7",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.86.0",
|
||||
"version": "0.86.1",
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -177,7 +177,17 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
|
||||
client,
|
||||
});
|
||||
};
|
||||
router.get("email.callback", validate(T.EmailCallbackSchema), emailCallback);
|
||||
router.post("email.callback", validate(T.EmailCallbackSchema), emailCallback);
|
||||
router.get(
|
||||
"email.callback",
|
||||
rateLimiter(RateLimiterStrategy.TenPerHour),
|
||||
validate(T.EmailCallbackSchema),
|
||||
emailCallback
|
||||
);
|
||||
router.post(
|
||||
"email.callback",
|
||||
rateLimiter(RateLimiterStrategy.TenPerHour),
|
||||
validate(T.EmailCallbackSchema),
|
||||
emailCallback
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -22,7 +22,7 @@ export class GitHubUtils {
|
||||
*/
|
||||
public static callbackUrl(
|
||||
{ baseUrl, params }: { baseUrl: string; params?: string } = {
|
||||
baseUrl: `${env.URL}`,
|
||||
baseUrl: env.URL,
|
||||
params: undefined,
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
fill?: string;
|
||||
};
|
||||
|
||||
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
>
|
||||
<path
|
||||
d="M12 20.8L4.6 13.4L6.3 7.8L12 13.4L17.7 7.8L19.4 13.4L12 20.8Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M12 20.8L4.6 13.4L6.3 7.8L12 13.4L12 20.8Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fillOpacity="0.3"
|
||||
/>
|
||||
<path
|
||||
d="M4.6 13.4L2.5 7.8L6.3 7.8L4.6 13.4Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fillOpacity="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M19.4 13.4L21.5 7.8L17.7 7.8L19.4 13.4Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fillOpacity="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M6.3 7.8L8.7 2.2L15.3 2.2L17.7 7.8L6.3 7.8Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fillOpacity="0.7"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import List from "~/components/List";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Notice from "~/components/Notice";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import env from "~/env";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GitLabIcon from "./Icon";
|
||||
import { GitLabConnectButton } from "./components/GitLabButton";
|
||||
|
||||
function GitLab() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const query = useQuery();
|
||||
const error = query.get("error");
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchAll({
|
||||
service: IntegrationService.GitLab,
|
||||
withRelations: true,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
return (
|
||||
<Scene title="GitLab" icon={<GitLabIcon />}>
|
||||
<Heading>GitLab</Heading>
|
||||
|
||||
{error === "access_denied" && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
Whoops, you need to accept the permissions in GitLab to connect{" "}
|
||||
{{ appName }} to your project. Try again?
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
{error === "unauthenticated" && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
Something went wrong while authenticating your request. Please try
|
||||
logging in again.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
{env.GITLAB_CLIENT_ID ? (
|
||||
<>
|
||||
<Text as="p">
|
||||
<Trans>
|
||||
Enable previews of GitLab issues and merge requests in documents
|
||||
by connecting a GitLab project to {appName}.
|
||||
</Trans>
|
||||
</Text>
|
||||
{integrations.gitlab.length ? (
|
||||
<>
|
||||
<Heading as="h2">
|
||||
<Flex justify="space-between" auto>
|
||||
{t("Connected")}
|
||||
<GitLabConnectButton icon={<PlusIcon />} />
|
||||
</Flex>
|
||||
</Heading>
|
||||
<List>
|
||||
{integrations.gitlab.map((integration) => {
|
||||
const gitlabProject = integration.settings?.gitlab?.project;
|
||||
const integrationCreatedBy = integration.user
|
||||
? integration.user.name
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={gitlabProject?.id}
|
||||
small
|
||||
title={gitlabProject?.name}
|
||||
subtitle={
|
||||
integrationCreatedBy ? (
|
||||
<>
|
||||
<Trans>Enabled by {{ integrationCreatedBy }}</Trans>{" "}
|
||||
·{" "}
|
||||
<Time
|
||||
dateTime={integration.createdAt}
|
||||
relative={false}
|
||||
format={{ en_US: "MMMM d, y" }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<PlaceholderText />
|
||||
)
|
||||
}
|
||||
image={
|
||||
<TeamLogo
|
||||
src={gitlabProject?.avatar_url}
|
||||
size={AvatarSize.Large}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<ConnectedButton
|
||||
onClick={integration.delete}
|
||||
confirmationMessage={t(
|
||||
"Disconnecting will prevent previewing GitLab links from this project in documents. Are you sure?"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
<GitLabConnectButton icon={<GitLabIcon />} />
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Notice>
|
||||
<Trans>
|
||||
The GitLab integration is currently disabled. Please set the
|
||||
associated environment variables and restart the server to enable
|
||||
the integration.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GitLab);
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Button, { type Props } from "~/components/Button";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { redirectTo } from "~/utils/urls";
|
||||
import { GitLabUtils } from "../../shared/GitLabUtils";
|
||||
|
||||
export function GitLabConnectButton(props: Props<HTMLButtonElement>) {
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
redirectTo(GitLabUtils.authUrl({ state: { teamId: team.id } }))
|
||||
}
|
||||
neutral
|
||||
{...props}
|
||||
>
|
||||
{t("Connect")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "gitlab",
|
||||
"name": "GitLab",
|
||||
"priority": 16,
|
||||
"description": "Adds a GitLab integration for link unfurling and converting links to mentions.",
|
||||
"after": "linear"
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Router from "koa-router";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { IntegrationAuthentication, Integration } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { GitLab } from "../gitlab";
|
||||
import UploadGitLabProjectAvatarTask from "../tasks/UploadGitLabProjectAvatarTask";
|
||||
import * as T from "./schema";
|
||||
import { GitLabUtils } from "plugins/gitlab/shared/GitLabUtils";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get(
|
||||
"gitlab.callback",
|
||||
auth({
|
||||
optional: true,
|
||||
}),
|
||||
validate(T.GitLabCallbackSchema),
|
||||
apexAuthRedirect<T.GitLabCallbackReq>({
|
||||
getTeamId: (ctx) => GitLabUtils.parseState(ctx.input.query.state)?.teamId,
|
||||
getRedirectPath: (ctx, team) =>
|
||||
GitLabUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
}),
|
||||
getErrorPath: () => GitLabUtils.errorUrl("unauthenticated"),
|
||||
}),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.GitLabCallbackReq>) => {
|
||||
const { code, error } = ctx.input.query;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
|
||||
if (error) {
|
||||
ctx.redirect(GitLabUtils.errorUrl(error));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// validation middleware ensures that code is non-null at this point.
|
||||
const oauth = await GitLab.oauthAccess(code!);
|
||||
const project = await GitLab.getInstalledProject(oauth.access_token);
|
||||
|
||||
const authentication = await IntegrationAuthentication.create(
|
||||
{
|
||||
service: IntegrationService.GitLab,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: oauth.access_token,
|
||||
scopes: oauth.scope.split(" "),
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
const integration = await Integration.create<
|
||||
Integration<IntegrationType.Embed>
|
||||
>(
|
||||
{
|
||||
service: IntegrationService.GitLab,
|
||||
type: IntegrationType.Embed,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
gitlab: {
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
path_with_namespace: project.path_with_namespace,
|
||||
avatar_url: project.avatar_url,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
transaction.afterCommit(async () => {
|
||||
if (project.avatar_url) {
|
||||
await new UploadGitLabProjectAvatarTask().schedule({
|
||||
integrationId: integration.id,
|
||||
avatarUrl: project.avatar_url,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.redirect(GitLabUtils.successUrl());
|
||||
} catch (err) {
|
||||
Logger.error("Encountered error during GitLab OAuth callback", err);
|
||||
ctx.redirect(GitLabUtils.errorUrl("unknown"));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,17 @@
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { z } from "zod";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
export const GitLabCallbackSchema = BaseSchema.extend({
|
||||
query: z
|
||||
.object({
|
||||
code: z.string().nullish(),
|
||||
state: z.string(),
|
||||
error: z.string().nullish(),
|
||||
})
|
||||
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
|
||||
message: "one of code or error is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export type GitLabCallbackReq = z.infer<typeof GitLabCallbackSchema>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import env from "@server/env";
|
||||
|
||||
export default {
|
||||
GITLAB_CLIENT_ID: env.GITLAB_CLIENT_ID,
|
||||
GITLAB_CLIENT_SECRET: env.GITLAB_CLIENT_SECRET,
|
||||
};
|
||||
@@ -0,0 +1,273 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationType,
|
||||
UnfurlResourceType,
|
||||
} from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration } from "@server/models";
|
||||
import User from "@server/models/User";
|
||||
import { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
|
||||
import { GitLabUtils } from "../shared/GitLabUtils";
|
||||
import env from "./env";
|
||||
|
||||
const AccessTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string(),
|
||||
expires_in: z.number(),
|
||||
refresh_token: z.string(),
|
||||
scope: z.string(),
|
||||
created_at: z.number(),
|
||||
});
|
||||
|
||||
const GitLabProjectSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
path_with_namespace: z.string(),
|
||||
avatar_url: z.string().optional(),
|
||||
});
|
||||
|
||||
const GitLabIssueSchema = z.object({
|
||||
id: z.number(),
|
||||
iid: z.number(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
state: z.string(),
|
||||
created_at: z.string(),
|
||||
author: z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
avatar_url: z.string().nullable(),
|
||||
}),
|
||||
labels: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const GitLabMergeRequestSchema = z.object({
|
||||
id: z.number(),
|
||||
iid: z.number(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
state: z.string(),
|
||||
created_at: z.string(),
|
||||
author: z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
avatar_url: z.string().nullable(),
|
||||
}),
|
||||
labels: z.array(z.string()).optional(),
|
||||
draft: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export class GitLab {
|
||||
private static supportedUnfurls = [
|
||||
UnfurlResourceType.Issue,
|
||||
UnfurlResourceType.PR,
|
||||
];
|
||||
|
||||
static async oauthAccess(code: string) {
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set("code", code);
|
||||
body.set("client_id", env.GITLAB_CLIENT_ID!);
|
||||
body.set("client_secret", env.GITLAB_CLIENT_SECRET!);
|
||||
body.set("redirect_uri", GitLabUtils.callbackUrl());
|
||||
body.set("grant_type", "authorization_code");
|
||||
|
||||
const res = await fetch(GitLabUtils.tokenUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Error while exchanging oauth code from GitLab; status: ${res.status}`
|
||||
);
|
||||
}
|
||||
|
||||
return AccessTokenResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
static async revokeAccess(accessToken: string) {
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set("client_id", env.GITLAB_CLIENT_ID!);
|
||||
body.set("client_secret", env.GITLAB_CLIENT_SECRET!);
|
||||
body.set("token", accessToken);
|
||||
|
||||
await fetch(GitLabUtils.revokeUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
static async getInstalledProject(accessToken: string) {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
// Get the first project the user has access to
|
||||
// In a real implementation, we would want to let the user select which project to connect
|
||||
const res = await fetch(
|
||||
"https://gitlab.com/api/v4/projects?membership=true&per_page=1",
|
||||
{
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Error while fetching GitLab projects; status: ${res.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const projects = await res.json();
|
||||
if (!projects.length) {
|
||||
throw new Error("No GitLab projects found");
|
||||
}
|
||||
|
||||
return GitLabProjectSchema.parse(projects[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param url GitLab resource url
|
||||
* @param actor User attempting to unfurl resource url
|
||||
* @returns An object containing resource details e.g, a GitLab issue or merge request details
|
||||
*/
|
||||
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
|
||||
const resource = GitLab.parseUrl(url);
|
||||
|
||||
if (!resource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const integration = (await Integration.scope("withAuthentication").findOne({
|
||||
where: {
|
||||
service: IntegrationService.GitLab,
|
||||
teamId: actor.teamId,
|
||||
"settings.gitlab.project.path_with_namespace": resource.projectPath,
|
||||
},
|
||||
})) as Integration<IntegrationType.Embed>;
|
||||
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${integration.authentication.token}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
let apiUrl: string;
|
||||
let resourceSchema: z.ZodObject<z.ZodRawShape>;
|
||||
let resourceType: UnfurlResourceType;
|
||||
|
||||
if (resource.type === "issues") {
|
||||
apiUrl = `https://gitlab.com/api/v4/projects/${encodeURIComponent(resource.projectPath)}/issues/${resource.id}`;
|
||||
resourceSchema = GitLabIssueSchema;
|
||||
resourceType = UnfurlResourceType.Issue;
|
||||
} else if (resource.type === "merge_requests") {
|
||||
apiUrl = `https://gitlab.com/api/v4/projects/${encodeURIComponent(resource.projectPath)}/merge_requests/${resource.id}`;
|
||||
resourceSchema = GitLabMergeRequestSchema;
|
||||
resourceType = UnfurlResourceType.PR;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(apiUrl, { headers });
|
||||
|
||||
if (res.status !== 200) {
|
||||
return { error: `Resource not found (${res.status})` };
|
||||
}
|
||||
|
||||
const data = resourceSchema.parse(await res.json());
|
||||
|
||||
// Fetch labels if they exist
|
||||
let labels = [];
|
||||
if (data.labels && data.labels.length > 0) {
|
||||
labels = data.labels.map((label) => ({
|
||||
name: label,
|
||||
color: "#428BCA", // Default GitLab blue
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
type: resourceType,
|
||||
url,
|
||||
id: `#${data.iid}`,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
author: {
|
||||
name: data.author.name,
|
||||
avatarUrl: data.author.avatar_url || "",
|
||||
},
|
||||
labels,
|
||||
state: {
|
||||
name: data.state,
|
||||
color: data.state === "opened" ? "#1aaa55" : "#db3b21", // Green for open, red for closed
|
||||
draft:
|
||||
resourceType === UnfurlResourceType.PR ? data.draft : undefined,
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
} satisfies UnfurlIssueOrPR;
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to fetch resource from GitLab", err);
|
||||
return { error: err.message || "Unknown error" };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a given URL and returns resource identifiers for GitLab specific URLs
|
||||
*
|
||||
* @param url URL to parse
|
||||
* @returns {object} Containing resource identifiers - `projectPath`, `type`, and `id`.
|
||||
*/
|
||||
private static parseUrl(url: string) {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== "gitlab.com") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = pathname.split("/");
|
||||
// Remove empty first element
|
||||
parts.shift();
|
||||
|
||||
// GitLab URLs are in the format: /namespace/project/-/issues/1 or /namespace/project/-/merge_requests/1
|
||||
// The namespace can have multiple levels (e.g., /group/subgroup/project/-/issues/1)
|
||||
if (parts.length < 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the index of "-" which separates project path from resource type
|
||||
const separatorIndex = parts.indexOf("-");
|
||||
if (separatorIndex === -1 || separatorIndex === parts.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectPath = parts.slice(0, separatorIndex).join("/");
|
||||
const type = parts[separatorIndex + 1];
|
||||
const id = parts[separatorIndex + 2];
|
||||
|
||||
if (
|
||||
!type ||
|
||||
!id ||
|
||||
!GitLab.supportedUnfurls.includes(type as UnfurlResourceType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { projectPath, type, id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import BaseTask from "@server/queues/tasks/BaseTask";
|
||||
import { Integration } from "@server/models";
|
||||
import { FileOperation } from "@server/models";
|
||||
import fetch from "node-fetch";
|
||||
|
||||
type Props = {
|
||||
integrationId: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export default class UploadGitLabProjectAvatarTask extends BaseTask<Props> {
|
||||
public async perform({ integrationId, avatarUrl }: Props) {
|
||||
const integration = await Integration.findByPk(integrationId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(avatarUrl);
|
||||
const buffer = await res.buffer();
|
||||
const name = avatarUrl.split("/").pop() || "avatar";
|
||||
const contentType = res.headers.get("content-type") || "image/png";
|
||||
|
||||
const operation = await FileOperation.createFromBuffer({
|
||||
buffer,
|
||||
contentType,
|
||||
name,
|
||||
userId: integration.userId,
|
||||
teamId: integration.teamId,
|
||||
source: "gitlab",
|
||||
});
|
||||
|
||||
await integration.update({
|
||||
settings: {
|
||||
...integration.settings,
|
||||
gitlab: {
|
||||
...(integration.settings as Integration<IntegrationType.Embed>)
|
||||
.gitlab,
|
||||
project: {
|
||||
...(integration.settings as Integration<IntegrationType.Embed>)
|
||||
.gitlab?.project,
|
||||
avatar_url: operation.url,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// If the avatar upload fails, we don't need to fail the entire task
|
||||
// as it's not critical to the integration's functionality.
|
||||
// Just log the error and continue.
|
||||
this.logger.error(
|
||||
`Failed to upload GitLab project avatar: ${err.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import queryString from "query-string";
|
||||
import env from "@shared/env";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
|
||||
export type OAuthState = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export class GitLabUtils {
|
||||
private static oauthScopes = "api read_api read_user read_repository";
|
||||
|
||||
public static tokenUrl = "https://gitlab.com/oauth/token";
|
||||
public static revokeUrl = "https://gitlab.com/oauth/revoke";
|
||||
private static authBaseUrl = "https://gitlab.com/oauth/authorize";
|
||||
|
||||
private static settingsUrl = integrationSettingsPath("gitlab");
|
||||
|
||||
static parseState(state: string): OAuthState {
|
||||
return JSON.parse(state);
|
||||
}
|
||||
|
||||
static successUrl() {
|
||||
return this.settingsUrl;
|
||||
}
|
||||
|
||||
static errorUrl(error: string) {
|
||||
return `${this.settingsUrl}?error=${error}`;
|
||||
}
|
||||
|
||||
static callbackUrl(
|
||||
{ baseUrl, params }: { baseUrl: string; params?: string } = {
|
||||
baseUrl: env.URL,
|
||||
params: undefined,
|
||||
}
|
||||
) {
|
||||
return params
|
||||
? `${baseUrl}/api/gitlab.callback?${params}`
|
||||
: `${baseUrl}/api/gitlab.callback`;
|
||||
}
|
||||
|
||||
static authUrl({ state }: { state: OAuthState }) {
|
||||
const params = {
|
||||
client_id: env.GITLAB_CLIENT_ID,
|
||||
redirect_uri: this.callbackUrl(),
|
||||
state: JSON.stringify(state),
|
||||
scope: this.oauthScopes,
|
||||
response_type: "code",
|
||||
};
|
||||
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export class LinearUtils {
|
||||
|
||||
static callbackUrl(
|
||||
{ baseUrl, params }: { baseUrl: string; params?: string } = {
|
||||
baseUrl: `${env.URL}`,
|
||||
baseUrl: env.URL,
|
||||
params: undefined,
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -55,7 +55,15 @@ export const Notion = observer(() => {
|
||||
onClose: clearQueryParams,
|
||||
});
|
||||
}
|
||||
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
|
||||
}, [
|
||||
t,
|
||||
dialogs,
|
||||
oauthSuccess,
|
||||
service,
|
||||
clearQueryParams,
|
||||
handleSubmit,
|
||||
integrationId,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!oauthError) {
|
||||
|
||||
@@ -52,7 +52,15 @@ export function ImportDialog({ integrationId, onSubmit }: Props) {
|
||||
toast.error(err.message);
|
||||
resetSubmitting();
|
||||
}
|
||||
}, [permission, onSubmit]);
|
||||
}, [
|
||||
permission,
|
||||
onSubmit,
|
||||
integrationId,
|
||||
t,
|
||||
imports,
|
||||
resetSubmitting,
|
||||
setSubmitting,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Flex column gap={12}>
|
||||
|
||||
@@ -36,7 +36,7 @@ export class NotionUtils {
|
||||
|
||||
static callbackUrl(
|
||||
{ baseUrl, params }: { baseUrl: string; params?: string } = {
|
||||
baseUrl: `${env.URL}`,
|
||||
baseUrl: env.URL,
|
||||
params: undefined,
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import OAuth2Strategy, { Strategy } from "passport-oauth2";
|
||||
import * as OAuth2StrategyModule from "passport-oauth2";
|
||||
import { Request } from "express";
|
||||
|
||||
const { Strategy } = OAuth2StrategyModule;
|
||||
|
||||
type OIDCOptions = Record<string, unknown> & {
|
||||
originalQuery?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export class OIDCStrategy extends Strategy {
|
||||
constructor(
|
||||
options: OAuth2Strategy.StrategyOptionsWithRequest,
|
||||
verify: OAuth2Strategy.VerifyFunctionWithRequest
|
||||
options: OAuth2StrategyModule.StrategyOptionsWithRequest,
|
||||
verify: OAuth2StrategyModule.VerifyFunctionWithRequest
|
||||
) {
|
||||
super(options, verify);
|
||||
|
||||
@@ -14,15 +21,17 @@ export class OIDCStrategy extends Strategy {
|
||||
}
|
||||
}
|
||||
|
||||
authenticate(req: any, options: any) {
|
||||
options.originalQuery = req.query;
|
||||
super.authenticate(req, options);
|
||||
authenticate(req: Request, options?: unknown) {
|
||||
const opts = (options || {}) as OIDCOptions;
|
||||
opts.originalQuery = req.query as Record<string, unknown>;
|
||||
super.authenticate(req, opts);
|
||||
}
|
||||
|
||||
authorizationParams(options: any) {
|
||||
authorizationParams(options: unknown) {
|
||||
const opts = options as OIDCOptions;
|
||||
return {
|
||||
...(options.originalQuery || {}),
|
||||
...(super.authorizationParams?.(options) || {}),
|
||||
...(opts.originalQuery ?? {}),
|
||||
...super.authorizationParams?.(options),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import env from "./env";
|
||||
|
||||
const SLACK_API_URL = "https://slack.com/api";
|
||||
|
||||
export async function post(endpoint: string, body: Record<string, any>) {
|
||||
export async function post(endpoint: string, body: Record<string, unknown>) {
|
||||
let data;
|
||||
const token = body.token;
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function post(endpoint: string, body: Record<string, any>) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function request(endpoint: string, body: Record<string, any>) {
|
||||
export async function request(endpoint: string, body: Record<string, unknown>) {
|
||||
let data;
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,7 @@ export class SlackUtils {
|
||||
static createState(
|
||||
teamId: string,
|
||||
type: IntegrationType,
|
||||
data?: Record<string, any>
|
||||
data?: Record<string, unknown>
|
||||
) {
|
||||
return JSON.stringify({ type, teamId, ...data });
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export class SlackUtils {
|
||||
|
||||
static callbackUrl(
|
||||
{ baseUrl, params }: { baseUrl: string; params?: string } = {
|
||||
baseUrl: `${env.URL}`,
|
||||
baseUrl: env.URL,
|
||||
params: undefined,
|
||||
}
|
||||
) {
|
||||
@@ -46,7 +46,7 @@ export class SlackUtils {
|
||||
|
||||
static connectUrl(
|
||||
{ baseUrl, params }: { baseUrl: string; params?: string } = {
|
||||
baseUrl: `${env.URL}`,
|
||||
baseUrl: env.URL,
|
||||
params: undefined,
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import JWT from "jsonwebtoken";
|
||||
import Router from "koa-router";
|
||||
import mime from "mime-types";
|
||||
import contentDisposition from "content-disposition";
|
||||
import env from "@server/env";
|
||||
import {
|
||||
AuthenticationError,
|
||||
@@ -27,7 +28,7 @@ const router = new Router();
|
||||
router.post(
|
||||
"files.create",
|
||||
rateLimiter(RateLimiterStrategy.TenPerMinute),
|
||||
auth(),
|
||||
auth({ allowMultipart: true }),
|
||||
validate(T.FilesCreateSchema),
|
||||
multipart({
|
||||
maximumFileSize: Math.max(
|
||||
@@ -97,11 +98,14 @@ router.get(
|
||||
ctx.set("Cache-Control", cacheHeader);
|
||||
ctx.set("Content-Type", contentType);
|
||||
ctx.set("Content-Security-Policy", "sandbox");
|
||||
ctx.attachment(fileName, {
|
||||
type: forceDownload
|
||||
? "attachment"
|
||||
: FileStorage.getContentDisposition(contentType),
|
||||
});
|
||||
ctx.set(
|
||||
"Content-Disposition",
|
||||
contentDisposition(fileName, {
|
||||
type: forceDownload
|
||||
? "attachment"
|
||||
: FileStorage.getContentDisposition(contentType),
|
||||
})
|
||||
);
|
||||
|
||||
// Handle byte range requests
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
|
||||
|
||||
@@ -787,7 +787,8 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
const failedDeliveries = deliveriesInWindow.filter(
|
||||
(delivery) => delivery.status === "failed"
|
||||
);
|
||||
const failureRate = (failedDeliveries.length / deliveriesInWindow.length) * 100;
|
||||
const failureRate =
|
||||
(failedDeliveries.length / deliveriesInWindow.length) * 100;
|
||||
|
||||
// Only log analysis if there are failures to report
|
||||
if (failedDeliveries.length > 0) {
|
||||
@@ -802,7 +803,11 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
}
|
||||
|
||||
// Check if failure rate exceeds threshold and we have enough data points
|
||||
if (failureRate >= failureRateThreshold && deliveriesInWindow.length >= DeliverWebhookTask.MIN_DELIVERIES_FOR_ANALYSIS) {
|
||||
if (
|
||||
failureRate >= failureRateThreshold &&
|
||||
deliveriesInWindow.length >=
|
||||
DeliverWebhookTask.MIN_DELIVERIES_FOR_ANALYSIS
|
||||
) {
|
||||
Logger.warn("Disabling webhook due to high failure rate", {
|
||||
subscriptionId: subscription.id,
|
||||
failureRate: Math.round(failureRate * 100) / 100,
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("accountProvisioner", () => {
|
||||
describe("hosted", () => {
|
||||
it("should create a new user and team", async () => {
|
||||
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const email = faker.internet.email();
|
||||
const { user, team, isNewTeam, isNewUser } = await accountProvisioner(
|
||||
ctx,
|
||||
{
|
||||
@@ -71,7 +71,7 @@ describe("accountProvisioner", () => {
|
||||
});
|
||||
const authentications = await existing.$get("authentications");
|
||||
const authentication = authentications[0];
|
||||
const newEmail = faker.internet.email().toLowerCase();
|
||||
const newEmail = faker.internet.email();
|
||||
const { user, isNewUser, isNewTeam } = await accountProvisioner(ctx, {
|
||||
user: {
|
||||
name: existing.name,
|
||||
@@ -113,7 +113,7 @@ describe("accountProvisioner", () => {
|
||||
|
||||
const providers = await existingTeam.$get("authenticationProviders");
|
||||
const authenticationProvider = providers[0];
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const email = faker.internet.email();
|
||||
const userWithoutAuth = await buildUser({
|
||||
email,
|
||||
teamId: existingTeam.id,
|
||||
@@ -245,7 +245,7 @@ describe("accountProvisioner", () => {
|
||||
const admin = await buildAdmin({ teamId: existingTeam.id });
|
||||
const providers = await existingTeam.$get("authenticationProviders");
|
||||
const authenticationProvider = providers[0];
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const email = faker.internet.email();
|
||||
|
||||
await TeamDomain.create({
|
||||
teamId: existingTeam.id,
|
||||
@@ -344,7 +344,7 @@ describe("accountProvisioner", () => {
|
||||
"authenticationProviders"
|
||||
);
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const email = faker.internet.email();
|
||||
const { user, isNewUser } = await accountProvisioner(ctx, {
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
@@ -384,6 +384,53 @@ describe("accountProvisioner", () => {
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should handle emails with capital letters correctly", async () => {
|
||||
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
|
||||
const email = "Jenny.Tester@EXAMPLE.COM";
|
||||
|
||||
const params = {
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email,
|
||||
avatarUrl: faker.image.avatar(),
|
||||
},
|
||||
team: {
|
||||
name: "New workspace",
|
||||
avatarUrl: faker.image.avatar(),
|
||||
subdomain: faker.internet.domainWord(),
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: "google",
|
||||
providerId: faker.internet.domainName(),
|
||||
},
|
||||
authentication: {
|
||||
providerId: uuidv4(),
|
||||
accessToken: "123",
|
||||
scopes: ["read"],
|
||||
},
|
||||
};
|
||||
|
||||
const { user, isNewTeam, isNewUser } = await accountProvisioner(
|
||||
ctx,
|
||||
params
|
||||
);
|
||||
|
||||
expect(user.email).toEqual(email);
|
||||
expect(isNewUser).toEqual(true);
|
||||
expect(isNewTeam).toEqual(true);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
|
||||
// Test that we can find the user again
|
||||
const existing = await accountProvisioner(ctx, params);
|
||||
|
||||
expect(user.email).toEqual(email);
|
||||
expect(existing.isNewTeam).toEqual(false);
|
||||
expect(existing.isNewUser).toEqual(false);
|
||||
expect(existing.user.id).toEqual(user.id);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("self hosted", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
InvalidAuthenticationError,
|
||||
TeamPendingDeletionError,
|
||||
} from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { Team, AuthenticationProvider } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
@@ -74,7 +75,12 @@ async function teamProvisioner(
|
||||
} else if (teamId) {
|
||||
// The user is attempting to log into a team with an unfamiliar SSO provider
|
||||
if (env.isCloudHosted) {
|
||||
throw InvalidAuthenticationError();
|
||||
const err = InvalidAuthenticationError();
|
||||
Logger.error("Authentication provider does not exist for team", err, {
|
||||
authenticationProvider,
|
||||
teamId,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
// This team + auth provider combination has not been seen before in self hosted
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user