From e1e88428d29c62d23974bc02d7165cb7e138b22c Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 14 Oct 2026 12:40:13 -0500 Subject: [PATCH 01/15] feat: copy error message on snack bar error notifications --- .../mui/SnackbarNotification/index.js | 50 +++++++++++++++++++ .../__tests__/snackbar-notification.test.js | 37 ++++++++++++++ src/i18n/en.json | 2 + src/utils/__tests__/actions.test.js | 36 +++++++++++++ src/utils/actions.js | 24 +++++++-- 5 files changed, 146 insertions(+), 3 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 1fd0ebf3..ffb68fda 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -19,11 +19,16 @@ import { Alert, Box, Collapse, + IconButton, Link, Snackbar, + Tooltip, Typography } from "@mui/material"; 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"; @@ -120,6 +125,18 @@ const NotificationSnackbar = ({ queue, vertical }) => { queue.onExited(); }; + const [copied, setCopied] = useState(false); + const handleCopy = () => { + const html = String(msgData.html).replace(//gi, "\n"); + const text = new DOMParser().parseFromString(html, "text/html").body + .textContent; + navigator.clipboard.writeText( + [text.trim(), msgData.details].filter(Boolean).join("\n\n") + ); + setCopied(true); + setTimeout(() => setCopied(false), MILLISECONDS_IN_SECOND); + }; + return ( { > + + + {copied ? ( + + ) : ( + + )} + + + + + + + ) + } severity={msgData.type} variant="filled" sx={{ width: "100%", backgroundColor: `${msgData.type}.light` }} diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 8b5d050b..a4d1da63 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -78,6 +78,43 @@ describe("SnackbarNotification", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); + test("copies an error as plain text with its details", () => { + const writeText = jest.fn(); + Object.assign(navigator, { clipboard: { writeText } }); + render( + End date is invalid.
", + type: "warning", + details: "Request: PUT /api/v1/summits/1 (412)" + }} + clearSnackbarMessage={jest.fn()} + > +
App
+
+ ); + + 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)" + ); + }); + + test("keeps only the close button on a success message", () => { + render( + +
App
+
+ ); + + 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" }; diff --git a/src/i18n/en.json b/src/i18n/en.json index 88ebce24..3b841942 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -10,6 +10,8 @@ "active": "Active", "archived": "Archived", "select_summit": "Select a Summit...", + "copy_error": "Copy error details", + "copied": "Copied", "add": "Add", "add_new": "Add New", "clear": "Clear", diff --git a/src/utils/__tests__/actions.test.js b/src/utils/__tests__/actions.test.js index 2482a485..e6b1f054 100644 --- a/src/utils/__tests__/actions.test.js +++ b/src/utils/__tests__/actions.test.js @@ -208,6 +208,42 @@ describe('Utils Actions', () => { // CLEAR_SESSION_STATE is only dispatched by initLogin expect(types).toContain(CLEAR_SESSION_STATE); }); + + test('adds request details without the query string', () => { + const err = { + status: 404, + response: { + body: { message: "Entity Not Found" }, + req: { method: "PUT", url: "https://api.example.com/api/v1/summits/1?access_token=secret&expand=x" } + } + }; + + store.dispatch(snackbarErrorHandler(err)); + + const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE); + expect(payload.details).toContain("Request: PUT https://api.example.com/api/v1/summits/1 (404)"); + expect(payload.details).not.toContain("secret"); + }); + + test('lists 412 field names without their values', () => { + const errors = { start_date: ["The start date field is required."], end_date: ["The end date is invalid."] }; + + store.dispatch(snackbarErrorHandler({ status: 412, response: { body: { errors }, req: {} } })); + + const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE); + expect(payload.details).toContain("Fields: start_date, end_date"); + expect(payload.details).not.toContain("required"); + }); + + test('flags a missing response when the browser is offline', () => { + const onLineSpy = jest.spyOn(navigator, "onLine", "get").mockReturnValue(false); + + store.dispatch(snackbarErrorHandler({ method: "GET", url: "https://api.example.com/api/v1/summits" })); + + const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE); + expect(payload.details).toContain("Request: GET https://api.example.com/api/v1/summits (no response, offline)"); + onLineSpy.mockRestore(); + }); }); describe('clearConsumedSnackbarMessage', () => { diff --git a/src/utils/actions.js b/src/utils/actions.js index afe89cf9..65248aa8 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -18,10 +18,10 @@ URI.escapeQuerySpace = false; let http = request; import Swal from 'sweetalert2'; import T from "i18n-react/dist/i18n-react"; -import { isClearingSessionState, setSessionClearingState, getCurrentPathName } from './methods'; +import { isClearingSessionState, setSessionClearingState, getCurrentPathName, formatEpoch } from './methods'; import { CLEAR_SESSION_STATE } from '../components/security/actions'; import { doLogin, initLogOut, getAuthHandlers } from '../components/security/methods'; -import {CODE_200} from "./constants"; +import {CODE_200, MILLISECONDS_IN_SECOND} from "./constants"; export const GENERIC_ERROR = "Yikes. Something seems to be broken. Our web team has been notified, and we apologize for the inconvenience."; export const RESET_LOADING = 'RESET_LOADING'; @@ -62,8 +62,26 @@ const notifySnackbarError = (message, callback) => (dispatch) => { if (typeof callback === "function") callback(); }; +// copyable context for bug reports; query strings are dropped since they carry the access_token +const buildErrorDetails = (err) => { + const req = err.response?.req || err; + const url = req.url ? req.url.split("?")[0] : ""; + const status = err.status || (navigator.onLine ? "no response" : "no response, offline"); + // field names only; the response body can hold debug output + const fields = err.status === 412 ? Object.keys(err.response?.body?.errors || {}).filter((key) => isNaN(key)) : []; + const now = new Date(); + return [ + `Request: ${[req.method, url].filter(Boolean).join(" ")} (${status})`, + fields.length ? `Fields: ${fields.join(", ")}` : "", + `Page: ${window.location.pathname}`, + `Time: ${now.toISOString()} (${formatEpoch(now.getTime() / MILLISECONDS_IN_SECOND, "M/D/YYYY h:mm:ss a [UTC]Z")})` + ].filter(Boolean).join("\n"); +}; + export const snackbarErrorHandler = (err, res) => (dispatch, state) => { - authErrorHandler(err, res, notifySnackbarError)(dispatch, state); + const details = buildErrorDetails(err); + const notifyWithDetails = (message, callback) => notifySnackbarError({ ...message, details }, callback); + authErrorHandler(err, res, notifyWithDetails)(dispatch, state); }; export const snackbarSuccessHandler = (message) => (dispatch, state) => From 15d635913c9a293769b39bf8c03d87bf5fa4442f Mon Sep 17 00:00:00 2001 From: anna Date: Tue, 27 Oct 2026 18:12:33 -0500 Subject: [PATCH 02/15] fix: guard against a failed copy or detail build --- .../mui/SnackbarNotification/index.js | 32 ++++++++++++----- .../__tests__/snackbar-notification.test.js | 35 +++++++++++++++++++ src/i18n/en.json | 1 + src/utils/__tests__/actions.test.js | 19 ++++++++++ src/utils/actions.js | 8 ++++- 5 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index ffb68fda..e97df9e2 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -125,16 +125,29 @@ const NotificationSnackbar = ({ queue, vertical }) => { queue.onExited(); }; - const [copied, setCopied] = useState(false); + // null, "copied" or "failed" + const [copyStatus, setCopyStatus] = useState(null); const handleCopy = () => { const html = String(msgData.html).replace(//gi, "\n"); const text = new DOMParser().parseFromString(html, "text/html").body .textContent; - navigator.clipboard.writeText( - [text.trim(), msgData.details].filter(Boolean).join("\n\n") - ); - setCopied(true); - setTimeout(() => setCopied(false), MILLISECONDS_IN_SECOND); + const finish = (status) => { + setCopyStatus(status); + setTimeout(() => setCopyStatus(null), MILLISECONDS_IN_SECOND); + }; + // the clipboard can be missing (non-https page) or blocked by the browser + try { + Promise.resolve( + navigator.clipboard.writeText( + [text.trim(), msgData.details].filter(Boolean).join("\n\n") + ) + ).then( + () => finish("copied"), + () => finish("failed") + ); + } catch (e) { + finish("failed"); + } }; return ( @@ -153,7 +166,10 @@ const NotificationSnackbar = ({ queue, vertical }) => { <> { aria-label={T.translate("general.copy_error")} onClick={handleCopy} > - {copied ? ( + {copyStatus === "copied" ? ( ) : ( diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index a4d1da63..591959bf 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -101,6 +101,41 @@ describe("SnackbarNotification", () => { ); }); + test("shows the check mark only after the clipboard accepts the text", async () => { + Object.assign(navigator, { clipboard: { writeText: jest.fn().mockResolvedValue() } }); + render( + +
App
+
+ ); + + fireEvent.click(screen.getByLabelText("general.copy_error")); + + expect(await screen.findByTestId("CheckIcon")).toBeInTheDocument(); + }); + + test("says the copy failed when the clipboard rejects it", async () => { + Object.assign(navigator, { clipboard: { writeText: jest.fn().mockRejectedValue(new Error("blocked")) } }); + render( + +
App
+
+ ); + + const button = screen.getByLabelText("general.copy_error"); + fireEvent.click(button); + fireEvent.mouseOver(button); + + expect(await screen.findByText("general.copy_failed")).toBeInTheDocument(); + expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument(); + }); + test("keeps only the close button on a success message", () => { render( { expect(payload.details).not.toContain("required"); }); + test('still notifies and re-logs in when the details cannot be built', () => { + windowSpy.mockImplementation(() => ({ + location: { + href: 'https://example.com', + pathname:'/', + replace: () => {} + }, + localStorage: localStorageMock + })); + const err = { status: 401, get response() { throw new Error("unreadable"); } }; + + store.dispatch(snackbarErrorHandler(err)); + + const actions = store.getActions(); + const { payload } = actions.find((a) => a.type === SET_SNACKBAR_MESSAGE); + expect(payload.details).toBe(""); + expect(actions.map((a) => a.type)).toContain(CLEAR_SESSION_STATE); + }); + test('flags a missing response when the browser is offline', () => { const onLineSpy = jest.spyOn(navigator, "onLine", "get").mockReturnValue(false); diff --git a/src/utils/actions.js b/src/utils/actions.js index 65248aa8..99169f84 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -79,7 +79,13 @@ const buildErrorDetails = (err) => { }; export const snackbarErrorHandler = (err, res) => (dispatch, state) => { - const details = buildErrorDetails(err); + let details = ""; + // details are optional; a failure here must not stop the snackbar or the 401 re-login + try { + details = buildErrorDetails(err); + } catch (e) { + details = ""; + } const notifyWithDetails = (message, callback) => notifySnackbarError({ ...message, details }, callback); authErrorHandler(err, res, notifyWithDetails)(dispatch, state); }; From 98d0fac3d2ff416ec3f95e0ee02a5d7089a529bd Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 28 Oct 2026 16:12:31 -0500 Subject: [PATCH 03/15] feat: add response and request payloads in error copying --- src/utils/__tests__/actions.test.js | 31 ++++++++++++++++++++++++----- src/utils/actions.js | 18 +++++++++++++---- src/utils/constants.js | 1 + 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/utils/__tests__/actions.test.js b/src/utils/__tests__/actions.test.js index e6a20c14..f20b2398 100644 --- a/src/utils/__tests__/actions.test.js +++ b/src/utils/__tests__/actions.test.js @@ -225,14 +225,35 @@ describe('Utils Actions', () => { expect(payload.details).not.toContain("secret"); }); - test('lists 412 field names without their values', () => { - const errors = { start_date: ["The start date field is required."], end_date: ["The end date is invalid."] }; + test('copies the request and 4xx response bodies with sensitive fields redacted', () => { + const req = request.put("https://api.example.com/api/v1/summits/1/attendees/2").send({ + ticket_type_id: 7, + send_speaker_email: true, + owner: { email: "jane@example.com", first_name: "Jane", phone_number: "555-0100", billing_address_1: "1 Main St" }, + payment: { payment_info_details: { brand: "visa", last4: "4242" }, live_secret_key: "sk_live_123" }, + access_token: "abc" + }); + const body = { message: "Validation Failed", errors: { start_date: ["The start date field is required."] }, user_password: "password2" }; + + store.dispatch(snackbarErrorHandler({ status: 412, response: { body, req } })); + + const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE); + expect(payload.details).toContain('"ticket_type_id": 7'); + expect(payload.details).toContain('"send_speaker_email": true'); + expect(payload.details).toContain("The start date field is required."); + ["jane@example.com", "Jane", "555-0100", "1 Main St", "visa", "4242", "sk_live_123", '"abc"', "password2"] + .forEach((value) => expect(payload.details).not.toContain(value)); + }); + + test('skips 5xx response bodies and truncates long payloads', () => { + const req = request.post("https://api.example.com/api/v1/summits").send({ notes: "x".repeat(5000) }); - store.dispatch(snackbarErrorHandler({ status: 412, response: { body: { errors }, req: {} } })); + store.dispatch(snackbarErrorHandler({ status: 500, response: { body: { trace: "debug output" }, req } })); const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE); - expect(payload.details).toContain("Fields: start_date, end_date"); - expect(payload.details).not.toContain("required"); + expect(payload.details).toContain("… (truncated)"); + expect(payload.details).not.toContain("x".repeat(2001)); + expect(payload.details).not.toContain("debug output"); }); test('still notifies and re-logs in when the details cannot be built', () => { diff --git a/src/utils/actions.js b/src/utils/actions.js index 99169f84..0231ead3 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -21,7 +21,7 @@ import T from "i18n-react/dist/i18n-react"; import { isClearingSessionState, setSessionClearingState, getCurrentPathName, formatEpoch } from './methods'; import { CLEAR_SESSION_STATE } from '../components/security/actions'; import { doLogin, initLogOut, getAuthHandlers } from '../components/security/methods'; -import {CODE_200, MILLISECONDS_IN_SECOND} from "./constants"; +import {CODE_200, ERROR_DETAILS_MAX_LENGTH, MILLISECONDS_IN_SECOND} from "./constants"; export const GENERIC_ERROR = "Yikes. Something seems to be broken. Our web team has been notified, and we apologize for the inconvenience."; export const RESET_LOADING = 'RESET_LOADING'; @@ -62,17 +62,27 @@ const notifySnackbarError = (message, callback) => (dispatch) => { if (typeof callback === "function") callback(); }; +// secrets, payment details and personal information are never copied +const SENSITIVE_KEYS = /^(.*_key|.*(token|secret|password).*|hash|.*emails?|.*phone.*|.*(first|last)_name|.*full_?names?|surname|(.*_)?owner_name|requestor_name|(.*_)?address(\d|_.*)?|zip_code|gender|ip|payment_info_details|payment_gateway_result)$/i; + +const formatPayload = (label, data) => { + if (data == null || (typeof data === "object" && !Object.keys(data).length)) return ""; + const json = JSON.stringify(data, (key, value) => (typeof value !== "boolean" && SENSITIVE_KEYS.test(key) ? "[REDACTED]" : value), 2); + return `${label}: ${json.length > ERROR_DETAILS_MAX_LENGTH ? `${json.slice(0, ERROR_DETAILS_MAX_LENGTH)}… (truncated)` : json}`; +}; + // copyable context for bug reports; query strings are dropped since they carry the access_token const buildErrorDetails = (err) => { const req = err.response?.req || err; const url = req.url ? req.url.split("?")[0] : ""; const status = err.status || (navigator.onLine ? "no response" : "no response, offline"); - // field names only; the response body can hold debug output - const fields = err.status === 412 ? Object.keys(err.response?.body?.errors || {}).filter((key) => isNaN(key)) : []; + // 5xx bodies can hold debug output, so only 4xx responses are copied + const isClientError = err.status >= 400 && err.status < 500; const now = new Date(); return [ `Request: ${[req.method, url].filter(Boolean).join(" ")} (${status})`, - fields.length ? `Fields: ${fields.join(", ")}` : "", + formatPayload("Request body", req.toJSON?.().data), + isClientError ? formatPayload("Response body", err.response?.body) : "", `Page: ${window.location.pathname}`, `Time: ${now.toISOString()} (${formatEpoch(now.getTime() / MILLISECONDS_IN_SECOND, "M/D/YYYY h:mm:ss a [UTC]Z")})` ].filter(Boolean).join("\n"); diff --git a/src/utils/constants.js b/src/utils/constants.js index 050d30df..af8e33b9 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -12,6 +12,7 @@ export const DEBOUNCE_WAIT = 500; export const NOTIFICATION_TIMEOUT = 6000; export const NOTIFICATION_DEDUPE_WINDOW = 1000; +export const ERROR_DETAILS_MAX_LENGTH = 2000; export const ASYNC_SELECT_SAFETY_TIMEOUT = 15000; export const DEFAULT_PER_PAGE = 10; export const TWENTY_PER_PAGE = 20; From e4ed7efe7f3605015f367fdf9c47837b9e6695d8 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 28 Oct 2026 16:41:53 -0500 Subject: [PATCH 04/15] fix: handle requests that send a uri not strings, handle timeout errors --- src/utils/__tests__/actions.test.js | 21 +++++++++++++++++++++ src/utils/actions.js | 8 +++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/actions.test.js b/src/utils/__tests__/actions.test.js index f20b2398..ec177253 100644 --- a/src/utils/__tests__/actions.test.js +++ b/src/utils/__tests__/actions.test.js @@ -284,6 +284,27 @@ describe('Utils Actions', () => { expect(payload.details).toContain("Request: GET https://api.example.com/api/v1/summits (no response, offline)"); onLineSpy.mockRestore(); }); + + test('builds details for a post request made with a URI object', () => { + // postRequest, deleteRequest, postFile and putFile hand superagent a URI, not a string + const req = request.post(URI("https://api.example.com/api/v1/summits").query({ access_token: "secret" })).send({ name: "Summit" }); + + store.dispatch(snackbarErrorHandler({ status: 412, response: { body: { errors: ["Name taken."] }, req } })); + + const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE); + expect(payload.details).toContain("Request: POST https://api.example.com/api/v1/summits (412)"); + expect(payload.details).toContain('"name": "Summit"'); + expect(payload.details).not.toContain("secret"); + }); + + test('says the request timed out', () => { + const err = Object.assign(new Error("Timeout of 60000ms exceeded"), { timeout: 60000, code: "ECONNABORTED" }); + + store.dispatch(snackbarErrorHandler(err)); + + const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE); + expect(payload.details).toContain("Request: (timed out)"); + }); }); describe('clearConsumedSnackbarMessage', () => { diff --git a/src/utils/actions.js b/src/utils/actions.js index 0231ead3..e8e1c782 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -74,13 +74,15 @@ const formatPayload = (label, data) => { // copyable context for bug reports; query strings are dropped since they carry the access_token const buildErrorDetails = (err) => { const req = err.response?.req || err; - const url = req.url ? req.url.split("?")[0] : ""; - const status = err.status || (navigator.onLine ? "no response" : "no response, offline"); + // post, delete and file requests keep the URI object superagent was given + const url = req.url ? String(req.url).split("?")[0] : ""; + // timeouts carry no method or url, only the timeout + const status = err.status || (err.timeout && "timed out") || (navigator.onLine ? "no response" : "no response, offline"); // 5xx bodies can hold debug output, so only 4xx responses are copied const isClientError = err.status >= 400 && err.status < 500; const now = new Date(); return [ - `Request: ${[req.method, url].filter(Boolean).join(" ")} (${status})`, + `Request: ${[req.method, url, `(${status})`].filter(Boolean).join(" ")}`, formatPayload("Request body", req.toJSON?.().data), isClientError ? formatPayload("Response body", err.response?.body) : "", `Page: ${window.location.pathname}`, From db9a963e95e5399df884090f82fb05171d62e1bc Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 28 Oct 2026 17:22:04 -0500 Subject: [PATCH 05/15] fix: reset copy status on new message instead of a timer, announce it to screen readers --- .../mui/SnackbarNotification/index.js | 27 +++++++-------- .../__tests__/snackbar-notification.test.js | 34 +++++++++++++++++-- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index e97df9e2..8b2f8a1a 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -125,31 +125,29 @@ const NotificationSnackbar = ({ queue, vertical }) => { queue.onExited(); }; - // null, "copied" or "failed" + // null, "copied" or "failed"; reset when a new message shows const [copyStatus, setCopyStatus] = useState(null); const handleCopy = () => { const html = String(msgData.html).replace(//gi, "\n"); const text = new DOMParser().parseFromString(html, "text/html").body .textContent; - const finish = (status) => { - setCopyStatus(status); - setTimeout(() => setCopyStatus(null), MILLISECONDS_IN_SECOND); - }; // the clipboard can be missing (non-https page) or blocked by the browser try { - Promise.resolve( - navigator.clipboard.writeText( - [text.trim(), msgData.details].filter(Boolean).join("\n\n") - ) - ).then( - () => finish("copied"), - () => finish("failed") - ); + navigator.clipboard + .writeText([text.trim(), msgData.details].filter(Boolean).join("\n\n")) + .then( + () => setCopyStatus("copied"), + () => setCopyStatus("failed") + ); } catch (e) { - finish("failed"); + setCopyStatus("failed"); } }; + useEffect(() => { + setCopyStatus(null); + }, [queue.current]); + return ( { {copyStatus === "copied" ? ( diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 591959bf..03401284 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -78,8 +78,8 @@ describe("SnackbarNotification", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); - test("copies an error as plain text with its details", () => { - const writeText = jest.fn(); + test("copies an error as plain text with its details", async () => { + const writeText = jest.fn().mockResolvedValue(); Object.assign(navigator, { clipboard: { writeText } }); render( { 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 only after the clipboard accepts the text", async () => { @@ -136,6 +138,34 @@ describe("SnackbarNotification", () => { expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument(); }); + test("clears the copied state when a new message with the same text shows", async () => { + Object.assign(navigator, { clipboard: { writeText: jest.fn().mockResolvedValue() } }); + const { rerender } = render( + +
App
+
+ ); + 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( + +
App
+
+ ); + + expect(await screen.findByLabelText("general.copy_error")).toBeInTheDocument(); + expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument(); + }); + test("keeps only the close button on a success message", () => { render( Date: Tue, 3 Nov 2026 17:21:16 -0600 Subject: [PATCH 06/15] fix: don't copy string request bodies since sensitive fields can't be redacted --- src/utils/actions.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/actions.js b/src/utils/actions.js index e8e1c782..093ad79f 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -66,7 +66,8 @@ const notifySnackbarError = (message, callback) => (dispatch) => { const SENSITIVE_KEYS = /^(.*_key|.*(token|secret|password).*|hash|.*emails?|.*phone.*|.*(first|last)_name|.*full_?names?|surname|(.*_)?owner_name|requestor_name|(.*_)?address(\d|_.*)?|zip_code|gender|ip|payment_info_details|payment_gateway_result)$/i; const formatPayload = (label, data) => { - if (data == null || (typeof data === "object" && !Object.keys(data).length)) return ""; + // strings can't be redacted by key, so only objects are copied + if (data == null || typeof data !== "object" || !Object.keys(data).length) return ""; const json = JSON.stringify(data, (key, value) => (typeof value !== "boolean" && SENSITIVE_KEYS.test(key) ? "[REDACTED]" : value), 2); return `${label}: ${json.length > ERROR_DETAILS_MAX_LENGTH ? `${json.slice(0, ERROR_DETAILS_MAX_LENGTH)}… (truncated)` : json}`; }; From 6813ef2db1cce3d5ce66a7e18e0b362d27acf839 Mon Sep 17 00:00:00 2001 From: anna Date: Tue, 3 Nov 2026 17:24:28 -0600 Subject: [PATCH 07/15] fix: clean up tests and make label syntax consistent --- src/components/mui/SnackbarNotification/index.js | 2 +- .../mui/__tests__/snackbar-notification.test.js | 9 ++++++++- src/i18n/en.json | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 8b2f8a1a..8eb4c437 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -185,7 +185,7 @@ const NotificationSnackbar = ({ queue, vertical }) => { diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 03401284..48c6bace 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -32,6 +32,10 @@ import { } from "../../../utils/constants"; describe("SnackbarNotification", () => { + afterEach(() => { + delete navigator.clipboard; + }); + test("renders children", () => { render( @@ -104,7 +108,8 @@ describe("SnackbarNotification", () => { }); test("shows the check mark only after the clipboard accepts the text", async () => { - Object.assign(navigator, { clipboard: { writeText: jest.fn().mockResolvedValue() } }); + let accept; + Object.assign(navigator, { clipboard: { writeText: jest.fn(() => new Promise((resolve) => { accept = resolve; })) } }); render( { ); fireEvent.click(screen.getByLabelText("general.copy_error")); + expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument(); + accept(); expect(await screen.findByTestId("CheckIcon")).toBeInTheDocument(); }); diff --git a/src/i18n/en.json b/src/i18n/en.json index d18a81b7..6b91c28e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -13,6 +13,7 @@ "copy_error": "Copy error details", "copied": "Copied", "copy_failed": "Couldn't copy", + "close": "Close", "add": "Add", "add_new": "Add New", "clear": "Clear", From 4df5f2eea8763a0e3da2ae5220f40af7003c1f20 Mon Sep 17 00:00:00 2001 From: ako3131 <58839275+ako3131@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:41:28 -0600 Subject: [PATCH 08/15] Refactor copy status handling in SnackbarNotification Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/components/mui/SnackbarNotification/index.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 8eb4c437..156b4164 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -125,8 +125,9 @@ const NotificationSnackbar = ({ queue, vertical }) => { queue.onExited(); }; - // null, "copied" or "failed"; reset when a new message shows - const [copyStatus, setCopyStatus] = useState(null); + // Ignore clipboard results belonging to an earlier message. + const [copyResult, setCopyResult] = useState(null); + const copyStatus = copyResult?.message === msgData ? copyResult.status : null; const handleCopy = () => { const html = String(msgData.html).replace(//gi, "\n"); const text = new DOMParser().parseFromString(html, "text/html").body @@ -136,16 +137,16 @@ const NotificationSnackbar = ({ queue, vertical }) => { navigator.clipboard .writeText([text.trim(), msgData.details].filter(Boolean).join("\n\n")) .then( - () => setCopyStatus("copied"), - () => setCopyStatus("failed") + () => setCopyResult({ message: msgData, status: "copied" }), + () => setCopyResult({ message: msgData, status: "failed" }) ); } catch (e) { - setCopyStatus("failed"); + setCopyResult({ message: msgData, status: "failed" }); } }; useEffect(() => { - setCopyStatus(null); + setCopyResult(null); }, [queue.current]); return ( From 5b8c3345affd79f64d7c0f0095fe6786f2207fb8 Mon Sep 17 00:00:00 2001 From: anna Date: Tue, 10 Nov 2026 19:48:07 -0600 Subject: [PATCH 09/15] fix: allow multiple error messages to be copied on a group error snackbar --- .../mui/SnackbarNotification/index.js | 56 +++++--- .../__tests__/snackbar-notification.test.js | 124 +++++++++--------- src/i18n/en.json | 1 + src/utils/__tests__/actions.test.js | 5 +- src/utils/actions.js | 7 +- 5 files changed, 110 insertions(+), 83 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 156b4164..6eda55c7 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -25,6 +25,7 @@ import { 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"; @@ -49,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(//gi, "\n"); + const text = new DOMParser().parseFromString(html, "text/html").body + .textContent; + return [text.trim(), msg.details].filter(Boolean).join("\n\n"); +}; + // a queue shows its first message, a group shows all of them at once const useMessages = ({ grouped = false } = {}) => { const [messages, setMessages] = useState([]); @@ -125,23 +133,35 @@ const NotificationSnackbar = ({ queue, vertical }) => { queue.onExited(); }; - // Ignore clipboard results belonging to an earlier message. + // clipboard results for an earlier message are ignored const [copyResult, setCopyResult] = useState(null); const copyStatus = copyResult?.message === msgData ? copyResult.status : null; + const copyStatusKey = { + copied: "general.copied", + failed: "general.copy_failed" + }[copyStatus]; + const copyLabel = + count > 1 ? "general.copy_all_errors" : "general.copy_error"; const handleCopy = () => { - const html = String(msgData.html).replace(//gi, "\n"); - const text = new DOMParser().parseFromString(html, "text/html").body - .textContent; + const finish = (status) => 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); // the clipboard can be missing (non-https page) or blocked by the browser try { - navigator.clipboard - .writeText([text.trim(), msgData.details].filter(Boolean).join("\n\n")) - .then( - () => setCopyResult({ message: msgData, status: "copied" }), - () => setCopyResult({ message: msgData, status: "failed" }) - ); + navigator.clipboard.writeText(copyText).then( + () => finish("copied"), + () => finish("failed") + ); } catch (e) { - setCopyResult({ message: msgData, status: "failed" }); + finish("failed"); } }; @@ -163,17 +183,11 @@ const NotificationSnackbar = ({ queue, vertical }) => { action={ isSuccess ? undefined : ( <> - + {copyStatus === "copied" ? ( @@ -183,6 +197,10 @@ const NotificationSnackbar = ({ queue, vertical }) => { )} + {/* a live region, since screen readers skip the tooltip change */} + + {copyStatusKey && T.translate(copyStatusKey)} + { + let clearSnackbarMessage; + + const ui = (snackbarMessage) => ( + +
App
+
+ ); + + const mockClipboard = (writeText) => + Object.assign(navigator, { clipboard: { writeText } }); + + beforeEach(() => { + clearSnackbarMessage = jest.fn(); + }); + afterEach(() => { delete navigator.clipboard; }); @@ -84,18 +102,13 @@ describe("SnackbarNotification", () => { test("copies an error as plain text with its details", async () => { const writeText = jest.fn().mockResolvedValue(); - Object.assign(navigator, { clipboard: { writeText } }); + mockClipboard(writeText); render( - End date is invalid.
", - type: "warning", - details: "Request: PUT /api/v1/summits/1 (412)" - }} - clearSnackbarMessage={jest.fn()} - > -
App
-
+ ui({ + html: "Start date is required.
End date is invalid.
", + type: "warning", + details: "Request: PUT /api/v1/summits/1 (412)" + }) ); fireEvent.click(screen.getByLabelText("general.copy_error")); @@ -107,81 +120,56 @@ describe("SnackbarNotification", () => { await screen.findByTestId("CheckIcon"); }); - test("shows the check mark only after the clipboard accepts the text", async () => { + test("shows the check mark and announces Copied only after the clipboard accepts the text", async () => { let accept; - Object.assign(navigator, { clipboard: { writeText: jest.fn(() => new Promise((resolve) => { accept = resolve; })) } }); - render( - -
App
-
+ 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 () => { - Object.assign(navigator, { clipboard: { writeText: jest.fn().mockRejectedValue(new Error("blocked")) } }); - render( - -
App
-
- ); + mockClipboard(jest.fn().mockRejectedValue(new Error("blocked"))); + render(ui({ html: "Not found", type: "warning" })); - const button = screen.getByLabelText("general.copy_error"); - fireEvent.click(button); - fireEvent.mouseOver(button); + fireEvent.click(screen.getByLabelText("general.copy_error")); - expect(await screen.findByText("general.copy_failed")).toBeInTheDocument(); + expect(await screen.findByText("general.copy_failed")).toHaveAttribute( + "role", + "status" + ); expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument(); }); test("clears the copied state when a new message with the same text shows", async () => { - Object.assign(navigator, { clipboard: { writeText: jest.fn().mockResolvedValue() } }); - const { rerender } = render( - -
App
-
- ); + 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( - -
App
-
- ); + rerender(ui({ html: "Not found", type: "warning" })); - expect(await screen.findByLabelText("general.copy_error")).toBeInTheDocument(); + expect(await screen.findByTestId("ContentCopyIcon")).toBeInTheDocument(); expect(screen.queryByTestId("CheckIcon")).not.toBeInTheDocument(); }); test("keeps only the close button on a success message", () => { - render( - -
App
-
- ); + render(ui({ html: "Saved", type: "success" })); expect(screen.queryByLabelText("general.copy_error")).not.toBeInTheDocument(); expect(screen.getByLabelText("Close")).toBeInTheDocument(); @@ -359,6 +347,24 @@ 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("ignores a repeat within the dedupe window", () => { const { rerender } = render(ui(errorA)); rerender(ui({ ...errorA })); diff --git a/src/i18n/en.json b/src/i18n/en.json index 6b91c28e..c8024ef1 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -11,6 +11,7 @@ "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", diff --git a/src/utils/__tests__/actions.test.js b/src/utils/__tests__/actions.test.js index ec177253..4f06125b 100644 --- a/src/utils/__tests__/actions.test.js +++ b/src/utils/__tests__/actions.test.js @@ -231,6 +231,7 @@ describe('Utils Actions', () => { send_speaker_email: true, owner: { email: "jane@example.com", first_name: "Jane", phone_number: "555-0100", billing_address_1: "1 Main St" }, payment: { payment_info_details: { brand: "visa", last4: "4242" }, live_secret_key: "sk_live_123" }, + extra_questions: [{ question_id: 3, answer: "1990-01-01" }], access_token: "abc" }); const body = { message: "Validation Failed", errors: { start_date: ["The start date field is required."] }, user_password: "password2" }; @@ -241,7 +242,7 @@ describe('Utils Actions', () => { expect(payload.details).toContain('"ticket_type_id": 7'); expect(payload.details).toContain('"send_speaker_email": true'); expect(payload.details).toContain("The start date field is required."); - ["jane@example.com", "Jane", "555-0100", "1 Main St", "visa", "4242", "sk_live_123", '"abc"', "password2"] + ["jane@example.com", "Jane", "555-0100", "1 Main St", "visa", "4242", "sk_live_123", "1990-01-01", '"abc"', "password2"] .forEach((value) => expect(payload.details).not.toContain(value)); }); @@ -279,10 +280,10 @@ describe('Utils Actions', () => { const onLineSpy = jest.spyOn(navigator, "onLine", "get").mockReturnValue(false); store.dispatch(snackbarErrorHandler({ method: "GET", url: "https://api.example.com/api/v1/summits" })); + onLineSpy.mockRestore(); const { payload } = store.getActions().find((a) => a.type === SET_SNACKBAR_MESSAGE); expect(payload.details).toContain("Request: GET https://api.example.com/api/v1/summits (no response, offline)"); - onLineSpy.mockRestore(); }); test('builds details for a post request made with a URI object', () => { diff --git a/src/utils/actions.js b/src/utils/actions.js index 093ad79f..60f030a1 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -63,13 +63,14 @@ const notifySnackbarError = (message, callback) => (dispatch) => { }; // secrets, payment details and personal information are never copied -const SENSITIVE_KEYS = /^(.*_key|.*(token|secret|password).*|hash|.*emails?|.*phone.*|.*(first|last)_name|.*full_?names?|surname|(.*_)?owner_name|requestor_name|(.*_)?address(\d|_.*)?|zip_code|gender|ip|payment_info_details|payment_gateway_result)$/i; +const SENSITIVE_KEYS = /^(.*_key|.*(token|secret|password).*|hash|.*emails?|.*phone.*|.*(first|last)_name|.*full_?names?|surname|(.*_)?owner_name|requestor_name|(.*_)?address(\d|_.*)?|zip_code|gender|ip|payment_info_details|payment_gateway_result|answers?)$/i; const formatPayload = (label, data) => { // strings can't be redacted by key, so only objects are copied if (data == null || typeof data !== "object" || !Object.keys(data).length) return ""; const json = JSON.stringify(data, (key, value) => (typeof value !== "boolean" && SENSITIVE_KEYS.test(key) ? "[REDACTED]" : value), 2); - return `${label}: ${json.length > ERROR_DETAILS_MAX_LENGTH ? `${json.slice(0, ERROR_DETAILS_MAX_LENGTH)}… (truncated)` : json}`; + const text = json.length > ERROR_DETAILS_MAX_LENGTH ? `${json.slice(0, ERROR_DETAILS_MAX_LENGTH)}… (truncated)` : json; + return `${label}: ${text}`; }; // copyable context for bug reports; query strings are dropped since they carry the access_token @@ -92,7 +93,7 @@ const buildErrorDetails = (err) => { }; export const snackbarErrorHandler = (err, res) => (dispatch, state) => { - let details = ""; + let details; // details are optional; a failure here must not stop the snackbar or the 401 re-login try { details = buildErrorDetails(err); From 4a832c283d2536a927e7176a0242d6f19963a25f Mon Sep 17 00:00:00 2001 From: anna Date: Tue, 10 Nov 2026 19:55:11 -0600 Subject: [PATCH 10/15] fix: add contact_name to sensitive keys and remove unneccesary code --- src/components/mui/SnackbarNotification/index.js | 4 ---- src/utils/__tests__/actions.test.js | 3 ++- src/utils/actions.js | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 6eda55c7..865442f5 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -165,10 +165,6 @@ const NotificationSnackbar = ({ queue, vertical }) => { } }; - useEffect(() => { - setCopyResult(null); - }, [queue.current]); - return ( { owner: { email: "jane@example.com", first_name: "Jane", phone_number: "555-0100", billing_address_1: "1 Main St" }, payment: { payment_info_details: { brand: "visa", last4: "4242" }, live_secret_key: "sk_live_123" }, extra_questions: [{ question_id: 3, answer: "1990-01-01" }], + client: { contact_name: "Ann Lee" }, access_token: "abc" }); const body = { message: "Validation Failed", errors: { start_date: ["The start date field is required."] }, user_password: "password2" }; @@ -242,7 +243,7 @@ describe('Utils Actions', () => { expect(payload.details).toContain('"ticket_type_id": 7'); expect(payload.details).toContain('"send_speaker_email": true'); expect(payload.details).toContain("The start date field is required."); - ["jane@example.com", "Jane", "555-0100", "1 Main St", "visa", "4242", "sk_live_123", "1990-01-01", '"abc"', "password2"] + ["jane@example.com", "Jane", "555-0100", "1 Main St", "visa", "4242", "sk_live_123", "1990-01-01", "Ann Lee", '"abc"', "password2"] .forEach((value) => expect(payload.details).not.toContain(value)); }); diff --git a/src/utils/actions.js b/src/utils/actions.js index 60f030a1..3b4b02bf 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -63,7 +63,7 @@ const notifySnackbarError = (message, callback) => (dispatch) => { }; // secrets, payment details and personal information are never copied -const SENSITIVE_KEYS = /^(.*_key|.*(token|secret|password).*|hash|.*emails?|.*phone.*|.*(first|last)_name|.*full_?names?|surname|(.*_)?owner_name|requestor_name|(.*_)?address(\d|_.*)?|zip_code|gender|ip|payment_info_details|payment_gateway_result|answers?)$/i; +const SENSITIVE_KEYS = /^(.*_key|.*(token|secret|password).*|hash|.*emails?|.*phone.*|.*(first|last)_name|.*full_?names?|surname|(.*_)?owner_name|requestor_name|contact_name|(.*_)?address(\d|_.*)?|zip_code|gender|ip|payment_info_details|payment_gateway_result|answers?)$/i; const formatPayload = (label, data) => { // strings can't be redacted by key, so only objects are copied From 47b4ee25cb69560f731b6f359afcac451dcf5e6e Mon Sep 17 00:00:00 2001 From: anna Date: Tue, 10 Nov 2026 20:12:10 -0600 Subject: [PATCH 11/15] fix: prevent stale clipboard results from overwriting current copy button feedback --- .../mui/SnackbarNotification/index.js | 9 ++++++- .../__tests__/snackbar-notification.test.js | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 865442f5..152a4ded 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -136,6 +136,11 @@ const NotificationSnackbar = ({ queue, vertical }) => { // 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" @@ -143,7 +148,9 @@ const NotificationSnackbar = ({ queue, vertical }) => { const copyLabel = count > 1 ? "general.copy_all_errors" : "general.copy_error"; const handleCopy = () => { - const finish = (status) => setCopyResult({ message: msgData, status }); + // 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 diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index ad706510..24cff94b 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -154,6 +154,30 @@ describe("SnackbarNotification", () => { 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" })); From f9be6a7bebaf135a3b8619f0f3657b82b3e88c57 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 11 Nov 2026 12:37:22 -0600 Subject: [PATCH 12/15] fix: add a test for when there is no clipboard --- .../mui/__tests__/snackbar-notification.test.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 24cff94b..d8cee863 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -154,6 +154,15 @@ describe("SnackbarNotification", () => { 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( From 066366483fb056b8164373e47f9f2502ff579d23 Mon Sep 17 00:00:00 2001 From: anna Date: Wed, 11 Nov 2026 12:43:17 -0600 Subject: [PATCH 13/15] fix: add qr_code to blocked keys --- src/utils/__tests__/actions.test.js | 3 ++- src/utils/actions.js | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/actions.test.js b/src/utils/__tests__/actions.test.js index 7fc12bb3..535b5b1b 100644 --- a/src/utils/__tests__/actions.test.js +++ b/src/utils/__tests__/actions.test.js @@ -233,6 +233,7 @@ describe('Utils Actions', () => { payment: { payment_info_details: { brand: "visa", last4: "4242" }, live_secret_key: "sk_live_123" }, extra_questions: [{ question_id: 3, answer: "1990-01-01" }], client: { contact_name: "Ann Lee" }, + qr_code: "BDG|123|jane@example.com|Jane Doe", access_token: "abc" }); const body = { message: "Validation Failed", errors: { start_date: ["The start date field is required."] }, user_password: "password2" }; @@ -243,7 +244,7 @@ describe('Utils Actions', () => { expect(payload.details).toContain('"ticket_type_id": 7'); expect(payload.details).toContain('"send_speaker_email": true'); expect(payload.details).toContain("The start date field is required."); - ["jane@example.com", "Jane", "555-0100", "1 Main St", "visa", "4242", "sk_live_123", "1990-01-01", "Ann Lee", '"abc"', "password2"] + ["jane@example.com", "Jane", "555-0100", "1 Main St", "visa", "4242", "sk_live_123", "1990-01-01", "Ann Lee", "BDG|123", '"abc"', "password2"] .forEach((value) => expect(payload.details).not.toContain(value)); }); diff --git a/src/utils/actions.js b/src/utils/actions.js index 3b4b02bf..f2f5a1f2 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -62,8 +62,8 @@ const notifySnackbarError = (message, callback) => (dispatch) => { if (typeof callback === "function") callback(); }; -// secrets, payment details and personal information are never copied -const SENSITIVE_KEYS = /^(.*_key|.*(token|secret|password).*|hash|.*emails?|.*phone.*|.*(first|last)_name|.*full_?names?|surname|(.*_)?owner_name|requestor_name|contact_name|(.*_)?address(\d|_.*)?|zip_code|gender|ip|payment_info_details|payment_gateway_result|answers?)$/i; +// best-effort redaction by key name of secrets, payment details and personal information +const SENSITIVE_KEYS = /^(.*_key|.*(token|secret|password).*|hash|.*emails?|.*phone.*|.*(first|last)_name|.*full_?names?|surname|(.*_)?owner_name|requestor_name|contact_name|qr_code|(.*_)?address(\d|_.*)?|zip_code|gender|ip|payment_info_details|payment_gateway_result|answers?)$/i; const formatPayload = (label, data) => { // strings can't be redacted by key, so only objects are copied From 85958a2a08c9a84b9bf4a62fced2f355dbe9f2f4 Mon Sep 17 00:00:00 2001 From: anna Date: Fri, 13 Nov 2026 14:27:58 -0600 Subject: [PATCH 14/15] fix: adjust tests after rebase --- .../mui/__tests__/snackbar-notification.test.js | 17 +++-------------- src/utils/__tests__/actions.test.js | 2 ++ 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index d8cee863..4345460b 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -32,12 +32,12 @@ import { } from "../../../utils/constants"; describe("SnackbarNotification", () => { - let clearSnackbarMessage; + let clearConsumedSnackbarMessage; const ui = (snackbarMessage) => (
App
@@ -47,7 +47,7 @@ describe("SnackbarNotification", () => { Object.assign(navigator, { clipboard: { writeText } }); beforeEach(() => { - clearSnackbarMessage = jest.fn(); + clearConsumedSnackbarMessage = jest.fn(); }); afterEach(() => { @@ -211,16 +211,6 @@ describe("SnackbarNotification", () => { describe("pinned and grouped errors", () => { const errorA = { html: "First error", type: "warning" }; const errorB = { html: "Second error", type: "error" }; - let clearConsumedSnackbarMessage; - - const ui = (snackbarMessage) => ( - -
App
-
- ); const advance = (ms) => act(() => { @@ -239,7 +229,6 @@ describe("SnackbarNotification", () => { beforeEach(() => { jest.useFakeTimers(); - clearConsumedSnackbarMessage = jest.fn(); }); afterEach(() => jest.useRealTimers()); diff --git a/src/utils/__tests__/actions.test.js b/src/utils/__tests__/actions.test.js index 535b5b1b..28ea51e5 100644 --- a/src/utils/__tests__/actions.test.js +++ b/src/utils/__tests__/actions.test.js @@ -2,6 +2,8 @@ import {authErrorHandler, snackbarErrorHandler, clearConsumedSnackbarMessage, CL import {setAuthHandlers} from "../../components/security/methods"; import { CLEAR_SESSION_STATE } from "../../components/security/actions"; import configureMockStore from 'redux-mock-store'; +import request from 'superagent/lib/client'; +import URI from "urijs"; import Swal from 'sweetalert2'; import thunk from 'redux-thunk' From 21759ae6dcc3753f204160f308d65abef7b53d10 Mon Sep 17 00:00:00 2001 From: anna Date: Fri, 13 Nov 2026 16:39:29 -0600 Subject: [PATCH 15/15] fix: add a repeated error message's detalis into the copy message --- .../mui/SnackbarNotification/index.js | 17 ++++++++++++----- .../__tests__/snackbar-notification.test.js | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js index 152a4ded..201a7d86 100644 --- a/src/components/mui/SnackbarNotification/index.js +++ b/src/components/mui/SnackbarNotification/index.js @@ -77,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 diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js index 4345460b..e1e88173 100644 --- a/src/components/mui/__tests__/snackbar-notification.test.js +++ b/src/components/mui/__tests__/snackbar-notification.test.js @@ -387,6 +387,24 @@ describe("SnackbarNotification", () => { 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 }));