Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33307c636a | |||
| 7fb8706c30 | |||
| 617504d8bb | |||
| 95537af5f3 | |||
| 1765a19aab | |||
| a73a8626c5 | |||
| 88054a3899 | |||
| 409313639d | |||
| 78ad61c9fb | |||
| 2d9de26041 | |||
| 0a9bd39aac | |||
| f614f3dd3f | |||
| 7f818c7329 | |||
| 27d116c8e2 | |||
| 7e962d36e6 | |||
| f09450e7ea | |||
| 05b9c69da8 | |||
| ac55ad55dd | |||
| 8c11b6cfc8 | |||
| d858289159 | |||
| 52d420bd98 | |||
| 386eebb117 | |||
| d0993c3393 | |||
| 54d17503bf | |||
| 0de2a3dc98 | |||
| 73ac18bbde | |||
| 18dcef8ce4 | |||
| 7458228df0 | |||
| 7c93f8a039 | |||
| d6a126d974 | |||
| 779fb1d568 | |||
| a0ce14f2a2 | |||
| 091abf0b9d | |||
| 342c42194e | |||
| 8383a0ee1e | |||
| 19a696942e | |||
| f1a5e95f77 | |||
| 99fedfa354 | |||
| 9da73202c7 | |||
| 30db7bc554 | |||
| b40eaf4184 | |||
| 3aff344501 | |||
| 0f812d70c1 | |||
| 125e9c2e0b | |||
| 95402b4b52 | |||
| d01e3ad09c | |||
| edb6d44bdc |
@@ -211,10 +211,6 @@ GITHUB_APP_PRIVATE_KEY=
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
# The GitLab integration allows previewing issue and merge request links as rich mentions
|
||||
GITLAB_CLIENT_ID=
|
||||
GITLAB_CLIENT_SECRET=
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed in addition to Slack authentication:
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
|
||||
|
||||
@@ -26,3 +26,6 @@ updates:
|
||||
aws:
|
||||
patterns:
|
||||
- "@aws-sdk/*"
|
||||
radix-ui:
|
||||
patterns:
|
||||
- "@radix-ui/*"
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn lint
|
||||
- run: yarn lint --quiet
|
||||
|
||||
types:
|
||||
needs: build
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.86.1
|
||||
Licensed Work: Outline 0.87.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-09
|
||||
Change Date: 2029-08-31
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { toast } from "sonner";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
|
||||
import history from "~/utils/history";
|
||||
import { createActionV2 } from "..";
|
||||
import { ActiveDocumentSection } from "../sections";
|
||||
|
||||
@@ -50,16 +49,6 @@ export const resolveCommentFactory = ({
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async ({ t }) => {
|
||||
await comment.resolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onResolve();
|
||||
toast.success(t("Thread resolved"));
|
||||
},
|
||||
@@ -82,16 +71,6 @@ export const unresolveCommentFactory = ({
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async () => {
|
||||
await comment.unresolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onUnresolve();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
key={collaborator.id}
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -11,9 +11,15 @@ class DocumentContext {
|
||||
/** The editor instance for this document */
|
||||
editor?: Editor;
|
||||
|
||||
/** The ID of the currently focused comment, or null if no comment is focused */
|
||||
@observable
|
||||
focusedCommentId: string | null = null;
|
||||
|
||||
/** Whether the editor has been initialized */
|
||||
@observable
|
||||
isEditorInitialized: boolean = false;
|
||||
|
||||
/** The headings in the document */
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
@@ -39,6 +45,11 @@ class DocumentContext {
|
||||
this.isEditorInitialized = initialized;
|
||||
};
|
||||
|
||||
@action
|
||||
setFocusedCommentId = (commentId: string | null) => {
|
||||
this.focusedCommentId = commentId;
|
||||
};
|
||||
|
||||
@action
|
||||
updateState = () => {
|
||||
this.updateHeadings();
|
||||
|
||||
@@ -25,6 +25,10 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { ContextMenu } from "./Menu/ContextMenu";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -50,6 +54,7 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
@@ -78,87 +83,109 @@ function DocumentListItem(
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
|
||||
const isShared = !!(
|
||||
userMemberships.getByDocumentId(document.id) ||
|
||||
groupMemberships.getByDocumentId(document.id)
|
||||
);
|
||||
|
||||
const sidebarContext = determineSidebarContext({
|
||||
document,
|
||||
user,
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
role="menuitem"
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
const actionContext = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId ? document.collectionId : undefined,
|
||||
});
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
const contextMenuAction = useDocumentMenuAction({ document });
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
action={contextMenuAction}
|
||||
context={actionContext}
|
||||
ariaLabel={t("Document options")}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
role="menuitem"
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,15 +30,10 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
) {
|
||||
const authorName = author.name;
|
||||
const urlObj = new URL(url);
|
||||
let service;
|
||||
|
||||
if (urlObj.hostname === "github.com") {
|
||||
service = IntegrationService.GitHub;
|
||||
} else if (urlObj.hostname === "gitlab.com") {
|
||||
service = IntegrationService.GitLab;
|
||||
} else {
|
||||
service = IntegrationService.Linear;
|
||||
}
|
||||
const service =
|
||||
urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.Linear;
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
export interface LazyComponent<T extends React.ComponentType<unknown>> {
|
||||
export interface LazyComponent<T extends React.ComponentType<any>> {
|
||||
Component: React.LazyExoticComponent<T>;
|
||||
preload: () => Promise<{ default: T }>;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ interface LazyLoadOptions {
|
||||
* MyComponent.preload();
|
||||
* ```
|
||||
*/
|
||||
export function createLazyComponent<T extends React.ComponentType<unknown>>(
|
||||
export function createLazyComponent<T extends React.ComponentType<any>>(
|
||||
factory: () => Promise<{ default: T }>,
|
||||
options: LazyLoadOptions = {}
|
||||
): LazyComponent<T> {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as React from "react";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { ActionContext, ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import { toMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
/** Action context to use - new context will be created if not provided */
|
||||
context?: ActionContext;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** ARIA label for the menu */
|
||||
ariaLabel: string;
|
||||
/** Callback when menu is opened */
|
||||
onOpen?: () => void;
|
||||
/** Callback when menu is closed */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const ContextMenu = observer(
|
||||
({ action, children, ariaLabel, context, onOpen, onClose }: Props) => {
|
||||
const isMobile = useMobile();
|
||||
const contentRef = React.useRef<React.ElementRef<typeof MenuContent>>(null);
|
||||
|
||||
const actionContext =
|
||||
context ??
|
||||
useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
|
||||
const menuItems = useComputed(() => {
|
||||
if (!open) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (action.children as ActionV2Variant[]).map((childAction) =>
|
||||
actionV2ToMenuItem(childAction, actionContext)
|
||||
);
|
||||
}, [open, action.children, actionContext]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onOpen, onClose]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const content = toMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<MenuProvider variant={"context"}>
|
||||
<Menu onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger aria-label={ariaLabel}>{children}</MenuTrigger>
|
||||
<MenuContent
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -8,11 +8,8 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import {
|
||||
DropdownMenu as DropdownMenuRoot,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
} from "~/components/primitives/DropdownMenu";
|
||||
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -23,7 +20,7 @@ import {
|
||||
MenuItem,
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
import { toDropdownMenuItems, toMobileMenuItems } from "./transformer";
|
||||
import { toMenuItems, toMobileMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
@@ -66,7 +63,7 @@ export const DropdownMenu = observer(
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const isMobile = useMobile();
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
|
||||
React.useRef<React.ElementRef<typeof MenuContent>>(null);
|
||||
|
||||
const actionContext =
|
||||
context ??
|
||||
@@ -126,24 +123,26 @@ export const DropdownMenu = observer(
|
||||
);
|
||||
}
|
||||
|
||||
const content = toDropdownMenuItems(menuItems);
|
||||
const content = toMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<DropdownMenuRoot open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuRoot>
|
||||
<MenuProvider variant={"dropdown"}>
|
||||
<Menu open={open} onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
</MenuTrigger>
|
||||
<MenuContent
|
||||
align={align}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import {
|
||||
DropdownMenuButton,
|
||||
DropdownMenuExternalLink,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuInternalLink,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
} from "~/components/primitives/DropdownMenu";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuIconWrapper,
|
||||
MenuInternalLink,
|
||||
MenuExternalLink,
|
||||
MenuLabel,
|
||||
MenuSeparator,
|
||||
MenuDisclosure,
|
||||
SelectedIconWrapper,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
SubMenu,
|
||||
SubMenuTrigger,
|
||||
SubMenuContent,
|
||||
MenuGroup,
|
||||
} from "~/components/primitives/Menu";
|
||||
import * as Components from "~/components/primitives/components/Menu";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
export function toMenuItems(items: MenuItem[]) {
|
||||
const filteredItems = filterMenuItems(items);
|
||||
|
||||
if (!filteredItems.length) {
|
||||
@@ -39,15 +29,15 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
|
||||
return filteredItems.map((item, index) => {
|
||||
const icon = showIcon ? (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
<Components.MenuIconWrapper aria-hidden>
|
||||
{"icon" in item ? item.icon : null}
|
||||
</MenuIconWrapper>
|
||||
</Components.MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
switch (item.type) {
|
||||
case "button":
|
||||
return (
|
||||
<DropdownMenuButton
|
||||
<MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -61,7 +51,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
|
||||
case "route":
|
||||
return (
|
||||
<DropdownMenuInternalLink
|
||||
<MenuInternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -72,7 +62,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
|
||||
case "link":
|
||||
return (
|
||||
<DropdownMenuExternalLink
|
||||
<MenuExternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -85,33 +75,33 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
);
|
||||
|
||||
case "submenu": {
|
||||
const submenuItems = toDropdownMenuItems(item.items);
|
||||
const submenuItems = toMenuItems(item.items);
|
||||
|
||||
if (!submenuItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownSubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<DropdownSubMenuTrigger
|
||||
<SubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<SubMenuTrigger
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<DropdownSubMenuContent>{submenuItems}</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
<SubMenuContent>{submenuItems}</SubMenuContent>
|
||||
</SubMenu>
|
||||
);
|
||||
}
|
||||
|
||||
case "group": {
|
||||
const groupItems = toDropdownMenuItems(item.items);
|
||||
const groupItems = toMenuItems(item.items);
|
||||
|
||||
if (!groupItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuGroup
|
||||
<MenuGroup
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
items={groupItems}
|
||||
@@ -120,7 +110,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
}
|
||||
|
||||
case "separator":
|
||||
return <DropdownMenuSeparator key={`${item.type}-${index}`} />;
|
||||
return <MenuSeparator key={`${item.type}-${index}`} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
@@ -149,15 +139,15 @@ export function toMobileMenuItems(
|
||||
|
||||
return filteredItems.map((item, index) => {
|
||||
const icon = showIcon ? (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
<Components.MenuIconWrapper aria-hidden>
|
||||
{"icon" in item ? item.icon : null}
|
||||
</MenuIconWrapper>
|
||||
</Components.MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
switch (item.type) {
|
||||
case "button":
|
||||
return (
|
||||
<MenuButton
|
||||
<Components.MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
$dangerous={item.dangerous}
|
||||
@@ -167,31 +157,31 @@ export function toMobileMenuItems(
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
{item.selected !== undefined && (
|
||||
<SelectedIconWrapper aria-hidden>
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{item.selected ? <CheckmarkIcon /> : null}
|
||||
</SelectedIconWrapper>
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
</MenuButton>
|
||||
</Components.MenuButton>
|
||||
);
|
||||
|
||||
case "route":
|
||||
return (
|
||||
<MenuInternalLink
|
||||
<Components.MenuInternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
to={item.to}
|
||||
disabled={item.disabled}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
</MenuInternalLink>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
</Components.MenuInternalLink>
|
||||
);
|
||||
|
||||
case "link":
|
||||
return (
|
||||
<MenuExternalLink
|
||||
<Components.MenuExternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
target={
|
||||
@@ -201,8 +191,8 @@ export function toMobileMenuItems(
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
</MenuExternalLink>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
</Components.MenuExternalLink>
|
||||
);
|
||||
|
||||
case "submenu": {
|
||||
@@ -217,7 +207,7 @@ export function toMobileMenuItems(
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
<Components.MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
@@ -225,9 +215,9 @@ export function toMobileMenuItems(
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
<MenuDisclosure />
|
||||
</MenuButton>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
<Components.MenuDisclosure />
|
||||
</Components.MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,14 +234,14 @@ export function toMobileMenuItems(
|
||||
|
||||
return (
|
||||
<div key={`${item.type}-${item.title}-${index}`}>
|
||||
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
|
||||
<Components.MenuHeader>{item.title}</Components.MenuHeader>
|
||||
{groupItems}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "separator":
|
||||
return <MenuSeparator key={`${item.type}-${index}`} />;
|
||||
return <Components.MenuSeparator key={`${item.type}-${index}`} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("PaginatedList", () => {
|
||||
i18n,
|
||||
tReady: true,
|
||||
t: ((key: string) => key) as TFunction,
|
||||
} as unknown;
|
||||
} as any;
|
||||
|
||||
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, unknown> | undefined
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<unknown[] | undefined> | undefined;
|
||||
|
||||
/** Additional options to pass to the fetch function */
|
||||
options?: Record<string, unknown>;
|
||||
options?: Record<string, any>;
|
||||
|
||||
/** Optional header content to display above the list */
|
||||
heading?: React.ReactNode;
|
||||
@@ -77,9 +77,7 @@ 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<unknown> | string
|
||||
) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
|
||||
/**
|
||||
* Handler for escape key press
|
||||
@@ -208,7 +206,7 @@ const PaginatedList = <T extends PaginatedItem>({
|
||||
if (fetch) {
|
||||
void fetchResults();
|
||||
}
|
||||
}, [fetch, fetchResults]);
|
||||
}, [fetch]);
|
||||
|
||||
// Handle updates to fetch or options
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -9,7 +9,7 @@ function Toasts() {
|
||||
|
||||
return (
|
||||
<StyledToaster
|
||||
theme={ui.resolvedTheme as unknown}
|
||||
theme={ui.resolvedTheme as any}
|
||||
closeButton
|
||||
toastOptions={{
|
||||
duration: 5000,
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuDisclosure,
|
||||
MenuExternalLink,
|
||||
MenuHeader,
|
||||
MenuInternalLink,
|
||||
MenuLabel,
|
||||
MenuSeparator,
|
||||
MenuSubTrigger,
|
||||
SelectedIconWrapper,
|
||||
} from "./components/Menu";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownSubMenu = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger ref={ref} {...rest} asChild>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
{...rest}
|
||||
sideOffset={4}
|
||||
collisionPadding={6}
|
||||
asChild
|
||||
>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
type DropdownSubMenuTriggerProps = BaseDropdownItemProps &
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>;
|
||||
|
||||
const DropdownSubMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
DropdownSubMenuTriggerProps
|
||||
>((props, ref) => {
|
||||
const { label, icon, disabled, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger ref={ref} {...rest} asChild>
|
||||
<MenuSubTrigger disabled={disabled}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
<MenuDisclosure />
|
||||
</MenuSubTrigger>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
DropdownSubMenuTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownSubMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
{...rest}
|
||||
collisionPadding={6}
|
||||
asChild
|
||||
>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DropdownMenuPrimitive.SubContent>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DropdownSubMenuContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
type DropdownMenuGroupProps = {
|
||||
label: string;
|
||||
items: React.ReactNode[];
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>,
|
||||
"children" | "asChild"
|
||||
>;
|
||||
|
||||
const DropdownMenuGroup = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Group>,
|
||||
DropdownMenuGroupProps
|
||||
>((props, ref) => {
|
||||
const { label, items, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group ref={ref} {...rest}>
|
||||
<DropdownMenuLabel>{label}</DropdownMenuLabel>
|
||||
{items}
|
||||
</DropdownMenuPrimitive.Group>
|
||||
);
|
||||
});
|
||||
DropdownMenuGroup.displayName = DropdownMenuPrimitive.Group.displayName;
|
||||
|
||||
type BaseDropdownItemProps = {
|
||||
label: string;
|
||||
icon?: React.ReactElement;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type DropdownMenuButtonProps = BaseDropdownItemProps & {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
tooltip?: React.ReactChild;
|
||||
selected?: boolean;
|
||||
dangerous?: boolean;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const DropdownMenuButton = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
DropdownMenuButtonProps
|
||||
>((props, ref) => {
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
tooltip,
|
||||
disabled,
|
||||
selected,
|
||||
dangerous,
|
||||
onClick,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const button = (
|
||||
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<MenuButton disabled={disabled} $dangerous={dangerous} onClick={onClick}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : null}
|
||||
</SelectedIconWrapper>
|
||||
)}
|
||||
</MenuButton>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip content={tooltip} placement="bottom">
|
||||
<div>{button}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{button}</>
|
||||
);
|
||||
});
|
||||
DropdownMenuButton.displayName = "DropdownMenuButton";
|
||||
|
||||
type DropdownMenuInternalLinkProps = BaseDropdownItemProps & {
|
||||
to: LocationDescriptor;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const DropdownMenuInternalLink = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
DropdownMenuInternalLinkProps
|
||||
>((props, ref) => {
|
||||
const { label, icon, disabled, to, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<MenuInternalLink to={to} disabled={disabled}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
</MenuInternalLink>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
});
|
||||
DropdownMenuInternalLink.displayName = "DropdownMenuInternalLink";
|
||||
|
||||
type DropdownMenuExternalLinkProps = BaseDropdownItemProps & {
|
||||
href: string;
|
||||
target?: string;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const DropdownMenuExternalLink = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
DropdownMenuExternalLinkProps
|
||||
>((props, ref) => {
|
||||
const { label, icon, disabled, href, target, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<MenuExternalLink href={href} target={target} disabled={disabled}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
</MenuExternalLink>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
});
|
||||
DropdownMenuExternalLink.displayName = "DropdownMenuExternalLink";
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>((props, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} {...props} asChild>
|
||||
<MenuSeparator />
|
||||
</DropdownMenuPrimitive.Separator>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label ref={ref} {...props} asChild>
|
||||
<MenuHeader>{children}</MenuHeader>
|
||||
</DropdownMenuPrimitive.Label>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
/** Styled components */
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
z-index: ${depths.menu};
|
||||
min-width: 180px;
|
||||
max-width: 276px;
|
||||
min-height: 44px;
|
||||
max-height: min(85vh, var(--radix-dropdown-menu-content-available-height));
|
||||
font-weight: normal;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
outline: none;
|
||||
|
||||
transform-origin: var(--radix-dropdown-menu-content-transform-origin);
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeAndScaleIn} 150ms ease-out;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuButton,
|
||||
DropdownMenuInternalLink,
|
||||
DropdownMenuExternalLink,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuTrigger,
|
||||
DropdownSubMenuContent,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CSRF } from "@shared/constants";
|
||||
import { useCsrfToken } from "~/hooks/useCsrfToken";
|
||||
|
||||
/**
|
||||
* Form component that automatically includes a CSRF token as a hidden input field.
|
||||
*/
|
||||
export const Form = ({
|
||||
children,
|
||||
...props
|
||||
}: React.FormHTMLAttributes<HTMLFormElement>) => {
|
||||
const token = useCsrfToken();
|
||||
|
||||
return (
|
||||
<form {...props}>
|
||||
{token && <input type="hidden" name={CSRF.fieldName} value={token} />}
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type MenuVariant = "dropdown" | "context";
|
||||
|
||||
const MenuContext = createContext<{
|
||||
variant: MenuVariant;
|
||||
}>({
|
||||
variant: "dropdown",
|
||||
});
|
||||
|
||||
export function MenuProvider({
|
||||
variant,
|
||||
children,
|
||||
}: {
|
||||
variant: MenuVariant;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ctx = useMemo(() => ({ variant }), [variant]);
|
||||
|
||||
return <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>;
|
||||
}
|
||||
|
||||
export const useMenuContext = () => useContext(MenuContext);
|
||||
@@ -0,0 +1,435 @@
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import * as Components from "../components/Menu";
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import { useMenuContext } from "./MenuContext";
|
||||
|
||||
type MenuProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Root
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Root>;
|
||||
|
||||
const Menu = ({ children, ...rest }: MenuProps) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
const Root =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Root
|
||||
: ContextMenuPrimitive.Root;
|
||||
|
||||
return <Root {...rest}>{children}</Root>;
|
||||
};
|
||||
|
||||
type SubMenuProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Sub
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Sub>;
|
||||
|
||||
const SubMenu = ({ children, ...rest }: SubMenuProps) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
const Sub =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Sub
|
||||
: ContextMenuPrimitive.Sub;
|
||||
|
||||
return <Sub {...rest}>{children}</Sub>;
|
||||
};
|
||||
|
||||
type TriggerProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Trigger
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Trigger>;
|
||||
|
||||
const MenuTrigger = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Trigger>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Trigger>,
|
||||
TriggerProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Trigger =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Trigger
|
||||
: ContextMenuPrimitive.Trigger;
|
||||
|
||||
return (
|
||||
<Trigger ref={ref} {...rest} asChild>
|
||||
{children}
|
||||
</Trigger>
|
||||
);
|
||||
});
|
||||
MenuTrigger.displayName = "MenuTrigger";
|
||||
|
||||
type ContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Content
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>;
|
||||
|
||||
const MenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Content>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
ContentProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Portal
|
||||
: ContextMenuPrimitive.Portal;
|
||||
|
||||
const Content =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Content
|
||||
: ContextMenuPrimitive.Content;
|
||||
|
||||
const offsetProp =
|
||||
variant === "dropdown" ? { sideOffset: 4 } : { alignOffset: 4 };
|
||||
|
||||
const contentProps = {
|
||||
maxHeightVar:
|
||||
variant === "dropdown"
|
||||
? "--radix-dropdown-menu-content-available-height"
|
||||
: "--radix-context-menu-content-available-height",
|
||||
transformOriginVar:
|
||||
variant === "dropdown"
|
||||
? "--radix-dropdown-menu-content-transform-origin"
|
||||
: "--radix-context-menu-content-transform-origin",
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Content ref={ref} {...rest} {...offsetProp} collisionPadding={6} asChild>
|
||||
<Components.MenuContent {...contentProps} hiddenScrollbars>
|
||||
{children}
|
||||
</Components.MenuContent>
|
||||
</Content>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
MenuContent.displayName = "MenuContent";
|
||||
|
||||
type SubMenuTriggerProps = BaseItemProps &
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger>;
|
||||
|
||||
const SubMenuTrigger = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
SubMenuTriggerProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, icon, disabled, ...rest } = props;
|
||||
|
||||
const Trigger =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.SubTrigger
|
||||
: ContextMenuPrimitive.SubTrigger;
|
||||
|
||||
return (
|
||||
<Trigger ref={ref} {...rest} asChild>
|
||||
<Components.MenuSubTrigger disabled={disabled}>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
<Components.MenuDisclosure />
|
||||
</Components.MenuSubTrigger>
|
||||
</Trigger>
|
||||
);
|
||||
});
|
||||
SubMenuTrigger.displayName = "SubMenuTrigger";
|
||||
|
||||
type SubMenuContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.SubContent
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>;
|
||||
|
||||
const SubMenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
SubMenuContentProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Portal
|
||||
: ContextMenuPrimitive.Portal;
|
||||
|
||||
const Content =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.SubContent
|
||||
: ContextMenuPrimitive.SubContent;
|
||||
|
||||
const contentProps = {
|
||||
maxHeightVar:
|
||||
variant === "dropdown"
|
||||
? "--radix-dropdown-menu-content-available-height"
|
||||
: "--radix-context-menu-content-available-height",
|
||||
transformOriginVar:
|
||||
variant === "dropdown"
|
||||
? "--radix-dropdown-menu-content-transform-origin"
|
||||
: "--radix-context-menu-content-transform-origin",
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Content ref={ref} {...rest} collisionPadding={6} asChild>
|
||||
<Components.MenuContent {...contentProps} hiddenScrollbars>
|
||||
{children}
|
||||
</Components.MenuContent>
|
||||
</Content>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
SubMenuContent.displayName = "SubMenuContent";
|
||||
|
||||
type MenuGroupProps = {
|
||||
label: string;
|
||||
items: React.ReactNode[];
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>,
|
||||
"children" | "asChild"
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Group>,
|
||||
"children" | "asChild"
|
||||
>;
|
||||
|
||||
const MenuGroup = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Group>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Group>,
|
||||
MenuGroupProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, items, ...rest } = props;
|
||||
|
||||
const Group =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Group
|
||||
: ContextMenuPrimitive.Group;
|
||||
|
||||
return (
|
||||
<Group ref={ref} {...rest}>
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
{items}
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
MenuGroup.displayName = "MenuGroup";
|
||||
|
||||
type BaseItemProps = {
|
||||
label: string;
|
||||
icon?: React.ReactElement;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type MenuButtonProps = BaseItemProps & {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
tooltip?: React.ReactChild;
|
||||
selected?: boolean;
|
||||
dangerous?: boolean;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const MenuButton = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuButtonProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
tooltip,
|
||||
disabled,
|
||||
selected,
|
||||
dangerous,
|
||||
onClick,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const Item =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Item
|
||||
: ContextMenuPrimitive.Item;
|
||||
|
||||
const button = (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuButton
|
||||
disabled={disabled}
|
||||
$dangerous={dangerous}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : null}
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
</Components.MenuButton>
|
||||
</Item>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip content={tooltip} placement="bottom">
|
||||
<div>{button}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{button}</>
|
||||
);
|
||||
});
|
||||
MenuButton.displayName = "MenuButton";
|
||||
|
||||
type MenuInternalLinkProps = BaseItemProps & {
|
||||
to: LocationDescriptor;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const MenuInternalLink = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuInternalLinkProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, icon, disabled, to, ...rest } = props;
|
||||
|
||||
const Item =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Item
|
||||
: ContextMenuPrimitive.Item;
|
||||
|
||||
return (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuInternalLink to={to} disabled={disabled}>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
</Components.MenuInternalLink>
|
||||
</Item>
|
||||
);
|
||||
});
|
||||
MenuInternalLink.displayName = "MenuInternalLink";
|
||||
|
||||
type MenuExternalLinkProps = BaseItemProps & {
|
||||
href: string;
|
||||
target?: string;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const MenuExternalLink = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuExternalLinkProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, icon, disabled, href, target, ...rest } = props;
|
||||
|
||||
const Item =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Item
|
||||
: ContextMenuPrimitive.Item;
|
||||
|
||||
return (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuExternalLink
|
||||
href={href}
|
||||
target={target}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
</Components.MenuExternalLink>
|
||||
</Item>
|
||||
);
|
||||
});
|
||||
MenuExternalLink.displayName = "MenuExternalLink";
|
||||
|
||||
type MenuSeparatorProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Separator
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>;
|
||||
|
||||
const MenuSeparator = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Separator>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
MenuSeparatorProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
const Separator =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Separator
|
||||
: ContextMenuPrimitive.Separator;
|
||||
|
||||
return (
|
||||
<Separator ref={ref} {...props} asChild>
|
||||
<Components.MenuSeparator />
|
||||
</Separator>
|
||||
);
|
||||
});
|
||||
MenuSeparator.displayName = "MenuSeparator";
|
||||
|
||||
type MenuLabelProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Label
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label>;
|
||||
|
||||
const MenuLabel = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Label>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
MenuLabelProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Label =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Label
|
||||
: ContextMenuPrimitive.Label;
|
||||
|
||||
return (
|
||||
<Label ref={ref} {...rest} asChild>
|
||||
<Components.MenuHeader>{children}</Components.MenuHeader>
|
||||
</Label>
|
||||
);
|
||||
});
|
||||
MenuLabel.displayName = "MenuLabel";
|
||||
|
||||
export {
|
||||
Menu,
|
||||
MenuTrigger,
|
||||
MenuContent,
|
||||
MenuButton,
|
||||
MenuInternalLink,
|
||||
MenuExternalLink,
|
||||
MenuSeparator,
|
||||
MenuGroup,
|
||||
MenuLabel,
|
||||
SubMenu,
|
||||
SubMenuTrigger,
|
||||
SubMenuContent,
|
||||
};
|
||||
@@ -3,7 +3,9 @@ import { ellipsis } from "polished";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type BaseMenuItemProps = {
|
||||
disabled?: boolean;
|
||||
@@ -135,3 +137,31 @@ export const SelectedIconWrapper = styled.span`
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const MenuContent = styled(Scrollable)<{
|
||||
maxHeightVar: string;
|
||||
transformOriginVar: string;
|
||||
}>`
|
||||
z-index: ${depths.menu};
|
||||
min-width: 180px;
|
||||
max-width: 276px;
|
||||
min-height: 44px;
|
||||
max-height: ${({ maxHeightVar }) => `min(85vh, var(${maxHeightVar}))`};
|
||||
font-weight: normal;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
outline: none;
|
||||
|
||||
transform-origin: ${({ transformOriginVar }) => `var(${transformOriginVar})`};
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeAndScaleIn} 150ms ease-out;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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, documents.add])
|
||||
}, [query])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,22 +79,6 @@ 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) {
|
||||
@@ -123,7 +107,20 @@ const LinkEditor: React.FC<Props> = ({
|
||||
|
||||
save(trimmedQuery, trimmedQuery);
|
||||
};
|
||||
}, [trimmedQuery, initialValue, handleRemoveLink, save]);
|
||||
}, [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 });
|
||||
};
|
||||
|
||||
const moveSelectionToEnd = () => {
|
||||
const { state, dispatch } = view;
|
||||
@@ -198,7 +195,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLink = React.useCallback(() => {
|
||||
const handleRemoveLink = () => {
|
||||
discardRef.current = true;
|
||||
|
||||
const { state, dispatch } = view;
|
||||
@@ -206,12 +203,9 @@ const LinkEditor: React.FC<Props> = ({
|
||||
dispatch(state.tr.removeMark(from, to, mark));
|
||||
}
|
||||
|
||||
if (onRemoveLink) {
|
||||
onRemoveLink();
|
||||
}
|
||||
|
||||
onRemoveLink?.();
|
||||
view.focus();
|
||||
}, [view, mark, from, to, onRemoveLink]);
|
||||
};
|
||||
|
||||
const isInternal = isInternalUrl(query);
|
||||
const hasResults = !!results.length;
|
||||
|
||||
@@ -184,16 +184,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
setItems(items);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [
|
||||
t,
|
||||
actorId,
|
||||
loading,
|
||||
search,
|
||||
users,
|
||||
documents,
|
||||
maxResultsInSection,
|
||||
collections,
|
||||
]);
|
||||
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
|
||||
|
||||
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 unknown).content;
|
||||
const nodes = (fragment as any).content;
|
||||
|
||||
return some(nodes, (n) => n.content.size);
|
||||
}
|
||||
|
||||
@@ -44,9 +44,7 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
softBreak: true,
|
||||
})
|
||||
: slice.content.content
|
||||
.map((node) =>
|
||||
ProsemirrorHelper.toPlainText(node, this.editor.schema)
|
||||
)
|
||||
.map((node) => ProsemirrorHelper.toPlainText(node))
|
||||
.join("");
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,7 +35,6 @@ import Extension, {
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
import { basicExtensions as extensions } from "@shared/editor/nodes";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
@@ -627,8 +626,7 @@ export class Editor extends React.PureComponent<
|
||||
*
|
||||
* @returns A list of headings in the document
|
||||
*/
|
||||
public getHeadings = () =>
|
||||
ProsemirrorHelper.getHeadings(this.view.state.doc, this.schema);
|
||||
public getHeadings = () => ProsemirrorHelper.getHeadings(this.view.state.doc);
|
||||
|
||||
/**
|
||||
* Return the images in the current editor.
|
||||
@@ -721,9 +719,8 @@ export class Editor extends React.PureComponent<
|
||||
*/
|
||||
public getPlainText = () => {
|
||||
const { doc } = this.view.state;
|
||||
const textSerializers = getTextSerializers(this.schema);
|
||||
|
||||
return textBetween(doc, 0, doc.content.size, textSerializers);
|
||||
return textBetween(doc, 0, doc.content.size);
|
||||
};
|
||||
|
||||
private dispatchThemeChanged = (event: CustomEvent) => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CSRF } from "@shared/constants";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getCookie } from "tiny-cookie";
|
||||
|
||||
/**
|
||||
* React hook for accessing CSRF tokens in components
|
||||
*
|
||||
* @returns The CSRF token string or null if not found
|
||||
*/
|
||||
export function useCsrfToken() {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateToken = () => {
|
||||
const currentToken = getCookie(CSRF.cookieName);
|
||||
|
||||
setToken(currentToken);
|
||||
};
|
||||
|
||||
// Initial load
|
||||
updateToken();
|
||||
|
||||
// Listen for cookie changes (when navigating or refreshing)
|
||||
const interval = setInterval(updateToken, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return token;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InputIcon, SearchIcon } from "outline-icons";
|
||||
import { ActionV2Separator, createActionV2 } from "~/actions";
|
||||
import {
|
||||
restoreDocument,
|
||||
unsubscribeDocument,
|
||||
subscribeDocument,
|
||||
restoreDocumentToCollection,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
editDocument,
|
||||
shareDocument,
|
||||
createNestedDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory,
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
deleteDocument,
|
||||
leaveDocument,
|
||||
permanentlyDeleteDocument,
|
||||
} from "~/actions/definitions/documents";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import useMobile from "./useMobile";
|
||||
import Document from "~/models/Document";
|
||||
import usePolicy from "./usePolicy";
|
||||
import useCurrentUser from "./useCurrentUser";
|
||||
import { useTemplateMenuActions } from "./useTemplateMenuActions";
|
||||
import { useMenuAction } from "./useMenuAction";
|
||||
|
||||
type Props = {
|
||||
/** Document for which the actions are generated */
|
||||
document: Document;
|
||||
/** Invoked when the "Find and replace" menu item is clicked */
|
||||
onFindAndReplace?: () => void;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
/** Callback when a template is selected to apply its content to the document */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
};
|
||||
|
||||
export function useDocumentMenuAction({
|
||||
document,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
onSelectTemplate,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const can = usePolicy(document);
|
||||
|
||||
const templateMenuActions = useTemplateMenuActions({
|
||||
document,
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
restoreDocument,
|
||||
restoreDocumentToCollection,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
createActionV2({
|
||||
name: `${t("Find and replace")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: !!onFindAndReplace && isMobile,
|
||||
perform: () => onFindAndReplace?.(),
|
||||
}),
|
||||
ActionV2Separator,
|
||||
editDocument,
|
||||
createActionV2({
|
||||
name: `${t("Rename")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <InputIcon />,
|
||||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||||
perform: () => requestAnimationFrame(() => onRename?.()),
|
||||
}),
|
||||
shareDocument,
|
||||
createNestedDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory({ actions: templateMenuActions }),
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
ActionV2Separator,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
ActionV2Separator,
|
||||
deleteDocument,
|
||||
permanentlyDeleteDocument,
|
||||
leaveDocument,
|
||||
],
|
||||
[
|
||||
t,
|
||||
isMobile,
|
||||
templateMenuActions,
|
||||
can.update,
|
||||
user.separateEditMode,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
]
|
||||
);
|
||||
|
||||
return useMenuAction(actions);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
}
|
||||
|
||||
if (isDocumentUrl(navigateTo)) {
|
||||
const document = documents.getByUrl(navigateTo);
|
||||
const document = documents.get(navigateTo);
|
||||
if (document) {
|
||||
navigateTo = document.path;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "./useStores";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { useEffect } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
export default function useFocusedComment() {
|
||||
/**
|
||||
* Custom hook to retrieve the currently focused comment in a document.
|
||||
* It checks both the document context and the query string for the comment ID.
|
||||
* If a comment is focused, it returns the comment itself or the parent thread if it exists
|
||||
*/
|
||||
export function useFocusedComment() {
|
||||
const { comments } = useStores();
|
||||
const location = useLocation<{ commentId?: string }>();
|
||||
const context = useDocumentContext();
|
||||
const query = useQuery();
|
||||
const focusedCommentId = location.state?.commentId || query.get("commentId");
|
||||
const focusedCommentId = context.focusedCommentId || query.get("commentId");
|
||||
const comment = focusedCommentId ? comments.get(focusedCommentId) : undefined;
|
||||
const history = useHistory();
|
||||
|
||||
// Move the query string into context
|
||||
useEffect(() => {
|
||||
if (focusedCommentId && context.focusedCommentId !== focusedCommentId) {
|
||||
context.setFocusedCommentId(focusedCommentId);
|
||||
}
|
||||
}, [focusedCommentId, context]);
|
||||
|
||||
// Clear query string from location
|
||||
useEffect(() => {
|
||||
if (focusedCommentId) {
|
||||
const params = new URLSearchParams(history.location.search);
|
||||
|
||||
if (params.get("commentId") === focusedCommentId) {
|
||||
params.delete("commentId");
|
||||
history.replace({
|
||||
pathname: history.location.pathname,
|
||||
search: params.toString(),
|
||||
state: history.location.state,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [focusedCommentId, history]);
|
||||
|
||||
return comment?.parentCommentId
|
||||
? comments.get(comment.parentCommentId)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { isBrowser } from "@shared/utils/browser";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useEventListener from "./useEventListener";
|
||||
import usePrevious from "./usePrevious";
|
||||
|
||||
type Options = {
|
||||
/* Whether to listen and react to changes in the value from other tabs */
|
||||
@@ -41,6 +42,7 @@ export default function usePersistedState<T extends Primitive | object>(
|
||||
defaultValue: T,
|
||||
options?: Options
|
||||
): [T, (value: T) => void] {
|
||||
const previousKey = usePrevious(key);
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
if (!isBrowser) {
|
||||
return defaultValue;
|
||||
@@ -65,6 +67,13 @@ export default function usePersistedState<T extends Primitive | object>(
|
||||
[key, storedValue]
|
||||
);
|
||||
|
||||
// Sync state when key changes
|
||||
useEffect(() => {
|
||||
if (previousKey !== key) {
|
||||
setStoredValue(Storage.get(key) ?? defaultValue);
|
||||
}
|
||||
}, [previousKey, key, defaultValue]);
|
||||
|
||||
// Listen to the key changing in other tabs so we can keep UI in sync
|
||||
useEventListener("storage", (event: StorageEvent) => {
|
||||
if (options?.listen !== false && event.key === key && event.newValue) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import noop from "lodash/noop";
|
||||
import { observer } from "mobx-react";
|
||||
import { InputIcon, SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
@@ -11,49 +10,14 @@ import Document from "~/models/Document";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import Switch from "~/components/Switch";
|
||||
import { ActionV2Separator, createActionV2 } from "~/actions";
|
||||
import {
|
||||
pinDocument,
|
||||
createTemplateFromDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
moveDocument,
|
||||
deleteDocument,
|
||||
permanentlyDeleteDocument,
|
||||
downloadDocument,
|
||||
importDocument,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
duplicateDocument,
|
||||
archiveDocument,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
printDocument,
|
||||
openDocumentComments,
|
||||
createDocumentFromTemplate,
|
||||
createNestedDocument,
|
||||
shareDocument,
|
||||
copyDocument,
|
||||
searchInDocument,
|
||||
leaveDocument,
|
||||
moveTemplate,
|
||||
restoreDocument,
|
||||
restoreDocumentToCollection,
|
||||
editDocument,
|
||||
applyTemplateFactory,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import { useTemplateMenuActions } from "~/hooks/useTemplateMenuActions";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { MenuSeparator } from "~/components/primitives/components/Menu";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
|
||||
type Props = {
|
||||
/** Document for which the menu is to be shown */
|
||||
@@ -95,7 +59,13 @@ function DocumentMenu({
|
||||
const isMobile = useMobile();
|
||||
const can = usePolicy(document);
|
||||
|
||||
const { subscriptions, pins } = useStores();
|
||||
const { userMemberships, groupMemberships, subscriptions, pins } =
|
||||
useStores();
|
||||
|
||||
const isShared = !!(
|
||||
userMemberships.getByDocumentId(document.id) ||
|
||||
groupMemberships.getByDocumentId(document.id)
|
||||
);
|
||||
|
||||
const {
|
||||
loading: auxDataLoading,
|
||||
@@ -155,78 +125,18 @@ function DocumentMenu({
|
||||
[document]
|
||||
);
|
||||
|
||||
const templateMenuActions = useTemplateMenuActions({
|
||||
const rootAction = useDocumentMenuAction({
|
||||
document,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
restoreDocument,
|
||||
restoreDocumentToCollection,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
createActionV2({
|
||||
name: `${t("Find and replace")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: !!onFindAndReplace && isMobile,
|
||||
perform: () => onFindAndReplace?.(),
|
||||
}),
|
||||
ActionV2Separator,
|
||||
editDocument,
|
||||
createActionV2({
|
||||
name: `${t("Rename")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <InputIcon />,
|
||||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||||
perform: () => requestAnimationFrame(() => onRename?.()),
|
||||
}),
|
||||
shareDocument,
|
||||
createNestedDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory({ actions: templateMenuActions }),
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
ActionV2Separator,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
ActionV2Separator,
|
||||
deleteDocument,
|
||||
permanentlyDeleteDocument,
|
||||
leaveDocument,
|
||||
],
|
||||
[
|
||||
t,
|
||||
isMobile,
|
||||
templateMenuActions,
|
||||
can.update,
|
||||
user.separateEditMode,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId: document.collectionId ?? undefined,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId ? document.collectionId : undefined,
|
||||
});
|
||||
|
||||
const toggleSwitches = React.useMemo<React.ReactNode>(() => {
|
||||
@@ -280,6 +190,7 @@ function DocumentMenu({
|
||||
}, [
|
||||
t,
|
||||
can.update,
|
||||
can.updateInsights,
|
||||
document.embedsDisabled,
|
||||
document.fullWidth,
|
||||
document.insightsEnabled,
|
||||
@@ -288,6 +199,7 @@ function DocumentMenu({
|
||||
showToggleEmbeds,
|
||||
handleEmbedsToggle,
|
||||
handleFullWidthToggle,
|
||||
handleInsightsToggle,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { useMemo } from "react";
|
||||
import { createActionV2 } from "~/actions";
|
||||
import { GroupSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
function GroupMemberMenu({ onRemove }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
createActionV2({
|
||||
name: t("Remove"),
|
||||
section: GroupSection,
|
||||
dangerous: true,
|
||||
perform: onRemove,
|
||||
}),
|
||||
],
|
||||
[t, onRemove]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("Group member options")}>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GroupMemberMenu);
|
||||
@@ -2,6 +2,8 @@ import { isPast } from "date-fns";
|
||||
import { computed, observable } from "mobx";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
import User from "./User";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class ApiKey extends ParanoidModel {
|
||||
static modelName = "ApiKey";
|
||||
@@ -25,6 +27,10 @@ class ApiKey extends ParanoidModel {
|
||||
@observable
|
||||
lastActiveAt?: string;
|
||||
|
||||
/** The user who this API key belongs to. */
|
||||
@Relation(() => User)
|
||||
user: User;
|
||||
|
||||
/** The user ID that the API key belongs to. */
|
||||
userId: string;
|
||||
|
||||
|
||||
@@ -723,8 +723,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
const text = ProsemirrorHelper.toPlainText(
|
||||
Node.fromJSON(schema, this.data),
|
||||
schema
|
||||
Node.fromJSON(schema, this.data)
|
||||
);
|
||||
return text;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import Group from "./Group";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Relation from "./decorators/Relation";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
/**
|
||||
* Represents a user's membership to a group.
|
||||
@@ -22,6 +24,10 @@ class GroupUser extends Model {
|
||||
/** The group that the user belongs to. */
|
||||
@Relation(() => Group, { onDelete: "cascade" })
|
||||
group: Group;
|
||||
|
||||
/** The permission of the user in the group. */
|
||||
@Field
|
||||
permission: GroupPermission;
|
||||
}
|
||||
|
||||
export default GroupUser;
|
||||
|
||||
@@ -75,8 +75,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
const id = params.id || "";
|
||||
const urlId = id.split("-").pop() ?? "";
|
||||
|
||||
const collection: Collection | null | undefined =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const collection: Collection | null | undefined = collections.get(id);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import queryString from "query-string";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { InputSelect, Option } from "~/components/InputSelect";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import { CommentSortType } from "~/types";
|
||||
|
||||
const CommentSortMenu = () => {
|
||||
type Props = {
|
||||
/** Callback when the sort type changes */
|
||||
onChange?: (sortType: CommentSortType | "resolved") => void;
|
||||
/** Whether resolved comments are being viewed */
|
||||
viewingResolved?: boolean;
|
||||
};
|
||||
|
||||
const CommentSortMenu = ({ viewingResolved, onChange }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const history = useHistory();
|
||||
const user = useCurrentUser();
|
||||
const params = useQuery();
|
||||
|
||||
const preferredSortType = user.getPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument
|
||||
@@ -25,42 +24,23 @@ const CommentSortMenu = () => {
|
||||
? CommentSortType.OrderInDocument
|
||||
: CommentSortType.MostRecent;
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const value = viewingResolved ? "resolved" : preferredSortType;
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(val: string) => {
|
||||
if (val === "resolved") {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
return;
|
||||
(val: CommentSortType | "resolved") => {
|
||||
if (val !== "resolved") {
|
||||
if (val !== preferredSortType) {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
val === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
}
|
||||
}
|
||||
|
||||
const sortType = val as CommentSortType;
|
||||
if (sortType !== preferredSortType) {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
sortType === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
}
|
||||
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: undefined,
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
onChange?.(val);
|
||||
},
|
||||
[history, location, sidebarContext, user, preferredSortType]
|
||||
[user, onChange, preferredSortType]
|
||||
);
|
||||
|
||||
const options: Option[] = React.useMemo(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -17,7 +16,6 @@ import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -51,14 +49,11 @@ function CommentThread({
|
||||
collapseNumDisplayed = 3,
|
||||
}: Props) {
|
||||
const [scrollOnMount] = React.useState(focused && !window.location.hash);
|
||||
const { editor } = useDocumentContext();
|
||||
const { editor, setFocusedCommentId } = useDocumentContext();
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
const replyRef = React.useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
|
||||
const user = useCurrentUser();
|
||||
|
||||
@@ -102,14 +97,7 @@ function CommentThread({
|
||||
!(event.target as HTMLElement).classList.contains("comment") &&
|
||||
event.defaultPrevented === false
|
||||
) {
|
||||
history.replace({
|
||||
search: location.search,
|
||||
pathname: location.pathname,
|
||||
state: {
|
||||
commentId: undefined,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
setFocusedCommentId(null);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -118,15 +106,7 @@ function CommentThread({
|
||||
}, [editor, thread.id]);
|
||||
|
||||
const handleClickThread = () => {
|
||||
history.replace({
|
||||
// Clear any commentId from the URL when explicitly focusing a thread
|
||||
search: thread.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname.replace(/\/history$/, ""),
|
||||
state: {
|
||||
commentId: thread.id,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
setFocusedCommentId(thread.id);
|
||||
};
|
||||
|
||||
const handleClickExpand = (ev: React.SyntheticEvent) => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
import CommentEditor from "./CommentEditor";
|
||||
import { HighlightedText } from "./HighlightText";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
|
||||
/**
|
||||
* Hook to calculate if we should display a timestamp on a comment
|
||||
@@ -111,6 +112,7 @@ function CommentThreadItem({
|
||||
onEditStart,
|
||||
onEditEnd,
|
||||
}: Props) {
|
||||
const { setFocusedCommentId } = useDocumentContext();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const [data, setData] = React.useState(comment.data);
|
||||
@@ -154,6 +156,9 @@ function CommentThreadItem({
|
||||
const handleUpdate = React.useCallback(
|
||||
(attrs: { resolved: boolean }) => {
|
||||
onUpdate?.(comment.id, attrs);
|
||||
if ("resolved" in attrs) {
|
||||
setFocusedCommentId(null);
|
||||
}
|
||||
},
|
||||
[comment.id, onUpdate]
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import { useFocusedComment } from "~/hooks/useFocusedComment";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -31,11 +31,13 @@ function Comments() {
|
||||
const { editor, isEditorInitialized } = useDocumentContext();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const params = useQuery();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
const focusedComment = useFocusedComment();
|
||||
const can = usePolicy(document);
|
||||
|
||||
const query = useQuery();
|
||||
const [viewingResolved, setViewingResolved] = useState(
|
||||
query.get("resolved") !== null || focusedComment?.isResolved || false
|
||||
);
|
||||
const scrollableRef = useRef<HTMLDivElement | null>(null);
|
||||
const prevThreadCount = useRef(0);
|
||||
const isAtBottom = useRef(true);
|
||||
@@ -43,6 +45,13 @@ function Comments() {
|
||||
|
||||
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
|
||||
|
||||
// Account for the resolved status of the comment changing
|
||||
useEffect(() => {
|
||||
if (focusedComment && focusedComment.isResolved !== viewingResolved) {
|
||||
setViewingResolved(focusedComment.isResolved);
|
||||
}
|
||||
}, [focusedComment, viewingResolved]);
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
`draft-${document?.id}-new`,
|
||||
undefined
|
||||
@@ -57,7 +66,6 @@ function Comments() {
|
||||
}
|
||||
: { type: CommentSortType.MostRecent };
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const threads = !document
|
||||
? []
|
||||
: viewingResolved
|
||||
@@ -124,7 +132,12 @@ function Comments() {
|
||||
title={
|
||||
<Flex align="center" justify="space-between" auto>
|
||||
<span>{t("Comments")}</span>
|
||||
<CommentSortMenu />
|
||||
<CommentSortMenu
|
||||
viewingResolved={viewingResolved}
|
||||
onChange={(val) => {
|
||||
setViewingResolved(val === "resolved");
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
onClose={() => ui.set({ commentsExpanded: false })}
|
||||
|
||||
@@ -67,9 +67,7 @@ function DataLoader({ match, children }: Props) {
|
||||
const { revisionId, documentSlug } = match.params;
|
||||
|
||||
// Allows loading by /doc/slug-<urlId> or /doc/<id>
|
||||
const document =
|
||||
documents.getByUrl(match.params.documentSlug) ??
|
||||
documents.get(match.params.documentSlug);
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
|
||||
if (document) {
|
||||
setDocument(document);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import { useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Text from "@shared/components/Text";
|
||||
import { richExtensions, withComments } from "@shared/editor/nodes";
|
||||
@@ -19,7 +19,7 @@ import Time from "~/components/Time";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import { useFocusedComment } from "~/hooks/useFocusedComment";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
@@ -55,15 +55,15 @@ 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<unknown>) {
|
||||
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const titleRef = React.useRef<RefHandle>(null);
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const { setFocusedCommentId } = useDocumentContext();
|
||||
const focusedComment = useFocusedComment();
|
||||
const { ui, comments } = useStores();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const params = useQuery();
|
||||
const {
|
||||
@@ -95,18 +95,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
|
||||
(focusedComment.isResolved && !viewingResolved) ||
|
||||
(!focusedComment.isResolved && viewingResolved)
|
||||
) {
|
||||
history.replace({
|
||||
search: focusedComment.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname,
|
||||
state: {
|
||||
commentId: focusedComment.id,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
setFocusedCommentId(focusedComment.id);
|
||||
}
|
||||
ui.set({ commentsExpanded: true });
|
||||
}
|
||||
}, [focusedComment, ui, document.id, history, params, sidebarContext]);
|
||||
}, [focusedComment, ui, document.id, params]);
|
||||
|
||||
// Save document when blurring title, but delay so that if clicking on a
|
||||
// button this is allowed to execute first.
|
||||
@@ -127,16 +120,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
|
||||
[focusAtStart, ref]
|
||||
);
|
||||
|
||||
const handleClickComment = React.useCallback(
|
||||
(commentId: string) => {
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId, sidebarContext },
|
||||
});
|
||||
},
|
||||
[history, sidebarContext]
|
||||
);
|
||||
|
||||
// Create a Comment model in local store when a comment mark is created, this
|
||||
// acts as a local draft before submission.
|
||||
const handleDraftComment = React.useCallback(
|
||||
@@ -156,13 +139,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
|
||||
);
|
||||
comment.id = commentId;
|
||||
comments.add(comment);
|
||||
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId, sidebarContext },
|
||||
});
|
||||
setFocusedCommentId(commentId);
|
||||
},
|
||||
[comments, user?.id, props.id, history, sidebarContext]
|
||||
[comments, user?.id, props.id]
|
||||
);
|
||||
|
||||
// Soft delete the Comment model when associated mark is totally removed.
|
||||
@@ -258,7 +237,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
|
||||
userId={user?.id}
|
||||
focusedCommentId={focusedComment?.id}
|
||||
onClickCommentMark={
|
||||
commentingEnabled && can.comment ? handleClickComment : undefined
|
||||
commentingEnabled && can.comment ? setFocusedCommentId : undefined
|
||||
}
|
||||
onCreateCommentMark={
|
||||
commentingEnabled && can.comment ? handleDraftComment : undefined
|
||||
|
||||
@@ -166,7 +166,7 @@ function DocumentHeader({
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(event) => event.ctrlKey && event.altKey && event.key === "˙",
|
||||
(event) => event.ctrlKey && event.altKey && event.code === "KeyH",
|
||||
handleToggle,
|
||||
{
|
||||
allowInInput: true,
|
||||
|
||||
@@ -34,7 +34,7 @@ function History() {
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
|
||||
const [eventsOffset, setEventsOffset] = React.useState(0);
|
||||
|
||||
|
||||
@@ -51,10 +51,7 @@ type MessageEvent = {
|
||||
};
|
||||
};
|
||||
|
||||
function MultiplayerEditor(
|
||||
{ onSynced, ...props }: Props,
|
||||
ref: React.Ref<unknown>
|
||||
) {
|
||||
function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
const documentId = props.id;
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
@@ -26,6 +26,7 @@ import { Background } from "./components/Background";
|
||||
import { Centered } from "./components/Centered";
|
||||
import { ConnectHeader } from "./components/ConnectHeader";
|
||||
import { TeamSwitcher } from "./components/TeamSwitcher";
|
||||
import { Form } from "~/components/primitives/Form";
|
||||
|
||||
export default function OAuthAuthorize() {
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
@@ -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>
|
||||
@@ -203,7 +204,7 @@ function Authorize() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form
|
||||
<Form
|
||||
method="POST"
|
||||
action="/oauth/authorize"
|
||||
style={{ width: "100%" }}
|
||||
@@ -236,7 +237,7 @@ function Authorize() {
|
||||
{t("Authorize")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Form>
|
||||
</Centered>
|
||||
</Background>
|
||||
);
|
||||
|
||||
@@ -12,15 +12,17 @@ import { detectLanguage } from "~/utils/language";
|
||||
import { BackButton } from "./BackButton";
|
||||
import { Background } from "./Background";
|
||||
import { Centered } from "./Centered";
|
||||
import { Form } from "~/components/primitives/Form";
|
||||
|
||||
const WorkspaceSetup = ({ onBack }: { onBack?: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<BackButton onBack={onBack} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<Centered
|
||||
as="form"
|
||||
as={Form}
|
||||
action="/api/installation.create"
|
||||
method="POST"
|
||||
gap={12}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon } from "outline-icons";
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
@@ -32,7 +32,7 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix />{" "}
|
||||
{apiKey.userId === user.id
|
||||
? ""
|
||||
: t(`by {{ name }}`, { name: user.name })}{" "}
|
||||
: t(`by {{ name }}`, { name: apiKey.user.name })}{" "}
|
||||
·{" "}
|
||||
</Text>
|
||||
{apiKey.lastActiveAt && (
|
||||
@@ -50,10 +50,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>
|
||||
|
||||
@@ -25,7 +25,10 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GroupMemberMenu from "~/menus/GroupMemberMenu";
|
||||
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
import GroupUser from "~/models/GroupUser";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
@@ -248,6 +251,7 @@ export const ViewGroupMembersDialog = observer(function ({
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
<br />
|
||||
</>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
@@ -262,7 +266,6 @@ export const ViewGroupMembersDialog = observer(function ({
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
<br />
|
||||
<PaginatedList<User>
|
||||
items={users.inGroup(group.id)}
|
||||
fetch={groupUsers.fetchPage}
|
||||
@@ -274,6 +277,10 @@ export const ViewGroupMembersDialog = observer(function ({
|
||||
<GroupMemberListItem
|
||||
key={user.id}
|
||||
user={user}
|
||||
group={group}
|
||||
groupUser={groupUsers.orderedData.find(
|
||||
(gu) => gu.userId === user.id && gu.groupId === group.id
|
||||
)}
|
||||
onRemove={can.update ? () => handleRemoveUser(user) : undefined}
|
||||
/>
|
||||
)}
|
||||
@@ -390,6 +397,8 @@ const AddPeopleToGroupDialog = observer(function ({
|
||||
<GroupMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
group={group}
|
||||
groupUser={undefined}
|
||||
onAdd={() => handleAddUser(item)}
|
||||
/>
|
||||
)}
|
||||
@@ -401,16 +410,41 @@ const AddPeopleToGroupDialog = observer(function ({
|
||||
|
||||
type GroupMemberListItemProps = {
|
||||
user: User;
|
||||
group: Group;
|
||||
groupUser: GroupUser | undefined;
|
||||
onAdd?: () => Promise<void>;
|
||||
onRemove?: () => Promise<void>;
|
||||
};
|
||||
|
||||
const GroupMemberListItem = observer(function ({
|
||||
user,
|
||||
onRemove,
|
||||
group,
|
||||
groupUser,
|
||||
onAdd,
|
||||
}: GroupMemberListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { groupUsers } = useStores();
|
||||
const can = usePolicy(group);
|
||||
|
||||
const permissions = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: t("Manage"),
|
||||
value: GroupPermission.Admin,
|
||||
},
|
||||
{
|
||||
label: t("Member"),
|
||||
value: GroupPermission.Member,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
label: t("Remove"),
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
] as Permission[],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
@@ -431,11 +465,40 @@ const GroupMemberListItem = observer(function ({
|
||||
image={<Avatar model={user} size={AvatarSize.Large} />}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
|
||||
{onAdd && (
|
||||
{onAdd ? (
|
||||
<Button onClick={onAdd} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
) : (
|
||||
<div style={{ marginRight: -8 }}>
|
||||
<InputMemberPermissionSelect
|
||||
permissions={permissions}
|
||||
onChange={async (
|
||||
permission: GroupPermission | typeof EmptySelectValue
|
||||
) => {
|
||||
try {
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupUsers.delete({
|
||||
userId: user.id,
|
||||
groupId: group.id,
|
||||
});
|
||||
} else {
|
||||
await groupUsers.update({
|
||||
userId: user.id,
|
||||
groupId: group.id,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={groupUser?.permission}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import sortBy from "lodash/sortBy";
|
||||
@@ -186,7 +185,7 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
statusFilter: [CollectionStatusFilter.Archived],
|
||||
});
|
||||
|
||||
get(id: string): Collection | undefined {
|
||||
get(id: string = ""): Collection | undefined {
|
||||
return (
|
||||
this.data.get(id) ??
|
||||
this.orderedData.find((collection) => id.endsWith(collection.urlId))
|
||||
@@ -242,10 +241,6 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
return this.orderedData.map((collection) => collection.asNavigationNode);
|
||||
}
|
||||
|
||||
getByUrl(url: string): Collection | null | undefined {
|
||||
return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId));
|
||||
}
|
||||
|
||||
async delete(collection: Collection) {
|
||||
await super.delete(collection);
|
||||
await this.rootStore.documents.fetchRecentlyUpdated();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import invariant from "invariant";
|
||||
import compact from "lodash/compact";
|
||||
import filter from "lodash/filter";
|
||||
import find from "lodash/find";
|
||||
import omitBy from "lodash/omitBy";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { observable, action, computed, runInAction } from "mobx";
|
||||
@@ -460,7 +459,7 @@ export default class DocumentsStore extends Store<Document> {
|
||||
|
||||
@action
|
||||
prefetchDocument = async (id: string) => {
|
||||
if (!this.data.get(id) && !this.getByUrl(id)) {
|
||||
if (!this.get(id)) {
|
||||
return this.fetch(id, {
|
||||
prefetch: true,
|
||||
});
|
||||
@@ -746,12 +745,6 @@ export default class DocumentsStore extends Store<Document> {
|
||||
return subscription?.delete();
|
||||
};
|
||||
|
||||
getByUrl = (url = ""): Document | undefined =>
|
||||
find(
|
||||
this.orderedData,
|
||||
(doc) => url.endsWith(doc.urlId) || url.endsWith(doc.id)
|
||||
);
|
||||
|
||||
getCollectionForDocument(document: Document) {
|
||||
return document.collectionId
|
||||
? this.rootStore.collections.get(document.collectionId)
|
||||
|
||||
@@ -3,6 +3,7 @@ import filter from "lodash/filter";
|
||||
import { action, runInAction } from "mobx";
|
||||
import GroupUser from "~/models/GroupUser";
|
||||
import { PaginationParams } from "~/types";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import RootStore from "./RootStore";
|
||||
import Store, {
|
||||
@@ -12,7 +13,7 @@ import Store, {
|
||||
} from "./base/Store";
|
||||
|
||||
export default class GroupUsersStore extends Store<GroupUser> {
|
||||
actions = [RPCAction.Create, RPCAction.Delete];
|
||||
actions = [RPCAction.Create, RPCAction.Update, RPCAction.Delete];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, GroupUser);
|
||||
@@ -43,10 +44,19 @@ export default class GroupUsersStore extends Store<GroupUser> {
|
||||
};
|
||||
|
||||
@action
|
||||
async create({ groupId, userId }: { groupId: string; userId: string }) {
|
||||
async create({
|
||||
groupId,
|
||||
userId,
|
||||
permission = GroupPermission.Member,
|
||||
}: {
|
||||
groupId: string;
|
||||
userId: string;
|
||||
permission?: GroupPermission;
|
||||
}) {
|
||||
const res = await client.post("/groups.add_user", {
|
||||
id: groupId,
|
||||
userId,
|
||||
permission,
|
||||
});
|
||||
invariant(res?.data, "Group Membership data should be available");
|
||||
res.data.users.forEach(this.rootStore.users.add);
|
||||
@@ -70,6 +80,29 @@ export default class GroupUsersStore extends Store<GroupUser> {
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
async update({
|
||||
groupId,
|
||||
userId,
|
||||
permission,
|
||||
}: {
|
||||
groupId: string;
|
||||
userId: string;
|
||||
permission?: GroupPermission;
|
||||
}) {
|
||||
const res = await client.post("/groups.update_user", {
|
||||
id: groupId,
|
||||
userId,
|
||||
permission,
|
||||
});
|
||||
invariant(res?.data, "Group Membership data should be available");
|
||||
res.data.users.forEach(this.rootStore.users.add);
|
||||
res.data.groups.forEach(this.rootStore.groups.add);
|
||||
|
||||
const groupMemberships = res.data.groupMemberships.map(this.add);
|
||||
return groupMemberships[0];
|
||||
}
|
||||
|
||||
@action
|
||||
removeGroupMemberships = (groupId: string) => {
|
||||
this.data.forEach((_, key) => {
|
||||
|
||||
@@ -34,13 +34,6 @@ 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;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
JSONValue,
|
||||
CollectionPermission,
|
||||
DocumentPermission,
|
||||
GroupPermission,
|
||||
} from "@shared/types";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import { SidebarContextType } from "./components/Sidebar/components/SidebarContext";
|
||||
@@ -125,7 +126,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) => unknown;
|
||||
perform?: (context: ActionContext) => any;
|
||||
to?: string | { url: string; target?: string };
|
||||
children?: ((context: ActionContext) => Action[]) | Action[];
|
||||
};
|
||||
@@ -154,7 +155,7 @@ export type ActionV2 = BaseActionV2 & {
|
||||
tooltip?:
|
||||
| ((context: ActionContext) => React.ReactChild | undefined)
|
||||
| React.ReactChild;
|
||||
perform: (context: ActionContext) => unknown;
|
||||
perform: (context: ActionContext) => any;
|
||||
};
|
||||
|
||||
export type InternalLinkActionV2 = BaseActionV2 & {
|
||||
@@ -311,7 +312,11 @@ export const EmptySelectValue = "__empty__";
|
||||
|
||||
export type Permission = {
|
||||
label: string;
|
||||
value: CollectionPermission | DocumentPermission | typeof EmptySelectValue;
|
||||
value:
|
||||
| CollectionPermission
|
||||
| DocumentPermission
|
||||
| GroupPermission
|
||||
| typeof EmptySelectValue;
|
||||
divider?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import retry from "fetch-retry";
|
||||
import trim from "lodash/trim";
|
||||
import queryString from "query-string";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import { JSONObject } from "@shared/types";
|
||||
import { JSONObject, Scope } from "@shared/types";
|
||||
import stores from "~/stores";
|
||||
import Logger from "./Logger";
|
||||
import download from "./download";
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
UnprocessableEntityError,
|
||||
UpdateRequiredError,
|
||||
} from "./errors";
|
||||
import { getCookie } from "tiny-cookie";
|
||||
import { CSRF } from "@shared/constants";
|
||||
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
|
||||
|
||||
type Options = {
|
||||
baseUrl?: string;
|
||||
@@ -47,7 +50,7 @@ class ApiClient {
|
||||
this.shareId = shareId;
|
||||
};
|
||||
|
||||
fetch = async <T = unknown>(
|
||||
fetch = async <T = any>(
|
||||
path: string,
|
||||
method: string,
|
||||
data: JSONObject | FormData | undefined,
|
||||
@@ -105,6 +108,20 @@ class ApiClient {
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
// Add CSRF token to headers for mutating requests
|
||||
const isModifyingRequest = ["POST", "PUT", "PATCH", "DELETE"].includes(
|
||||
method
|
||||
);
|
||||
const canAccessWithReadOnly = AuthenticationHelper.canAccess(path, [
|
||||
Scope.Read,
|
||||
]);
|
||||
if (isModifyingRequest && !canAccessWithReadOnly) {
|
||||
const csrfToken = getCookie(CSRF.cookieName);
|
||||
if (csrfToken) {
|
||||
headerOptions[CSRF.headerName] = csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
// for multipart forms or other non JSON requests fetch
|
||||
// populates the Content-Type without needing to explicitly
|
||||
// set it.
|
||||
@@ -180,7 +197,7 @@ class ApiClient {
|
||||
const error: {
|
||||
message?: string;
|
||||
error?: string;
|
||||
data?: Record<string, unknown>;
|
||||
data?: Record<string, any>;
|
||||
} = {};
|
||||
|
||||
try {
|
||||
@@ -213,6 +230,12 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
if (error.error === "csrf_error") {
|
||||
throw new AuthorizationError(
|
||||
"CSRF token invalid, please try reloading."
|
||||
);
|
||||
}
|
||||
|
||||
throw new AuthorizationError(error.message);
|
||||
}
|
||||
|
||||
@@ -244,13 +267,13 @@ class ApiClient {
|
||||
throw err;
|
||||
};
|
||||
|
||||
get = <T = unknown>(
|
||||
get = <T = any>(
|
||||
path: string,
|
||||
data: JSONObject | undefined,
|
||||
options?: FetchOptions
|
||||
) => this.fetch<T>(path, "GET", data, options);
|
||||
|
||||
post = <T = unknown>(
|
||||
post = <T = any>(
|
||||
path: string,
|
||||
data?: JSONObject | FormData | undefined,
|
||||
options?: FetchOptions
|
||||
|
||||
@@ -13,7 +13,7 @@ type LogCategory =
|
||||
| "plugins"
|
||||
| "policies";
|
||||
|
||||
type Extra = Record<string, unknown>;
|
||||
type Extra = Record<string, any>;
|
||||
|
||||
class Logger {
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
type ComponentPromise<T extends React.ComponentType<unknown>> = Promise<{
|
||||
type ComponentPromise<T extends React.ComponentType<any>> = Promise<{
|
||||
default: T;
|
||||
}>;
|
||||
|
||||
@@ -12,7 +12,7 @@ type ComponentPromise<T extends React.ComponentType<unknown>> = Promise<{
|
||||
* @param interval The interval between retries in milliseconds, defaults to 1000.
|
||||
* @returns A lazy component.
|
||||
*/
|
||||
export default function lazyWithRetry<T extends React.ComponentType<unknown>>(
|
||||
export default function lazyWithRetry<T extends React.ComponentType<any>>(
|
||||
component: () => ComponentPromise<T>,
|
||||
retries?: number,
|
||||
interval?: number
|
||||
@@ -20,7 +20,7 @@ export default function lazyWithRetry<T extends React.ComponentType<unknown>>(
|
||||
return React.lazy(() => retry(component, retries, interval));
|
||||
}
|
||||
|
||||
function retry<T extends React.ComponentType<unknown>>(
|
||||
function retry<T extends React.ComponentType<any>>(
|
||||
fn: () => ComponentPromise<T>,
|
||||
retriesLeft = 3,
|
||||
interval = 1000
|
||||
|
||||
@@ -37,17 +37,6 @@ 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;
|
||||
}
|
||||
@@ -78,16 +67,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function settingsPath(...args: string[]): string {
|
||||
|
||||
export function commentPath(document: Document, comment: Comment): string {
|
||||
return `${documentPath(document)}?commentId=${comment.id}${
|
||||
comment.isResolved ? "&resolved=" : ""
|
||||
comment.isResolved ? "&resolved=1" : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ export default {
|
||||
// TypeScript files
|
||||
"**/*.[tj]s?(x)": [
|
||||
(f) => `prettier --write ${f.join(" ")}`,
|
||||
(f) => (f.length > 20 ? `yarn lint` : `oxlint ${f.join(" ")}`),
|
||||
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix`),
|
||||
() => `yarn build:i18n`,
|
||||
() => "git add shared/i18n/locales/en_US/translation.json",
|
||||
],
|
||||
|
||||
@@ -51,32 +51,32 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@aws-sdk/client-s3": "3.873.0",
|
||||
"@aws-sdk/lib-storage": "3.873.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.873.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.873.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.873.0",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@babel/plugin-proposal-decorators": "^7.28.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-destructuring": "^7.28.0",
|
||||
"@babel/plugin-transform-regenerator": "^7.28.1",
|
||||
"@babel/preset-env": "^7.28.0",
|
||||
"@babel/plugin-transform-regenerator": "^7.28.3",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^6.7.10",
|
||||
"@bull-board/koa": "^6.12.0",
|
||||
"@css-inline/css-inline-wasm": "^0.14.3",
|
||||
"@css-inline/css-inline-wasm": "^0.17.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dotenvx/dotenvx": "^1.48.4",
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@fast-csv/parse": "^5.0.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.3",
|
||||
"@fortawesome/react-fontawesome": "^0.2.6",
|
||||
"@getoutline/react-roving-tabindex": "^3.2.4",
|
||||
"@hocuspocus/extension-redis": "1.1.2",
|
||||
"@hocuspocus/extension-throttle": "1.1.2",
|
||||
@@ -84,22 +84,23 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@linear/sdk": "^39.0.0",
|
||||
"@linear/sdk": "^39.2.1",
|
||||
"@node-oauth/oauth2-server": "^5.2.0",
|
||||
"@notionhq/client": "^2.3.0",
|
||||
"@octokit/auth-app": "^6.1.4",
|
||||
"@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",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-one-time-password-field": "^0.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-one-time-password-field": "^0.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.2",
|
||||
"@sentry/node": "^7.120.4",
|
||||
"@sentry/react": "^7.120.4",
|
||||
@@ -123,11 +124,11 @@
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie": "^0.7.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"core-js": "^3.37.0",
|
||||
"core-js": "^3.45.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"datadog-metrics": "^0.12.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.62.0",
|
||||
"dd-trace": "^5.63.0",
|
||||
"diff": "^5.2.0",
|
||||
"email-providers": "^1.14.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
@@ -170,7 +171,7 @@
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^3.0.0",
|
||||
"mermaid": "11.9.0",
|
||||
"mermaid": "11.10.1",
|
||||
"mime-types": "^3.0.1",
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
@@ -255,17 +256,17 @@
|
||||
"styled-normalize": "^8.1.1",
|
||||
"throng": "^5.0.0",
|
||||
"tiny-cookie": "^2.5.1",
|
||||
"tmp": "^0.2.4",
|
||||
"tmp": "^0.2.5",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"turndown": "^7.2.0",
|
||||
"ukkonen": "^2.1.0",
|
||||
"ukkonen": "^2.2.0",
|
||||
"umzug": "^3.8.2",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.15.15",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-pwa": "^1.0.2",
|
||||
"vite-plugin-pwa": "1.0.3",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
@@ -276,7 +277,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.0",
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.3.1",
|
||||
@@ -351,7 +352,7 @@
|
||||
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
|
||||
"browserslist-to-esbuild": "^1.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"discord-api-types": "^0.37.119",
|
||||
"discord-api-types": "^0.38.20",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^8.13.0",
|
||||
"ioredis-mock": "^8.9.0",
|
||||
@@ -365,7 +366,7 @@
|
||||
"prettier": "^3.6.2",
|
||||
"react-refresh": "^0.17.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.1.3",
|
||||
"rollup-plugin-webpack-stats": "2.1.3",
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.9.2",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
@@ -381,6 +382,6 @@
|
||||
"qs": "6.9.7",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.1",
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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);
|
||||
@@ -1,23 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
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: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"id": "gitlab",
|
||||
"name": "GitLab",
|
||||
"priority": 16,
|
||||
"description": "Adds a GitLab integration for link unfurling and converting links to mentions.",
|
||||
"after": "linear"
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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;
|
||||
@@ -1,17 +0,0 @@
|
||||
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>;
|
||||
@@ -1,6 +0,0 @@
|
||||
import env from "@server/env";
|
||||
|
||||
export default {
|
||||
GITLAB_CLIENT_ID: env.GITLAB_CLIENT_ID,
|
||||
GITLAB_CLIENT_SECRET: env.GITLAB_CLIENT_SECRET,
|
||||
};
|
||||
@@ -1,293 +0,0 @@
|
||||
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 { 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: string) => ({
|
||||
name: label,
|
||||
color: "#428BCA", // Default GitLab blue
|
||||
}));
|
||||
}
|
||||
|
||||
// Create the appropriate response based on the resource type
|
||||
if (resourceType === UnfurlResourceType.Issue) {
|
||||
return {
|
||||
type: UnfurlResourceType.Issue,
|
||||
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
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: UnfurlResourceType.PR,
|
||||
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: !!data.draft,
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
};
|
||||
}
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import BaseTask from "@server/queues/tasks/BaseTask";
|
||||
import { Integration } from "@server/models";
|
||||
import { FileOperation } from "@server/models";
|
||||
import fetch from "node-fetch";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import {
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
FileOperationFormat,
|
||||
} from "@shared/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
type Props = {
|
||||
integrationId: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
// Define a type for GitLab settings
|
||||
interface GitLabSettings {
|
||||
gitlab: {
|
||||
project?: {
|
||||
avatar_url?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
// Create a file operation with the correct parameters
|
||||
const operation = await FileOperation.create({
|
||||
type: FileOperationType.Import,
|
||||
state: FileOperationState.Creating,
|
||||
format: FileOperationFormat.JSON, // Use a valid FileOperationFormat
|
||||
key: `uploads/${integration.teamId}/${uuidv4()}/${name}`,
|
||||
userId: integration.userId,
|
||||
teamId: integration.teamId,
|
||||
size: buffer.length,
|
||||
});
|
||||
|
||||
// Cast the settings to our GitLabSettings interface
|
||||
const currentSettings = integration.settings as unknown as GitLabSettings;
|
||||
|
||||
// Update the integration settings with the avatar URL
|
||||
await integration.update({
|
||||
settings: {
|
||||
...integration.settings,
|
||||
gitlab: {
|
||||
...currentSettings.gitlab,
|
||||
project: {
|
||||
...currentSettings.gitlab?.project,
|
||||
avatar_url: operation.url,
|
||||
},
|
||||
},
|
||||
} as Record<string, unknown>,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// 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.
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Logger.error("Failed to upload GitLab project avatar", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import * as 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)}`;
|
||||
}
|
||||
}
|
||||
@@ -55,15 +55,7 @@ export const Notion = observer(() => {
|
||||
onClose: clearQueryParams,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
t,
|
||||
dialogs,
|
||||
oauthSuccess,
|
||||
service,
|
||||
clearQueryParams,
|
||||
handleSubmit,
|
||||
integrationId,
|
||||
]);
|
||||
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!oauthError) {
|
||||
|
||||
@@ -52,15 +52,7 @@ export function ImportDialog({ integrationId, onSubmit }: Props) {
|
||||
toast.error(err.message);
|
||||
resetSubmitting();
|
||||
}
|
||||
}, [
|
||||
permission,
|
||||
onSubmit,
|
||||
integrationId,
|
||||
t,
|
||||
imports,
|
||||
resetSubmitting,
|
||||
setSubmitting,
|
||||
]);
|
||||
}, [permission, onSubmit]);
|
||||
|
||||
return (
|
||||
<Flex column gap={12}>
|
||||
|
||||
@@ -21,6 +21,11 @@ type ParsePageOutput = ImportTaskOutput[number] & {
|
||||
};
|
||||
|
||||
export default class NotionAPIImportTask extends APIImportTask<IntegrationService.Notion> {
|
||||
private skippableErrorMessages = [
|
||||
"Database retrievals do not support linked databases",
|
||||
"does not contain any data sources accessible by this API bot", // error msg for linked database views
|
||||
];
|
||||
|
||||
/**
|
||||
* Process the Notion import task.
|
||||
* This fetches data from Notion and converts it to task output.
|
||||
@@ -138,8 +143,8 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
if (
|
||||
error.code === APIErrorCode.ObjectNotFound ||
|
||||
error.code === APIErrorCode.Unauthorized ||
|
||||
error.message.includes(
|
||||
"Database retrievals do not support linked databases"
|
||||
this.skippableErrorMessages.some((errorMsg) =>
|
||||
error.message.includes(errorMsg)
|
||||
)
|
||||
) {
|
||||
Logger.warn(
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import {
|
||||
Strategy,
|
||||
StrategyOptionsWithRequest,
|
||||
VerifyFunctionWithRequest,
|
||||
} from "passport-oauth2";
|
||||
import { Request } from "express";
|
||||
|
||||
interface OIDCOptions extends StrategyOptionsWithRequest {
|
||||
originalQuery?: Record<string, string | string[]>;
|
||||
}
|
||||
import OAuth2Strategy, { Strategy } from "passport-oauth2";
|
||||
|
||||
export class OIDCStrategy extends Strategy {
|
||||
constructor(
|
||||
options: StrategyOptionsWithRequest,
|
||||
verify: VerifyFunctionWithRequest
|
||||
options: OAuth2Strategy.StrategyOptionsWithRequest,
|
||||
verify: OAuth2Strategy.VerifyFunctionWithRequest
|
||||
) {
|
||||
super(options, verify);
|
||||
|
||||
@@ -23,13 +14,12 @@ export class OIDCStrategy extends Strategy {
|
||||
}
|
||||
}
|
||||
|
||||
authenticate(req: Request, options?: Record<string, unknown>) {
|
||||
const opts = options ? { ...options } : ({} as OIDCOptions);
|
||||
opts.originalQuery = req.query as Record<string, string | string[]>;
|
||||
super.authenticate(req, opts);
|
||||
authenticate(req: any, options: any) {
|
||||
options.originalQuery = req.query;
|
||||
super.authenticate(req, options);
|
||||
}
|
||||
|
||||
authorizationParams(options: OIDCOptions) {
|
||||
authorizationParams(options: any) {
|
||||
return {
|
||||
...options.originalQuery,
|
||||
...super.authorizationParams?.(options),
|
||||
|
||||
@@ -52,6 +52,7 @@ export async function fetchOIDCConfiguration(
|
||||
Accept: "application/json",
|
||||
},
|
||||
timeout: 10000, // 10 second timeout
|
||||
allowPrivateIPAddress: true,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -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, unknown>) {
|
||||
export async function post(endpoint: string, body: Record<string, any>) {
|
||||
let data;
|
||||
const token = body.token;
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function post(endpoint: string, body: Record<string, unknown>) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function request(endpoint: string, body: Record<string, unknown>) {
|
||||
export async function request(endpoint: string, body: Record<string, any>) {
|
||||
let data;
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,7 @@ export class SlackUtils {
|
||||
static createState(
|
||||
teamId: string,
|
||||
type: IntegrationType,
|
||||
data?: Record<string, unknown>
|
||||
data?: Record<string, any>
|
||||
) {
|
||||
return JSON.stringify({ type, teamId, ...data });
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 598 B |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 1003 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 705 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 994 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 443 B After Width: | Height: | Size: 339 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 729 B |
|
Before Width: | Height: | Size: 698 B After Width: | Height: | Size: 515 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.5 KiB |