Skip to content

Commit 2dfd199

Browse files
authored
Merge pull request #5580 from HHS/procurement-tracker-ui-fixes
Procurement Tracker UI Minor Fixes
2 parents 402beca + 100695d commit 2dfd199

16 files changed

Lines changed: 1019 additions & 224 deletions

File tree

backend/ops_api/Pipfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ PyYAML = "==6.0.3"
2424
requests = "==2.33.1"
2525
sqlalchemy = "==2.0.49"
2626
sqlalchemy-continuum = "==1.6.0"
27-
deepdiff = "*"
27+
deepdiff = "==9.0.0"
2828

2929
[dev-packages]
3030
black = {extras = ["d"], version = "==26.3.1"}

backend/ops_api/Pipfile.lock

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/ops_api/ops/services/procurement_tracker_steps.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,34 @@
2626
from ops_api.ops.validation.procurement_tracker_steps_validator import ProcurementTrackerStepsValidator
2727

2828

29+
def escape_markdown(text: str) -> str:
30+
"""
31+
Escape Markdown metacharacters for safe plain-text display.
32+
33+
Ensures user-supplied text is displayed literally in both plain-text
34+
and ReactMarkdown contexts, preventing unintended formatting or links.
35+
36+
Args:
37+
text: The text to escape
38+
39+
Returns:
40+
Text with Markdown metacharacters escaped
41+
"""
42+
# Escape backslash first to prevent double-escaping
43+
text = text.replace("\\", "\\\\")
44+
# Escape Markdown syntax characters
45+
text = text.replace("*", "\\*") # Bold/italic
46+
text = text.replace("_", "\\_") # Italic/bold
47+
text = text.replace("[", "\\[") # Links
48+
text = text.replace("]", "\\]")
49+
text = text.replace("`", "\\`") # Code
50+
text = text.replace("#", "\\#") # Headers
51+
text = text.replace("+", "\\+") # Lists
52+
text = text.replace("-", "\\-") # Lists
53+
text = text.replace("!", "\\!") # Images
54+
return text
55+
56+
2957
class ProcurementTrackerStepService:
3058
"""Service for procurement tracker step operations."""
3159

@@ -362,8 +390,11 @@ def _handle_approval_notifications(
362390
)
363391
if step.pre_award_approval_reviewer_notes and step.pre_award_approval_reviewer_notes.strip():
364392
reviewer_notes_text = step.pre_award_approval_reviewer_notes.strip()
365-
# Use 5 backticks to safely contain any triple-backtick sequences in notes
366-
message += f"\n\nNotes:\n`````\n{reviewer_notes_text}\n`````"
393+
# Escape Markdown metacharacters to ensure notes display literally in both
394+
# SimpleAlert (plain text) and NotificationCenter/LogItem (ReactMarkdown).
395+
# Prevents user-supplied markdown from rendering as formatting or clickable links.
396+
reviewer_notes_text = escape_markdown(reviewer_notes_text)
397+
message += f"\n\nNotes:\n{reviewer_notes_text}"
367398

