From 88da603c9bcfa4c6c72a7caad115cb76f0b066ae Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 11:21:54 -0600 Subject: [PATCH] feat(frontend): ValidationPanel with rule-grouped errors + warnings --- .../ClaimDrawer/ValidationPanel.test.tsx | 247 ++++++++++++++++++ .../ClaimDrawer/ValidationPanel.tsx | 187 +++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 src/components/ClaimDrawer/ValidationPanel.test.tsx create mode 100644 src/components/ClaimDrawer/ValidationPanel.tsx diff --git a/src/components/ClaimDrawer/ValidationPanel.test.tsx b/src/components/ClaimDrawer/ValidationPanel.test.tsx new file mode 100644 index 0000000..67514b4 --- /dev/null +++ b/src/components/ClaimDrawer/ValidationPanel.test.tsx @@ -0,0 +1,247 @@ +// @vitest-environment happy-dom +// Pure prop-driven component, but match the act() convention used by +// the other ClaimDrawer test files (ClaimDrawerHeader / +// ClaimDrawerSkeleton / ClaimDrawerError). +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, it } from "vitest"; +import { ValidationPanel } from "./ValidationPanel"; +import type { ClaimDetailValidation, ClaimDetailValidationIssue } from "@/types"; + +function renderIntoContainer(element: React.ReactElement): { + container: HTMLDivElement; + unmount: () => void; +} { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render(element); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function makeIssue( + overrides: Partial = {} +): ClaimDetailValidationIssue { + return { + rule: "R050_diagnosis_present", + message: "Primary diagnosis is required", + severity: "error", + ...overrides, + }; +} + +function makeValidation( + overrides: Partial = {} +): ClaimDetailValidation { + return { + passed: true, + errors: [], + warnings: [], + ...overrides, + }; +} + +describe("ValidationPanel", () => { + it("test_all_passed_state_renders_checkmark_and_message", () => { + const { container, unmount } = renderIntoContainer( + + ); + + // The pass-state root has data-testid="validation-passed". + const passedRoot = container.querySelector('[data-testid="validation-passed"]'); + expect(passedRoot).not.toBeNull(); + + // The success copy is in the rendered text. + const text = (container.textContent ?? "").toLowerCase(); + expect(text).toContain("all checks passed"); + + // The checkmark icon (lucide-react `CheckCircle2`) is an inline SVG. + const svg = passedRoot?.querySelector("svg"); + expect(svg).not.toBeNull(); + + // Neither sub-section is rendered when nothing is wrong. + expect(container.querySelector('[data-testid="validation-errors"]')).toBeNull(); + expect(container.querySelector('[data-testid="validation-warnings"]')).toBeNull(); + + unmount(); + }); + + it("test_errors_only_renders_errors_subsection", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const errorsBlock = container.querySelector('[data-testid="validation-errors"]'); + expect(errorsBlock).not.toBeNull(); + + // Red left-border token applied to the inner block — sniff the className. + const cls = errorsBlock?.className ?? ""; + expect(cls).toContain("border-[color:var(--m-error)]"); + expect(cls).toContain("border-l-2"); + + // Rule code + message visible in the rendered text. + const text = container.textContent ?? ""; + expect(text).toContain("R050_diagnosis_present"); + expect(text).toContain("Primary diagnosis is required"); + + // No warnings sub-section when only errors are present. + expect(container.querySelector('[data-testid="validation-warnings"]')).toBeNull(); + // And no all-passed pill. + expect(container.querySelector('[data-testid="validation-passed"]')).toBeNull(); + + unmount(); + }); + + it("test_warnings_only_renders_warnings_subsection", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const warningsBlock = container.querySelector('[data-testid="validation-warnings"]'); + expect(warningsBlock).not.toBeNull(); + + // Amber left-border token applied to the inner block. + const cls = warningsBlock?.className ?? ""; + expect(cls).toContain("border-[color:var(--m-warning)]"); + expect(cls).toContain("border-l-2"); + + const text = container.textContent ?? ""; + expect(text).toContain("R200_units_recommended"); + expect(text).toContain("Units reported as 0"); + + // No errors sub-section when only warnings are present. + expect(container.querySelector('[data-testid="validation-errors"]')).toBeNull(); + + unmount(); + }); + + it("test_both_errors_and_warnings_renders_both", () => { + const { container, unmount } = renderIntoContainer( + + ); + + expect(container.querySelector('[data-testid="validation-errors"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="validation-warnings"]')).not.toBeNull(); + + // Errors sub-section appears before warnings sub-section (blocking + // issues first, then advisories). + const errorsEl = container.querySelector('[data-testid="validation-errors"]'); + const warningsEl = container.querySelector('[data-testid="validation-warnings"]'); + expect(errorsEl).not.toBeNull(); + expect(warningsEl).not.toBeNull(); + expect( + errorsEl!.compareDocumentPosition(warningsEl!) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + + unmount(); + }); + + it("test_errors_are_grouped_by_rule_with_count", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const errorsBlock = container.querySelector('[data-testid="validation-errors"]'); + expect(errorsBlock).not.toBeNull(); + + const text = errorsBlock?.textContent ?? ""; + + // Header should show the rule code with the grouped count "(2)". + expect(text).toContain("R050_diagnosis_present"); + expect(text).toContain("(2)"); + + // Both messages appear underneath the group header. + expect(text).toContain("Primary diagnosis is required"); + expect(text).toContain("Diagnosis qualifier is required"); + + unmount(); + }); + + it("test_passed_with_warnings_still_shows_warnings", () => { + // The "all checks passed" pill is reserved for passed && no-warnings; + // passed=true with warnings present should still surface the warnings + // sub-section so the user notices the advisories. + const { container, unmount } = renderIntoContainer( + + ); + + expect(container.querySelector('[data-testid="validation-warnings"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="validation-passed"]')).toBeNull(); + + unmount(); + }); +}); diff --git a/src/components/ClaimDrawer/ValidationPanel.tsx b/src/components/ClaimDrawer/ValidationPanel.tsx new file mode 100644 index 0000000..6d2d048 --- /dev/null +++ b/src/components/ClaimDrawer/ValidationPanel.tsx @@ -0,0 +1,187 @@ +import { AlertCircle, AlertTriangle, CheckCircle2 } from "lucide-react"; +import type { ClaimDetail } from "@/types"; + +type ValidationPanelProps = { + validation: ClaimDetail["validation"]; +}; + +type IssueList = ClaimDetail["validation"]["errors"]; + +/** + * Group issues by their `rule` field, preserving insertion order so the + * backend's surface order is respected on screen. Returns an array of + * `[rule, issues]` pairs — Maps aren't a great fit for JSX iteration here. + */ +function groupByRule(issues: IssueList): Array<[string, IssueList]> { + const groups = new Map(); + for (const issue of issues) { + const bucket = groups.get(issue.rule); + if (bucket) { + bucket.push(issue); + } else { + groups.set(issue.rule, [issue]); + } + } + return Array.from(groups.entries()); +} + +/** + * One rule-group block: header with rule code + count chip, followed by + * the list of messages underneath. + */ +function IssueGroup({ + rule, + issues, + testId, +}: { + rule: string; + issues: IssueList; + testId: string; +}) { + const Icon = testId === "validation-errors" ? AlertCircle : AlertTriangle; + const iconColor = + testId === "validation-errors" + ? "text-[color:var(--m-error)]" + : "text-[color:var(--m-warning)]"; + + return ( +
+
+ + {rule} + + + ({issues.length}) + +
+
    + {issues.map((issue, idx) => ( +
  • + + {issue.message} +
  • + ))} +
+
+ ); +} + +/** + * Validation section of the claim detail drawer (SP4). + * + * Three states: + * - passed && warnings.length === 0 → "All checks passed" pill + * - errors and/or warnings present → grouped sub-sections + * + * Issues are grouped by their `rule` field (e.g. R050_diagnosis_present) + * with a per-group count, so repeated violations of the same rule don't + * drown out other rules. Errors render before warnings so the user sees + * blocking issues first. + */ +export function ValidationPanel({ validation }: ValidationPanelProps) { + const allPassed = validation.passed && validation.warnings.length === 0; + + if (allPassed) { + return ( +
+ + + All checks passed + +
+ ); + } + + const errorGroups = groupByRule(validation.errors); + const warningGroups = groupByRule(validation.warnings); + + return ( +
+ + Validation + + + {validation.errors.length > 0 ? ( +
+
+ + + Errors ({validation.errors.length}) + +
+
+ {errorGroups.map(([rule, issues]) => ( + + ))} +
+
+ ) : null} + + {validation.warnings.length > 0 ? ( +
+
+ + + Warnings ({validation.warnings.length}) + +
+
+ {warningGroups.map(([rule, issues]) => ( + + ))} +
+
+ ) : null} +
+ ); +}