fix(client): make check error messages selectable and copyable

Closes #3907.

Selecting the text of a failed check's error message navigated away
before the selection could be copied. The mouseup that ends a drag-select
lands on the table row, which fires the row's onClick.

Two changes:

- DataTable ignores a row click when the user has an active text
  selection, so drag-selecting text inside any row no longer navigates.
  This applies to all eleven tables that pass onRowClick, not just checks.
- The message column gets an explicit copy button, which is the more
  discoverable of the two fixes the reporter suggested. Added as a shared
  CopyButton in design-elements so other columns can reuse it.

Note the row click on the uptime details checks table navigates to
/checks/<check id>, but that route's parameter is :monitorId. A check id
never matches a monitor, so the destination page renders empty - which is
the "all this information disappears" in the report. Left alone here to
keep this change reviewable; it needs its own decision about what that
row should link to.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
This commit is contained in:
gorkem-bwl committed 2026-09-03 00:24:07 -04:00
1 parent a1c54997bb
commit db4689207a
6 files changed
+122 -3

No files matched your search

@@ -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 (
<Tooltip
title={title}
disableInteractive
>
<IconButton
onClick={handleCopy}
aria-label={title}
size="small"
>
{copied ? (
<Check
size={14}
strokeWidth={1.5}
color={theme.palette.success.main}
/>
) : (
<Copy
size={14}
strokeWidth={1.5}
color={theme.palette.text.secondary}
/>
)}
</IconButton>
</Tooltip>
);
};
export default CopyButton;
@@ -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 (
<Stack
onClick={() => (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);
}}
@@ -28,3 +28,4 @@ export * from "./Avatar";
export * from "./StatusCodeLabel";
export * from "./StrategyBadge";
export * from "./StepProgress";
export { default as CopyButton } from "./CopyButton";
@@ -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 (
<Stack
direction="row"
alignItems="center"
justifyContent="center"
gap={theme.spacing(LAYOUT.XS)}
>
<span>{row.message}</span>
<CopyButton
value={row.message}
label={t("common.copyMessage")}
/>
</Stack>
);
},
},
];
@@ -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<Check>[] = [
@@ -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 (
<Stack
direction="row"
alignItems="center"
justifyContent="center"
gap={theme.spacing(LAYOUT.XS)}
>
<span>{row.message}</span>
<CopyButton
value={row.message}
label={t("common.copyMessage")}
/>
</Stack>
);
},
},
{
+4
View File
@@ -14,6 +14,10 @@
}
},
"appName": "Checkmate",
"copy": "Copy",
"copied": "Copied",
"copyMessage": "Copy message",
"copyFailed": "Could not copy to clipboard",
"statusCode": {
"down": "Down",
"networkError": {