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"));
|
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;
|
const isInteractive = Boolean(onRowClick) || expandableRows;
|
||||||
|
|
||||||
if (data.length === 0 || headers.length === 0) {
|
if (data.length === 0 || headers.length === 0) {
|
||||||
@@ -100,7 +108,10 @@ export function DataTable<
|
|||||||
keys.push(key);
|
keys.push(key);
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
onClick={() => (onRowClick ? onRowClick(row) : null)}
|
onClick={() => {
|
||||||
|
if (hasTextSelection()) return;
|
||||||
|
if (onRowClick) onRowClick(row);
|
||||||
|
}}
|
||||||
spacing={theme.spacing(LAYOUT.XS)}
|
spacing={theme.spacing(LAYOUT.XS)}
|
||||||
sx={{
|
sx={{
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
@@ -247,6 +258,7 @@ export function DataTable<
|
|||||||
...(getRowSx?.(row) as object),
|
...(getRowSx?.(row) as object),
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (hasTextSelection()) return;
|
||||||
if (expandableRows) handleExpand(row);
|
if (expandableRows) handleExpand(row);
|
||||||
else if (onRowClick) onRowClick(row);
|
else if (onRowClick) onRowClick(row);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -28,3 +28,4 @@ export * from "./Avatar";
|
|||||||
export * from "./StatusCodeLabel";
|
export * from "./StatusCodeLabel";
|
||||||
export * from "./StrategyBadge";
|
export * from "./StrategyBadge";
|
||||||
export * from "./StepProgress";
|
export * from "./StepProgress";
|
||||||
|
export { default as CopyButton } from "./CopyButton";
|
||||||
@@ -3,8 +3,12 @@ import {
|
|||||||
Pagination,
|
Pagination,
|
||||||
StatusLabel,
|
StatusLabel,
|
||||||
StatusCodeLabel,
|
StatusCodeLabel,
|
||||||
|
CopyButton,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import Box from "@mui/material/Box";
|
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 { Header } from "@/Components/design-elements/Table";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -31,6 +35,7 @@ export const ChecksTable = ({
|
|||||||
setRowsPerPage: (rowsPerPage: number) => void;
|
setRowsPerPage: (rowsPerPage: number) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
|
|
||||||
const getHeaders = () => {
|
const getHeaders = () => {
|
||||||
@@ -79,7 +84,21 @@ export const ChecksTable = ({
|
|||||||
id: "message",
|
id: "message",
|
||||||
content: t("common.table.headers.message"),
|
content: t("common.table.headers.message"),
|
||||||
render: (row) => {
|
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,
|
Pagination,
|
||||||
StatusLabel,
|
StatusLabel,
|
||||||
StatusCodeLabel,
|
StatusCodeLabel,
|
||||||
|
CopyButton,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import Box from "@mui/material/Box";
|
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 { Header } from "@/Components/design-elements";
|
||||||
import type { Check } from "@/Types/Check";
|
import type { Check } from "@/Types/Check";
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ export const ChecksTable = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
|
|
||||||
const headers: Header<Check>[] = [
|
const headers: Header<Check>[] = [
|
||||||
@@ -52,7 +57,21 @@ export const ChecksTable = ({
|
|||||||
id: "message",
|
id: "message",
|
||||||
content: t("common.table.headers.message"),
|
content: t("common.table.headers.message"),
|
||||||
render: (row) => {
|
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",
|
"appName": "Checkmate",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied",
|
||||||
|
"copyMessage": "Copy message",
|
||||||
|
"copyFailed": "Could not copy to clipboard",
|
||||||
"statusCode": {
|
"statusCode": {
|
||||||
"down": "Down",
|
"down": "Down",
|
||||||
"networkError": {
|
"networkError": {
|
||||||
|
|||||||
Reference in new issue
Block a user