This commit is contained in:
Philipinho
2026-08-09 15:01:52 +01:00
parent 21665fc0e7
commit 16f7139e6f
24 changed files with 1303 additions and 223 deletions
@@ -7,6 +7,8 @@ import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
import { matchIntegrationLink } from "@docmost/editor-ext";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
import { queryClient } from "@/main.tsx";
import { Integration } from "@/features/integration/types/integration.types";
import {
getAttachmentInfo,
uploadFile,
@@ -24,6 +26,17 @@ const ATTACHMENT_NODE_TYPES = [
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
// Only installed + enabled providers get card treatment; anything else pastes
// as an ordinary link. The cache is prefetched when the page editor mounts;
// a cold cache also means ordinary link.
function isIntegrationInstalled(provider: string): boolean {
const installed = queryClient.getQueryData<Integration[]>([
"installed-integrations",
]);
const integration = installed?.find((i) => i.type === provider);
return Boolean(integration?.isEnabled);
}
export const handlePaste = (
editor: Editor,
event: ClipboardEvent,
@@ -33,7 +46,11 @@ export const handlePaste = (
const clipboardData = event.clipboardData.getData("text/plain");
const integrationMatch = matchIntegrationLink(clipboardData.trim());
if (integrationMatch && editor.state.selection.empty) {
if (
integrationMatch &&
editor.state.selection.empty &&
isIntegrationInstalled(integrationMatch.provider)
) {
event.preventDefault();
const pastedUrl = clipboardData.trim();
editor
@@ -1,3 +1,31 @@
// Light-scheme text per hue, measured to pass 4.5:1 on the light-variant
// badge background; hexes are darkened .9 shades for hues whose scale
// never gets dark enough.
const BADGE_TEXT_LIGHT: Record<string, string> = {
dark: "var(--mantine-color-dark-9)",
gray: "var(--mantine-color-gray-9)",
red: "var(--mantine-color-red-9)",
pink: "var(--mantine-color-pink-9)",
grape: "var(--mantine-color-grape-9)",
violet: "var(--mantine-color-violet-9)",
indigo: "var(--mantine-color-indigo-9)",
blue: "var(--mantine-color-blue-9)",
cyan: "var(--mantine-color-cyan-9)",
teal: "var(--mantine-color-teal-9)",
green: "#277c38",
lime: "#4e7e0b",
yellow: "#ad5900",
orange: "#c3410e",
};
export function badgeTextColor(color?: string): string | undefined {
if (!color) return undefined;
const light = BADGE_TEXT_LIGHT[color];
if (!light) return undefined;
// Dark scheme keeps Mantine's own light-variant text.
return `light-dark(${light}, var(--mantine-color-${color}-light-color))`;
}
export function toBadgeColor(raw?: string): string {
if (!raw) return "gray";
const hex = raw.toLowerCase().replace("#", "");
@@ -17,7 +17,7 @@ import { getIntegrationIcon } from "@/features/integration/components/integratio
import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
import { timeAgo } from "@/lib/time";
import { useUnfurl } from "./use-unfurl";
import { toBadgeColor } from "./badge-color";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-link-view.module.css";
const SLACK_TEXT_CLAMP_LINES = 4;
@@ -125,6 +125,96 @@ function SlackMessageCard({
);
}
function JiraIssueCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
const { t } = useTranslation();
const meta = unfurlData.metadata ?? {};
const infoLine = [
meta.issueKey,
unfurlData.author
? t("Assigned to {{name}}", { name: unfurlData.author })
: t("Unassigned"),
meta.updatedAt
? t("Updated {{time}}", { time: timeAgo(new Date(meta.updatedAt)) })
: null,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
/>
) : (
<div style={{ flexShrink: 0 }}>{getIntegrationIcon("jira", 28)}</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
<Group gap={4} wrap="nowrap">
{meta.issueTypeIconUrl && (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
style={{ flexShrink: 0 }}
/>
)}
<Text size="xs" c="dimmed" truncate>
{infoLine}
</Text>
</Group>
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("jira", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function IntegrationLinkView(props: any) {
const { node, updateAttributes, editor } = props;
const { url, provider, unfurlData, status } = node.attrs;
@@ -231,6 +321,10 @@ function IntegrationLinkView(props: any) {
return <SlackMessageCard url={url} unfurlData={unfurlData} />;
}
if (provider === "jira" && unfurlData.metadata?.issueKey) {
return <JiraIssueCard url={url} unfurlData={unfurlData} />;
}
return (
<NodeViewWrapper data-drag-handle="">
<Card
@@ -261,6 +355,7 @@ function IntegrationLinkView(props: any) {
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
@@ -4,7 +4,7 @@ import { memo } from "react";
import { useTranslation } from "react-i18next";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { useUnfurl } from "./use-unfurl";
import { toBadgeColor } from "./badge-color";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-link-view.module.css";
function shortUrl(url: string): string {
@@ -40,6 +40,7 @@ function IntegrationMentionView(props: any) {
size="xs"
variant="light"
color={toBadgeColor(data.statusColor)}
c={badgeTextColor(toBadgeColor(data.statusColor))}
className={classes.mentionIcon}
>
{data.status}
@@ -80,6 +81,7 @@ function IntegrationMentionView(props: any) {
size="xs"
variant="light"
color="gray"
c={badgeTextColor("gray")}
tt="none"
className={classes.mentionIcon}
>
@@ -88,6 +90,29 @@ function IntegrationMentionView(props: any) {
)}
</>
);
} else if (meta.issueKey) {
// Jira: type icon leads, provider icon trails.
content = (
<>
{meta.issueTypeIconUrl ? (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
className={classes.mentionIcon}
/>
) : (
getIntegrationIcon(provider, 14)
)}
<Text component="span" size="sm" c="dimmed">
{meta.issueKey}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
{meta.issueTypeIconUrl && getIntegrationIcon(provider, 14)}
</>
);
} else if (issueNumber) {
content = (
<>
@@ -62,7 +62,7 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
}, [menuState, dismiss]);
const convert = useCallback(
(target: "preview" | "mention" | "url") => {
(target: "card" | "mention" | "url") => {
const found = findTarget();
if (!found) {
dismiss();
@@ -74,45 +74,37 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
const to = pos + node.nodeSize;
const isBlock = node.type.name === "integrationLink";
if (target === "preview" && !isBlock) {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.deleteRange({ from, to })
.insertContentAt(from, { type: "integrationLink", attrs })
.run();
} else if (target === "mention" && isBlock) {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.deleteRange({ from, to })
.insertContentAt(from, {
type: "paragraph",
content: [
{ type: "integrationMention", attrs },
{ type: "text", text: " " },
],
})
.run();
} else if (target === "url") {
// Always replace, even when the node is already in the requested form:
// the menu closes via the doc change, and BubbleMenu never re-evaluates
// on meta-only transactions, so a bare dismiss would leave it stuck.
let content: Record<string, any>;
if (target === "card") {
content = { type: "integrationLink", attrs };
} else if (target === "mention") {
const mention = { type: "integrationMention", attrs };
content = isBlock
? {
type: "paragraph",
content: [mention, { type: "text", text: " " }],
}
: mention;
} else {
const linkText = {
type: "text",
text: attrs.url,
marks: [{ type: "link", attrs: { href: attrs.url } }],
};
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.deleteRange({ from, to })
.insertContentAt(
from,
isBlock ? { type: "paragraph", content: [linkText] } : linkText,
)
.run();
} else {
// already in the requested form
dismiss();
content = isBlock
? { type: "paragraph", content: [linkText] }
: linkText;
}
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.deleteRange({ from, to })
.insertContentAt(from, content)
.run();
},
[editor, findTarget, dismiss],
);
@@ -126,6 +118,9 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
options={{ placement: "bottom-start", flip: true }}
shouldShow={shouldShow}
>
{/* Content is gated on the plugin state too: meta-only dismissals
(Escape) are invisible to BubbleMenu's update cycle. */}
{menuState ? (
<Paper shadow="md" radius="md" withBorder p={4} miw={140}>
<Text size="xs" c="dimmed" px={8} py={4}>
{t("Paste as")}
@@ -137,9 +132,9 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
size="compact-sm"
fullWidth
justify="flex-start"
onClick={() => convert("preview")}
onClick={() => convert("card")}
>
{t("Preview")}
{t("Card")}
</Button>
<Button
variant="subtle"
@@ -163,6 +158,7 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
</Button>
</Stack>
</Paper>
) : null}
</BaseBubbleMenu>
);
}
@@ -76,6 +76,7 @@ import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx";
import { getInstalledIntegrations } from "@/features/integration/services/integration-service";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { useTranslation } from "react-i18next";
import {
@@ -326,6 +327,15 @@ function CollabPageEditor({
[pageId, editable, extensions],
);
useEffect(() => {
// Warm the cache the paste handler reads to decide whether a pasted
// integration url becomes a card or stays an ordinary link.
queryClient.prefetchQuery({
queryKey: ["installed-integrations"],
queryFn: getInstalledIntegrations,
});
}, []);
useLayoutEffect(() => {
if (editor && !editor.isDestroyed) {
// @ts-ignore
@@ -1,104 +0,0 @@
import { Card, Group, Text, Badge, Button, Stack, Switch } from "@mantine/core";
import {
IconBrandGithub,
IconBrandSlack,
IconBrandGitlab,
IconPuzzle,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
IntegrationDefinition,
Integration,
} from "../types/integration.types";
const iconMap: Record<string, React.ElementType> = {
github: IconBrandGithub,
slack: IconBrandSlack,
gitlab: IconBrandGitlab,
};
type IntegrationCardProps = {
definition: IntegrationDefinition;
installation?: Integration;
onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void;
onConfigure: (integration: Integration) => void;
onToggle: (integration: Integration, enabled: boolean) => void;
};
export default function IntegrationCard({
definition,
installation,
onInstall,
onUninstall,
onConfigure,
onToggle,
}: IntegrationCardProps) {
const { t } = useTranslation();
const Icon = iconMap[definition.icon] ?? IconPuzzle;
const isInstalled = !!installation;
return (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
<Icon size={28} stroke={1.5} />
<div>
<Text fw={600} size="sm">
{definition.name}
</Text>
<Text size="xs" c="dimmed">
{definition.description}
</Text>
</div>
</Group>
</Group>
<Group gap="xs" mb="md">
{definition.capabilities.map((cap) => (
<Badge key={cap} size="xs" variant="light">
{cap}
</Badge>
))}
</Group>
{isInstalled ? (
<Stack gap="xs">
<Group justify="space-between">
<Switch
label={t("Enabled")}
checked={installation.isEnabled}
onChange={(e) => onToggle(installation, e.currentTarget.checked)}
size="sm"
/>
</Group>
<Group gap="xs">
<Button
size="xs"
variant="light"
onClick={() => onConfigure(installation)}
>
{t("Configure")}
</Button>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onUninstall(installation.id)}
>
{t("Uninstall")}
</Button>
</Group>
</Stack>
) : (
<Button
size="xs"
variant="light"
onClick={() => onInstall(definition.type)}
>
{t("Install")}
</Button>
)}
</Card>
);
}
@@ -11,7 +11,6 @@ type IntegrationRowProps = {
installation?: Integration;
onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void;
onConfigure: (integration: Integration) => void;
onToggle: (integration: Integration, enabled: boolean) => void;
};
@@ -20,7 +19,6 @@ export default function IntegrationRow({
installation,
onInstall,
onUninstall,
onConfigure,
onToggle,
}: IntegrationRowProps) {
const { t } = useTranslation();
@@ -64,13 +62,6 @@ export default function IntegrationRow({
}
size="sm"
/>
<Button
size="xs"
variant="light"
onClick={() => onConfigure(installation)}
>
{t("Configure")}
</Button>
<Button
size="xs"
variant="subtle"
@@ -1,34 +0,0 @@
import { Modal, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Integration } from "../types/integration.types";
type IntegrationSettingsModalProps = {
integration: Integration | null;
opened: boolean;
onClose: () => void;
};
export default function IntegrationSettingsModal({
integration,
opened,
onClose,
}: IntegrationSettingsModalProps) {
const { t } = useTranslation();
if (!integration) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={`${integration.type.charAt(0).toUpperCase() + integration.type.slice(1)} ${t("Settings")}`}
size="md"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t("Integration settings will appear here.")}
</Text>
</Stack>
</Modal>
);
}
@@ -1,12 +1,11 @@
import { Text, Alert, Stack } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useState, useCallback } from "react";
import { useCallback } from "react";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import IntegrationRow from "../components/integration-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import IntegrationSettingsModal from "../components/integration-settings-modal";
import {
useAvailableIntegrations,
useInstalledIntegrations,
@@ -31,8 +30,6 @@ export default function Integrations() {
const uninstallMutation = useUninstallIntegration();
const updateMutation = useUpdateIntegrationSettings();
const [configuring, setConfiguring] = useState<Integration | null>(null);
const handleInstall = useCallback(
async (type: string) => {
const definition = available?.find((d) => d.type === type);
@@ -68,10 +65,6 @@ export default function Integrations() {
[uninstallMutation],
);
const handleConfigure = useCallback((integration: Integration) => {
setConfiguring(integration);
}, []);
const handleToggle = useCallback(
(integration: Integration, enabled: boolean) => {
updateMutation.mutate({
@@ -122,19 +115,12 @@ export default function Integrations() {
installation={installation}
onInstall={handleInstall}
onUninstall={handleUninstall}
onConfigure={handleConfigure}
onToggle={handleToggle}
/>
);
})}
</Stack>
)}
<IntegrationSettingsModal
integration={configuring}
opened={!!configuring}
onClose={() => setConfiguring(null)}
/>
</>
);
}