Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 97 additions & 5 deletions src/components/mui/SnackbarNotification/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,17 @@ import {
Alert,
Box,
Collapse,
IconButton,
Link,
Snackbar,
Tooltip,
Typography
} from "@mui/material";
import { visuallyHidden } from "@mui/utils";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import ErrorIcon from "@mui/icons-material/Error";
import SnackbarNotificationContext from "./Context";
export { useSnackbarMessage } from "./Context";
Expand All @@ -44,6 +50,13 @@ import { clearConsumedSnackbarMessage } from "../../../utils/actions";
const formatSentAt = (msg) =>
formatEpoch(msg.receivedAt / MILLISECONDS_IN_SECOND, "h:mm:ss a");

const toCopyText = (msg) => {
const html = String(msg.html).replace(/<br\s*\/?>/gi, "\n");
const text = new DOMParser().parseFromString(html, "text/html").body
.textContent;
return [text.trim(), msg.details].filter(Boolean).join("\n\n");
Comment thread
smarcet marked this conversation as resolved.
Resolved
};

// a queue shows its first message, a group shows all of them at once
const useMessages = ({ grouped = false } = {}) => {
const [messages, setMessages] = useState([]);
Expand All @@ -64,17 +77,24 @@ const useMessages = ({ grouped = false } = {}) => {
// a blank message never opens, since it would block the queue
if (empty(msg.html)) return prev;
const now = Date.now();
// skip a repeat unless the first is fading out; errors only within the dedupe window
const isRepeat = prev.some(
// a repeat adds its details to the first, unless the first is fading out; errors only within the dedupe window
const repeat = prev.find(
(m) =>
!closingRef.current.includes(m) &&
m.html === msg.html &&
m.type === msg.type &&
(!grouped || now - m.receivedAt < NOTIFICATION_DEDUPE_WINDOW)
);
if (isRepeat) return prev;
const next = { ...msg, receivedAt: now };
return [...prev, next];
if (!repeat) return [...prev, { ...msg, receivedAt: now }];
if (!msg.details) return prev;
return prev.map((m) =>
m === repeat
? {
...m,
details: [m.details, msg.details].filter(Boolean).join("\n\n")
}
: m
);
});

// closes what is on screen; anything newer shows once it has faded out
Expand Down Expand Up @@ -120,6 +140,45 @@ const NotificationSnackbar = ({ queue, vertical }) => {
queue.onExited();
};

// clipboard results for an earlier message are ignored
const [copyResult, setCopyResult] = useState(null);
const copyStatus = copyResult?.message === msgData ? copyResult.status : null;
// clipboard callbacks only see their own render, so they read a ref
const msgRef = useRef(msgData);
useEffect(() => {
msgRef.current = msgData;
});
const copyStatusKey = {
copied: "general.copied",
failed: "general.copy_failed"
}[copyStatus];
const copyLabel =
count > 1 ? "general.copy_all_errors" : "general.copy_error";
const handleCopy = () => {
// a late result for a message no longer on screen is dropped
const finish = (status) =>
msgRef.current === msgData && setCopyResult({ message: msgData, status });
// a group copies oldest first, each error under its position and sent time
const copyText =
count > 1
? queue.shown
.map(
(m, i) =>
`[${i + 1}/${count}] ${formatSentAt(m)}\n${toCopyText(m)}`
)
.join("\n\n")
: toCopyText(msgData);
Comment on lines +162 to +170

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ako3131 When several requests fail with the same message inside the dedupe window, the copy only contains the first request's details.

add() (lines 81-88) treats a message as a repeat when html and type match within NOTIFICATION_DEDUPE_WINDOW, and returns prev without looking at details. Every error that falls through to the default branch of authErrorHandler has the same errors.server_error text. So when a page fires several requests and the API returns 500 for all of them, one message is kept and the other requests' method, URL and body are dropped. That is the case where the request line is most useful in a bug report, and the copied text points at only one of the failing endpoints.

Suggested fix: keep showing a single message, but append the repeat's details to it. A sketch of the body of add():

const repeat = prev.find(
  (m) =>
    !closingRef.current.includes(m) &&
    m.html === msg.html &&
    m.type === msg.type &&
    now - m.receivedAt < NOTIFICATION_DEDUPE_WINDOW
);
if (!repeat) return [...prev, { ...msg, receivedAt: now }];
// same text from another request: one message on screen, both requests in the copy
if (!msg.details) return prev;
return prev.map((m) =>
  m === repeat
    ? { ...m, details: [m.details, msg.details].filter(Boolean).join("\n\n") }
    : m
);

After the rebase, keep the base's window condition (!grouped || now - m.receivedAt < NOTIFICATION_DEDUPE_WINDOW). Replacing the message object also clears the "Copied" state, which is correct because the copied text is now out of date. Please add a test with two errors that share html and type but have different details, asserting that both request lines are in the copied text.

// the clipboard can be missing (non-https page) or blocked by the browser
try {
navigator.clipboard.writeText(copyText).then(
() => finish("copied"),
() => finish("failed")
);
} catch (e) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ako3131 The synchronous catch branch has no test. Every clipboard test installs a writeText mock, so the case this branch exists for, navigator.clipboard being undefined on a non-https page, is never exercised.

Suggested fix: add a test that clicks the copy button without calling mockClipboard and expects general.copy_failed in the role="status" element and no CheckIcon.

finish("failed");
}
};

