From 0f04be7f582f329332a2e5fc4bad1bfc5990edea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Daxb=C3=B6ck?= Date: Wed, 18 Feb 2026 13:32:27 +0100 Subject: [PATCH] feat: Add CopyableCard component for GitHub saved replies Add a reusable CopyableCard component with a rounded dropdown button to copy title and description as GitHub-flavored markdown. Applied to the saved replies page to make it easy to set up GitHub saved replies. - New CopyableCard component with Copy dropdown (Reply title/Reply description) - Styled to match existing CopyMarkdownButton design - Preserves markdown formatting (checkboxes, lists) when copying Co-Authored-By: Claude --- .../templates/saved-replies/index.mdx | 59 ++++--- src/components/copyableCard.tsx | 149 ++++++++++++++++++ src/mdxComponents.ts | 2 + 3 files changed, 188 insertions(+), 22 deletions(-) create mode 100644 src/components/copyableCard.tsx diff --git a/develop-docs/sdk/getting-started/templates/saved-replies/index.mdx b/develop-docs/sdk/getting-started/templates/saved-replies/index.mdx index ad7c321c7a6dd..7d1c51852cad3 100644 --- a/develop-docs/sdk/getting-started/templates/saved-replies/index.mdx +++ b/develop-docs/sdk/getting-started/templates/saved-replies/index.mdx @@ -12,31 +12,46 @@ To set up saved replies go to [GitHub > Settings > Saved replies](https://github --- -## Open an issue first (behavior/refactor -> close) + Thanks for the contribution! We ask that behavioral changes and refactors have a linked issue so we can discuss the approach before a PR is opened. Could you open an issue describing the problem you're solving and your proposed approach? Closing this for now - happy to revisit once there's an issue to reference. -> -> Please also have a look at our CONTRIBUTING.md for more PR guidelines. +Please also have a look at our CONTRIBUTING.md for more PR guidelines.`}> ---- +Thanks for the contribution! We ask that behavioral changes and refactors have a linked issue so we can discuss the approach before a PR is opened. Could you open an issue describing the problem you're solving and your proposed approach? Closing this for now - happy to revisit once there's an issue to reference. -## Let's discuss the approach first (idea -> close) +Please also have a look at our CONTRIBUTING.md for more PR guidelines. -> This is an interesting idea! We'd like to align on the approach before reviewing code - could you open an issue describing the problem and your proposed solution? That way we can agree on direction first. Closing this PR for now. -> -> Please also have a look at our CONTRIBUTING.md for more PR guidelines. + ---- + + +This is an interesting idea! We'd like to align on the approach before reviewing code - could you open an issue describing the problem and your proposed solution? That way we can agree on direction first. Closing this PR for now. + +Please also have a look at our CONTRIBUTING.md for more PR guidelines. + + + + + +Thanks for the contribution! This PR needs some updates before we can review: + +- [ ] CI checks are passing +- [ ] PR description explains what and why +- [ ] Linked issue exists +- [ ] Tests are included + +We marked it as draft for now. Please update and we'll take another look. + +Please also have a look at our CONTRIBUTING.md for more PR guidelines. -## Not ready for review (request changes / mark as draft) - -> Thanks for the contribution! This PR needs some updates before we can review: -> -> - [ ] CI checks are passing -> - [ ] PR description explains what and why -> - [ ] Linked issue exists -> - [ ] Tests are included -> -> We marked it as draft for now. Please update and we'll take another look. -> -> Please also have a look at our CONTRIBUTING.md for more PR guidelines. + diff --git a/src/components/copyableCard.tsx b/src/components/copyableCard.tsx new file mode 100644 index 0000000000000..45112bd6c27e2 --- /dev/null +++ b/src/components/copyableCard.tsx @@ -0,0 +1,149 @@ +'use client'; + +import {Fragment, useEffect, useRef, useState} from 'react'; +import {createPortal} from 'react-dom'; +import {Clipboard} from 'react-feather'; + +import Chevron from 'sentry-docs/icons/Chevron'; + +interface CopyableCardProps { + children: React.ReactNode; + description: string; + title: string; +} + +export function CopyableCard({title, description, children}: CopyableCardProps) { + const [copiedItem, setCopiedItem] = useState<'title' | 'description' | null>(null); + const [isOpen, setIsOpen] = useState(false); + const [isMounted, setIsMounted] = useState(false); + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + + useEffect(() => { + setIsMounted(true); + + const handleClickOutside = (event: MouseEvent) => { + if ( + buttonRef.current && + !buttonRef.current.contains(event.target as Node) && + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + async function copyText(text: string, item: 'title' | 'description') { + try { + await navigator.clipboard.writeText(text.trim()); + setCopiedItem(item); + setIsOpen(false); + setTimeout(() => setCopiedItem(null), 1500); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to copy:', error); + } + } + + const getDropdownPosition = () => { + if (!buttonRef.current) { + return {top: 0, left: 0}; + } + const rect = buttonRef.current.getBoundingClientRect(); + return { + top: rect.bottom + 8, + left: rect.right - 160, + }; + }; + + const buttonClass = + 'inline-flex items-center text-nowrap h-full text-gray-700 dark:text-[var(--foreground)] bg-transparent border-none cursor-pointer transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-[var(--gray-a4)] active:bg-gray-100 dark:active:bg-[var(--gray-5)] focus:bg-gray-50 dark:focus:bg-[var(--gray-a4)] outline-none'; + const dropdownItemClass = + 'w-full p-2 px-3 text-left text-sm bg-transparent border-none rounded-md transition-colors hover:bg-gray-100 dark:hover:bg-[var(--gray-a4)] font-sans text-gray-900 dark:text-[var(--foreground)] cursor-pointer'; + + const getButtonLabel = () => { + if (copiedItem === 'title') { + return 'Reply title copied!'; + } + if (copiedItem === 'description') { + return 'Reply description copied!'; + } + return 'Copy'; + }; + + return ( +
+
+

+ {title} +

+ + {isMounted && ( + +
+
+ + +
+ + +
+
+ + {isOpen && + createPortal( +
+
+ + +
+
, + document.body + )} + + )} +
+
+
{children}
+
+
+ ); +} diff --git a/src/mdxComponents.ts b/src/mdxComponents.ts index e19489ab36640..9a0cc3908d9cb 100644 --- a/src/mdxComponents.ts +++ b/src/mdxComponents.ts @@ -10,6 +10,7 @@ import {CommunitySupportedPlatforms} from './components/communitySupportedPlatfo import {ConfigKey} from './components/configKey'; import {ConfigValue} from './components/configValue'; import {ContentSeparator} from './components/contentSeparator'; +import {CopyableCard} from './components/copyableCard'; import {CreateGitHubAppForm} from './components/createGitHubAppForm'; import {DefinitionList} from './components/definitionList'; import {DevDocsCardGrid} from './components/devDocsCardGrid'; @@ -113,6 +114,7 @@ export function mdxComponents( OnboardingSteps, RelayMetrics, SandboxLink, + CopyableCard, SignInNote, SplitLayout, SplitSection,