diff --git a/client/src/Components/design-elements/CopyButton.tsx b/client/src/Components/design-elements/CopyButton.tsx new file mode 100644 index 000000000..caa317598 --- /dev/null +++ b/client/src/Components/design-elements/CopyButton.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import IconButton from "@mui/material/IconButton"; +import Tooltip from "@mui/material/Tooltip"; +import { useTheme } from "@mui/material/styles"; +import { Check, Copy } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/Hooks/UseToast"; + +const RESET_DELAY_MS = 1500; + +/** + * Copies `value` to the clipboard. Shows a tick for a moment so the click is + * acknowledged without moving the user anywhere. + */ +const CopyButton = ({ value, label }: { value: string; label?: string }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const { toastError } = useToast(); + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + // The button usually sits inside a clickable row. + e.stopPropagation(); + + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), RESET_DELAY_MS); + } catch { + toastError(t("common.copyFailed")); + } + }; + + const title = copied ? t("common.copied") : (label ?? t("common.copy")); + + return ( + + + {copied ? ( + + ) : ( + + )} + + + ); +}; + +export default CopyButton; diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index df0d52b16..c7bdad9f8 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -79,6 +79,14 @@ export function DataTable< }; const isSmall = useMediaQuery(theme.breakpoints.down("md")); + + // Selecting text inside a row ends in a mouseup on that row, which would + // otherwise navigate away and take the text with it. Ignore the click when + // the user has actually selected something. + const hasTextSelection = () => { + const selection = window.getSelection(); + return Boolean(selection && !selection.isCollapsed && selection.toString().trim()); + }; const isInteractive = Boolean(onRowClick) || expandableRows; if (data.length === 0 || headers.length === 0) { @@ -100,7 +108,10 @@ export function DataTable< keys.push(key); return ( (onRowClick ? onRowClick(row) : null)} + onClick={() => { + if (hasTextSelection()) return; + if (onRowClick) onRowClick(row); + }} spacing={theme.spacing(LAYOUT.XS)} sx={{ borderStyle: "solid", @@ -247,6 +258,7 @@ export function DataTable< ...(getRowSx?.(row) as object), }} onClick={() => { + if (hasTextSelection()) return; if (expandableRows) handleExpand(row); else if (onRowClick) onRowClick(row); }} diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index d7d38d014..e35d95823 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -28,3 +28,4 @@ export * from "./Avatar"; export * from "./StatusCodeLabel"; export * from "./StrategyBadge"; export * from "./StepProgress"; +export { default as CopyButton } from "./CopyButton"; diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx index 31737f4bb..0e75116dc 100644 --- a/client/src/Pages/Checks/Components/ChecksTable.tsx +++ b/client/src/Pages/Checks/Components/ChecksTable.tsx @@ -3,8 +3,12 @@ import { Pagination, StatusLabel, StatusCodeLabel, + CopyButton, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; +import { LAYOUT } from "@/Utils/Theme/constants"; import type { Header } from "@/Components/design-elements/Table"; import type { Monitor } from "@/Types/Monitor"; import { useTranslation } from "react-i18next"; @@ -31,6 +35,7 @@ export const ChecksTable = ({ setRowsPerPage: (rowsPerPage: number) => void; }) => { const { t } = useTranslation(); + const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const getHeaders = () => { @@ -79,7 +84,21 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - return row.message || "N/A"; + if (!row.message) return "N/A"; + return ( + + {row.message} + + + ); }, }, ]; diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index b100c28c4..91b4ce588 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -3,8 +3,12 @@ import { Pagination, StatusLabel, StatusCodeLabel, + CopyButton, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; +import { LAYOUT } from "@/Utils/Theme/constants"; import type { Header } from "@/Components/design-elements"; import type { Check } from "@/Types/Check"; @@ -31,6 +35,7 @@ export const ChecksTable = ({ }) => { const navigate = useNavigate(); const { t } = useTranslation(); + const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const headers: Header[] = [ @@ -52,7 +57,21 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - return row.message || "N/A"; + if (!row.message) return "N/A"; + return ( + + {row.message} + + + ); }, }, { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index cbebadded..9b193ef1f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -14,6 +14,10 @@ } }, "appName": "Checkmate", + "copy": "Copy", + "copied": "Copied", + "copyMessage": "Copy message", + "copyFailed": "Could not copy to clipboard", "statusCode": { "down": "Down", "networkError": {