return (
<Snackbar
open={queue.open}
Expand All @@ -130,6 +189,39 @@ const NotificationSnackbar = ({ queue, vertical }) => {
>
<Alert
onClose={onClose}
// a custom action replaces Alert's close button, so errors re-add it
action={
isSuccess ? undefined : (
<>
<Tooltip title={T.translate(copyStatusKey || copyLabel)}>
<IconButton
size="small"
color="inherit"
aria-label={T.translate(copyLabel)}
onClick={handleCopy}
>
{copyStatus === "copied" ? (
<CheckIcon fontSize="small" />
) : (
<ContentCopyIcon fontSize="small" />
)}
</IconButton>
</Tooltip>
{/* a live region, since screen readers skip the tooltip change */}
<Box component="span" role="status" sx={visuallyHidden}>
{copyStatusKey && T.translate(copyStatusKey)}
</Box>
<IconButton
size="small"
color="inherit"
aria-label={T.translate("general.close")}
onClick={onClose}
>
<CloseIcon fontSize="small" />
</IconButton>
</>
)
}
severity={msgData.type}
variant="filled"
sx={{ width: "100%", backgroundColor: `${msgData.type}.light` }}
Expand Down
177 changes: 166 additions & 11 deletions src/components/mui/__tests__/snackbar-notification.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@ import {
} from "../../../utils/constants";

describe("SnackbarNotification", () => {
let clearConsumedSnackbarMessage;

const ui = (snackbarMessage) => (
<SnackbarNotification
snackbarMessage={snackbarMessage}
clearConsumedSnackbarMessage={clearConsumedSnackbarMessage}
>
<div>App</div>
</SnackbarNotification>
);

const mockClipboard = (writeText) =>
Object.assign(navigator, { clipboard: { writeText } });

beforeEach(() => {
clearConsumedSnackbarMessage = jest.fn();
});

afterEach(() => {
delete navigator.clipboard;
});

test("renders children", () => {
render(
<SnackbarNotification clearConsumedSnackbarMessage={jest.fn()}>
Expand Down Expand Up @@ -78,19 +100,117 @@ describe("SnackbarNotification", () => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});

test("copies an error as plain text with its details", async () => {
const writeText = jest.fn().mockResolvedValue();
mockClipboard(writeText);
render(
ui({
html: "Start date is required.<br>End date is invalid.<br>",
type: "warning",
details: "Request: PUT /api/v1/summits/1 (412)"
})
);

fireEvent.click(screen.getByLabelText("general.copy_error"));

expect(writeText).toHaveBeenCalledWith(
"Start date is required.\nEnd date is invalid.\n\nRequest: PUT /api/v1/summits/1 (412)"
);
// let the copied state settle inside the test
await screen.findByTestId("CheckIcon");
});

test("shows the check mark and announces Copied only after the clipboard accepts the text", async () => {
let accept;
mockClipboard(
jest.fn(
() =>
new Promise((resolve) => {
accept = resolve;
})
)
);
render(ui({ html: "Not found", type: "warning" }));

fireEvent.click(screen.getByLabelText("general.copy_error"));
expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument();
expect(screen.getByRole("status")).toBeEmptyDOMElement();

accept();
expect(await screen.findByTestId("CheckIcon")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("general.copied");
});

test("says the copy failed when the clipboard rejects it", async () => {
mockClipboard(jest.fn().mockRejectedValue(new Error("blocked")));
render(ui({ html: "Not found", type: "warning" }));

fireEvent.click(screen.getByLabelText("general.copy_error"));

expect(await screen.findByText("general.copy_failed")).toHaveAttribute(
"role",
"status"
);
expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument();
});

test("says the copy failed when there is no clipboard", () => {
render(ui({ html: "Not found", type: "warning" }));

fireEvent.click(screen.getByLabelText("general.copy_error"));

expect(screen.getByRole("status")).toHaveTextContent("general.copy_failed");
expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument();
});

test("keeps a newer copy's check mark when an earlier copy finishes late", async () => {
let acceptFirst;
mockClipboard(
jest
.fn()
.mockReturnValueOnce(
new Promise((resolve) => {
acceptFirst = resolve;
})
)
.mockResolvedValueOnce()
);
const { rerender } = render(ui({ html: "First error", type: "warning" }));
fireEvent.click(screen.getByLabelText("general.copy_error"));
rerender(ui({ html: "Second error", type: "error" }));
fireEvent.click(screen.getByLabelText("general.copy_all_errors"));
await screen.findByTestId("CheckIcon");

await act(async () => {
acceptFirst();
});
expect(screen.getByTestId("CheckIcon")).toBeInTheDocument();
});

test("clears the copied state when a new message with the same text shows", async () => {
mockClipboard(jest.fn().mockResolvedValue());
const { rerender } = render(ui({ html: "Not found", type: "warning" }));
fireEvent.click(screen.getByLabelText("general.copy_error"));
await screen.findByTestId("CheckIcon");

// a repeat only shows once the copied one is closed
fireEvent.click(screen.getByRole("button", { name: /close/i }));
rerender(ui({ html: "Not found", type: "warning" }));

expect(await screen.findByTestId("ContentCopyIcon")).toBeInTheDocument();
expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument();
});

test("keeps only the close button on a success message", () => {
render(ui({ html: "Saved", type: "success" }));

expect(screen.queryByLabelText("general.copy_error")).not.toBeInTheDocument();
expect(screen.getByLabelText("Close")).toBeInTheDocument();
});

describe("pinned and grouped errors", () => {
const errorA = { html: "First error", type: "warning" };
const errorB = { html: "Second error", type: "error" };
let clearConsumedSnackbarMessage;

const ui = (snackbarMessage) => (
<SnackbarNotification
snackbarMessage={snackbarMessage}
clearConsumedSnackbarMessage={clearConsumedSnackbarMessage}
>
<div>App</div>
</SnackbarNotification>
);

const advance = (ms) =>
act(() => {
Expand All @@ -109,7 +229,6 @@ describe("SnackbarNotification", () => {

beforeEach(() => {
jest.useFakeTimers();
clearConsumedSnackbarMessage = jest.fn();
});
afterEach(() => jest.useRealTimers());

Expand Down Expand Up @@ -250,6 +369,42 @@ describe("SnackbarNotification", () => {
expect(items[1]).toHaveTextContent("First error");
});

test("copies every error in the group, oldest first", async () => {
const writeText = jest.fn().mockResolvedValue();
mockClipboard(writeText);
jest.setSystemTime(new Date(2026, 10, 10, 17, 18, 52));
const { rerender } = render(
ui({ ...errorA, details: "Request: PUT /api/v1/summits/1 (412)" })
);
jest.setSystemTime(new Date(2026, 10, 10, 17, 18, 55));
rerender(ui(errorB));

fireEvent.click(screen.getByLabelText("general.copy_all_errors"));

expect(writeText).toHaveBeenCalledWith(
"[1/2] 5:18:52 pm\nFirst error\n\nRequest: PUT /api/v1/summits/1 (412)\n\n[2/2] 5:18:55 pm\nSecond error"
);
await screen.findByTestId("CheckIcon");
});

test("adds a repeat's details to the error it repeats", async () => {
const writeText = jest.fn().mockResolvedValue();
mockClipboard(writeText);
const { rerender } = render(
ui({ ...errorA, details: "Request: GET /api/v1/summits (500)" })
);
rerender(
ui({ ...errorA, details: "Request: GET /api/v1/attendees (500)" })
);

fireEvent.click(screen.getByLabelText("general.copy_error"));

expect(writeText).toHaveBeenCalledWith(
"First error\n\nRequest: GET /api/v1/summits (500)\n\nRequest: GET /api/v1/attendees (500)"
);
await screen.findByTestId("CheckIcon");
});

test("ignores a repeat within the dedupe window", () => {
const { rerender } = render(ui(errorA));
rerender(ui({ ...errorA }));
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
"active": "Active",
"archived": "Archived",
"select_summit": "Select a Summit...",
"copy_error": "Copy error details",
"copy_all_errors": "Copy all errors",
"copied": "Copied",
"copy_failed": "Couldn't copy",
"close": "Close",
"add": "Add",
"add_new": "Add New",
"clear": "Clear",
Expand Down
Loading
Loading