368399
notification_service.create(
369400
{

backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_steps_duplicate_notifications.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,6 @@ def test_approval_response_includes_reviewer_notes_in_notification(auth_client,
275275
assert notification is not None, "Notification should be created for approval response"
276276
assert reviewer_notes in notification.message, f"Reviewer notes should be in message. Got: {notification.message}"
277277
assert "Notes:" in notification.message, "Message should include 'Notes:' label"
278-
assert "```" in notification.message, "Notes should be wrapped in code block"
279278

280279

281280
def test_decline_response_includes_reviewer_notes_in_notification(auth_client, test_pre_award_step, loaded_db):
@@ -306,7 +305,6 @@ def test_decline_response_includes_reviewer_notes_in_notification(auth_client, t
306305
assert notification is not None, "Notification should be created for decline response"
307306
assert reviewer_notes in notification.message, f"Reviewer notes should be in message. Got: {notification.message}"
308307
assert "Notes:" in notification.message, "Message should include 'Notes:' label"
309-
assert "```" in notification.message, "Notes should be wrapped in code block"
310308

311309

312310
def test_approval_response_excludes_empty_reviewer_notes(auth_client, test_pre_award_step, loaded_db):
@@ -398,12 +396,12 @@ def test_reviewer_notes_prevent_markdown_injection(auth_client, test_pre_award_s
398396
).first()
399397

400398
assert notification is not None, "Notification should be created"
401-
# Verify notes are wrapped in 5-backtick code block (prevents Markdown rendering)
402-
assert "`````" in notification.message, "Notes should be wrapped in 5-backtick code block"
403-
# Verify the raw Markdown syntax is preserved as plain text
404-
assert "**Bold**" in notification.message, "Markdown syntax should be preserved literally"
405-
assert "[link]" in notification.message, "Link syntax should be preserved literally"
406-
assert "```code```" in notification.message, "Triple backticks should be preserved literally"
399+
# Verify notes are included in the message
400+
assert "Notes:" in notification.message, "Message should include 'Notes:' label"
401+
# Verify the Markdown syntax is escaped to prevent rendering
402+
assert "\\*\\*Bold\\*\\*" in notification.message, "Asterisks should be escaped"
403+
assert "\\[link\\]" in notification.message, "Brackets should be escaped"
404+
assert "\\`\\`\\`code\\`\\`\\`" in notification.message, "Backticks should be escaped"
407405

408406

409407
def test_reviewer_notes_backtick_injection_prevented(auth_client, test_pre_award_step, loaded_db):
@@ -432,9 +430,9 @@ def test_reviewer_notes_backtick_injection_prevented(auth_client, test_pre_award
432430
).first()
433431

434432
assert notification is not None, "Notification should be created"
435-
# Verify 5-backtick fence is used
436-
assert notification.message.count("`````") == 2, "Should have opening and closing 5-backtick fences"
437-
# Verify triple backticks are contained within the fence (appear in raw form)
438-
assert "```" in notification.message, "Triple backticks should be preserved"
439-
# Verify the markdown after triple backticks is also preserved literally
440-
assert "**This should NOT render as bold**" in notification.message, "Markdown after backticks should be literal"
433+
# Verify notes are included in the message
434+
assert "Notes:" in notification.message, "Message should include 'Notes:' label"
435+
# Verify triple backticks are escaped
436+
assert "\\`\\`\\`" in notification.message, "Triple backticks should be escaped"
437+
# Verify the markdown after triple backticks is also escaped
438+
assert "\\*\\*This should NOT render as bold\\*\\*" in notification.message, "Asterisks should be escaped"

frontend/cypress/e2e/agreementDetailsEdit.cy.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,8 @@ describe("Awarded Agreement", () => {
446446
cy.get("#agreementNotes").clear();
447447
cy.get("#agreementNotes").type("Adding notes as agreement team member.");
448448
cy.get("[data-cy='continue-btn']").click();
449+
// wait for page to finish loading after save
450+
cy.url().should("not.include", "mode=edit");
449451
// verify notes are added
450452
cy.get("[data-cy='details-notes']").should("contain", "Adding notes as agreement team member.");
451453
checkAgreementHistory();
@@ -475,6 +477,8 @@ describe("Awarded Agreement", () => {
475477
cy.get("#agreementNotes").clear();
476478
cy.get("#agreementNotes").type("Adding notes as agreement power user.");
477479
cy.get("[data-cy='continue-btn']").click();
480+
// wait for page to finish loading after save
481+
cy.url().should("not.include", "mode=edit");
478482
// verify notes are added
479483
cy.get("[data-cy='details-notes']").should("contain", "Adding notes as agreement power user.");
480484
checkAgreementHistory();

frontend/src/components/Agreements/ProcurementTracker/StepBuilderAccordion/StepBuilderAccordion.jsx

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import React from "react";
12
import Accordion from "../../../UI/Accordion";
23
import { fromUpperCaseToTitleCase } from "../../../../helpers/utils";
34
import { PROCUREMENT_STEP_STATUS } from "../ProcurementTracker.constants";
@@ -63,43 +64,41 @@ const getStepState = (step, activeStepNumber) => {
6364

6465
/**
6566
* @param {StepBuilderAccordionProps} props
67+
* @param {React.Ref} ref - Forwarded ref to the accordion container
6668
* @returns {import("react").ReactElement}
6769
*/
68-
const StepBuilderAccordion = ({
69-
step,
70-
totalSteps,
71-
activeStepNumber,
72-
children,
73-
isClosed = false,
74-
isReadOnly = false,
75-
level = 3
76-
}) => {
77-
const stepState = getStepState(step, activeStepNumber);
78-
const readOnlyClassName = isReadOnly ? "step-builder-accordion__heading--read-only" : "";
70+
const StepBuilderAccordion = React.forwardRef(
71+
({ step, totalSteps, activeStepNumber, children, isClosed = false, isReadOnly = false, level = 3 }, ref) => {
72+
const stepState = getStepState(step, activeStepNumber);
73+
const readOnlyClassName = isReadOnly ? "step-builder-accordion__heading--read-only" : "";
7974

80-
const heading = (
81-
<div
82-
className={`step-builder-accordion__heading step-builder-accordion__heading--${stepState} ${readOnlyClassName}`}
83-
data-testid={`step-builder-heading-${step?.id}`}
84-
>
85-
<span className="step-builder-accordion__step-count">
86-
<span className="step-builder-accordion__step-number">{step?.step_number}</span>{" "}
87-
<span className="step-builder-accordion__step-total">of {totalSteps}</span>
88-
</span>{" "}
89-
<span className="step-builder-accordion__step-label">{formatStepLabel(step?.step_type)}</span>
90-
</div>
91-
);
75+
const heading = (
76+
<div
77+
className={`step-builder-accordion__heading step-builder-accordion__heading--${stepState} ${readOnlyClassName}`}
78+
data-testid={`step-builder-heading-${step?.id}`}
79+
>
80+
<span className="step-builder-accordion__step-count">
81+
<span className="step-builder-accordion__step-number">{step?.step_number}</span>{" "}
82+
<span className="step-builder-accordion__step-total">of {totalSteps}</span>
83+
</span>{" "}
84+
<span className="step-builder-accordion__step-label">{formatStepLabel(step?.step_type)}</span>
85+
</div>
86+
);
9287

93-
return (
94-
<Accordion
95-
heading={heading}
96-
dataCy={`step-builder-accordion-${step?.id}`}
97-
isClosed={isClosed}
98-
level={level}
99-
>
100-
{children}
101-
</Accordion>
102-
);
103-
};
88+
return (
89+
<Accordion
90+
ref={ref}
91+
heading={heading}
92+
dataCy={`step-builder-accordion-${step?.id}`}
93+
isClosed={isClosed}
94+
level={level}
95+
>
96+
{children}
97+
</Accordion>
98+
);
99+
}
100+
);
101+
102+
StepBuilderAccordion.displayName = "StepBuilderAccordion";
104103

105104
export default StepBuilderAccordion;

frontend/src/components/UI/Accordion/Accordion.jsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ import accordion from "@uswds/uswds/js/usa-accordion";
1919
* @param {string} [props.id] - Optional id for anchor linking.
2020
* @param {string} [props.dataCy] - Data attribute for testing.
2121
* @param {(isOpen: boolean) => void} [props.onToggle] - Optional callback fired on toggle.
22+
* @param {React.Ref} ref - Forwarded ref to the accordion container div.
2223
* @returns {JSX.Element} - The rendered accordion component.
2324
*/
24-
const Accordion = ({ heading, children, level = 4, isClosed = false, id, dataCy, onToggle }) => {
25+
const Accordion = React.forwardRef(({ heading, children, level = 4, isClosed = false, id, dataCy, onToggle }, ref) => {
2526
const accordionId = React.useId();
2627
const AccordionHeading = `h${level}`;
2728
const [isOpen, setIsOpen] = React.useState(!isClosed);
@@ -57,7 +58,14 @@ const Accordion = ({ heading, children, level = 4, isClosed = false, id, dataCy,
5758
id={id}
5859
className={`usa-accordion ${isOpen ? "padding-bottom-6" : ""}`}
5960
style={{ lineHeight: "inherit" }}
60-
ref={accordionRef}
61+
ref={(node) => {
62+
accordionRef.current = node;
63+
if (typeof ref === "function") {
64+
ref(node);
65+
} else if (ref) {
66+
ref.current = node;
67+
}
68+
}}
6169
data-cy={dataCy}
6270
>
6371
<AccordionHeading className="usa-accordion__heading">
@@ -86,6 +94,8 @@ const Accordion = ({ heading, children, level = 4, isClosed = false, id, dataCy,
8694
</div>
8795
</div>
8896
);
89-
};
97+
});
98+
99+
Accordion.displayName = "Accordion";
90100

91101
export default Accordion;

0 commit comments

Comments
 (0)