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:
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
}
|
||||
},
|
||||
"appName": "Checkmate",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyMessage": "Copy message",
|
||||
"copyFailed": "Could not copy to clipboard",
|
||||
"statusCode": {
|
||||
"down": "Down",
|
||||
"networkError": {
|
||||
|
||||
Reference in new issue
Block a user