From c80046df4d45e667f178d2f782a35ce1ffb444bc Mon Sep 17 00:00:00 2001 From: nkhazi Date: Thu, 30 Jul 2026 12:55:24 -0400 Subject: [PATCH 1/8] Added features print PDF support for table component --- .../widgets/TableWidget/TableCell.test.tsx | 53 +++- .../widgets/TableWidget/TableCell.tsx | 24 +- .../widgets/TableWidget/TableWidget.test.tsx | 15 + .../widgets/TableWidget/TableWidget.tsx | 259 +++++++++++++----- 4 files changed, 270 insertions(+), 81 deletions(-) diff --git a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx index 133540f4ef..e570b999a0 100644 --- a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx +++ b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx @@ -34,6 +34,9 @@ describe("TableCell", () => { expect(screen.getByTestId("read-only-cell-read-only")).toHaveTextContent( "$1,234.50", ); + expect(screen.getByTestId("read-only-cell-read-only")).toHaveTextContent( + "$1,234.50", + ); }); it("renders an editable numeric text input", () => { @@ -54,7 +57,8 @@ describe("TableCell", () => { expect(input).toHaveAttribute("type", "text"); expect(input).toHaveAttribute("inputmode", "decimal"); expect(input).toHaveValue("1250.5"); - expect(input).toHaveClass("overflow-x-auto"); + expect(input).not.toHaveClass("width-full"); + expect(input).toHaveStyle("width: 100%"); }); it("updates the input display when the value prop changes", () => { @@ -200,6 +204,14 @@ describe("TableCell", () => { "tabindex", "-1", ); + + expect(screen.getByTestId("input-cell-read-only")).toHaveTextContent( + "100.00", + ); + expect(screen.getByTestId("input-cell-read-only")).toHaveAttribute( + "tabindex", + "-1", + ); }); it("supports keyboard focus for editable values", async () => { @@ -242,6 +254,45 @@ describe("TableCell", () => { "text-wrap", ); }); + it("never allows a numeric read-only value to break mid-number", () => { + render( + , + ); + + const readOnlyEl = screen.getByTestId("read-only-cell-read-only"); + expect(readOnlyEl).toHaveStyle("white-space: nowrap"); + expect(readOnlyEl).not.toHaveStyle("overflow-wrap: anywhere"); + expect(readOnlyEl).not.toHaveStyle("word-break: break-all"); + expect(readOnlyEl).not.toHaveStyle("word-break: break-word"); + }); + + it("never allows a numeric input value to break mid-number", () => { + render( + , + ); + + const input = screen.getByTestId("input-cell-input"); + expect(input).toHaveStyle("white-space: nowrap"); + expect(input).not.toHaveStyle("overflow-wrap: anywhere"); + expect(input).not.toHaveStyle("word-break: break-all"); + expect(input).not.toHaveStyle("word-break: break-word"); + }); it("renders validation errors when cellErrors provided", () => { render( diff --git a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx index c1dc0caac1..63d86bb049 100644 --- a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx +++ b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx @@ -6,7 +6,7 @@ import { ChangeEvent, useState } from "react"; import { FieldErrors } from "src/components/core/forms/FieldErrors"; const READ_ONLY_OUTPUT_CLASS = - "usa-input margin-0 width-full overflow-x-auto display-block border border-base-light bg-base-lightest text-right text-wrap"; + "usa-input margin-0 display-inline-block border border-base-light bg-base-lightest text-right text-wrap"; type TableCellProps = { /** The cell configuration from the table widget schema */ @@ -109,6 +109,15 @@ function TableCell({ className={READ_ONLY_OUTPUT_CLASS} data-testid={`${id}-read-only`} tabIndex={-1} + style={{ + display: "inline-block", + width: "100%", + boxSizing: "border-box", + overflow: "visible", + whiteSpace: "nowrap", + overflowWrap: "normal", + wordBreak: "normal", + }} > {renderedValue === "" ? "\u00A0" : renderedValue} @@ -130,9 +139,7 @@ function TableCell({ {hasError && } ); diff --git a/frontend/src/components/apply-form/widgets/TableWidget/TableWidget.test.tsx b/frontend/src/components/apply-form/widgets/TableWidget/TableWidget.test.tsx index 8af4b7bd5d..61c5a04406 100644 --- a/frontend/src/components/apply-form/widgets/TableWidget/TableWidget.test.tsx +++ b/frontend/src/components/apply-form/widgets/TableWidget/TableWidget.test.tsx @@ -49,6 +49,21 @@ describe("TableWidget", () => { }, }; + it("applies the fixed table-layout class so colgroup widths are respected", () => { + render( + , + ); + expect(screen.getByTestId("table")).toHaveClass( + "applyform-table-fixed-layout", + ); + }); + it("renders configured table headers and cells", () => { render( row.cells[colIndex]) + .find((candidate) => candidate !== undefined); + return !cell || cell.type === "plainText"; +} + +/** + * Estimate how many "character units" a numeric (input/readOnly) column + * needs by finding the longest formatted value across all rows. + */ +function getMaxFormattedLength( + rows: UiSchemaTableRow[], + colIndex: number, + value: unknown, +): number { + let maxLen = 0; + rows.forEach((row) => { + const cell = row.cells[colIndex]; + if (!cell || cell.type === "plainText") return; + const renderValue = getRenderValue(cell.definition, value); + const formatted = formatTableCellValue(renderValue, cell.format); + if (formatted.length > maxLen) { + maxLen = formatted.length; + } + }); + return maxLen; +} + +/** + * Compute column widths (as percentages summing to 100) based on the actual + * content each column needs to display, rather than a fixed width from the + * schema. Numeric columns get width proportional to their longest formatted + * value (since numbers can't wrap onto multiple lines); plainText columns get + * a fixed, smaller allotment since text can wrap. This keeps values from + * overflowing their cells both on screen and when printed. + */ +function computeColumnWidths( + columns: UiSchemaTableColumn[], + rows: UiSchemaTableRow[], + value: unknown, +): number[] { + const units = columns.map((_, colIndex) => { + if (isPlainTextColumn(rows, colIndex)) { + return TEXT_COLUMN_UNIT; + } + return getMaxFormattedLength(rows, colIndex, value) + NUMERIC_UNIT_PADDING; + }); + + const total = units.reduce((sum, unit) => sum + unit, 0) || 1; + const rawPercentages = units.map((unit) => (unit / total) * 100); + + // Enforce a minimum width so no column collapses, then renormalize to 100%. + const clamped = rawPercentages.map((pct) => + Math.max(pct, MIN_COLUMN_WIDTH_PERCENT), + ); + const clampedTotal = clamped.reduce((sum, pct) => sum + pct, 0); + + return clamped.map((pct) => (pct / clampedTotal) * 100); +} + /** * TableWidget renders a data table with support for multiple cell types. * @@ -155,6 +231,11 @@ function TableWidget({ (uiSchemaField as UiSchemaTableMultiField | undefined)?.children?.rows ?? []; + const columnWidths = useMemo( + () => computeColumnWidths(columns, rows, value), + [columns, rows, value], + ); + const cellChangeHandlers = useMemo( () => rows.reduce( @@ -303,84 +384,110 @@ function TableWidget({ }); return ( - {label ?? uiSchemaField.name} - } - > - - - {columns.map((column) => ( - + + {columns.map((column) => ( + + ))} + + + + {rows.map((row, rowIndex) => { + const rowLabel = + row.cells[0]?.type === "plainText" + ? row.cells[0].staticContent + : undefined; + + return ( + + {row.cells.map((cell, cellIndex) => { + const cellId = `${uiSchemaField.name}-${rowIndex}-${cellIndex}`; + + return ( + + ); + })} + + ); + })} + +
+ {/* + @trussworks/react-uswds's TableProps intentionally omits `style`, so + table-layout: fixed (needed for the colgroup percentages below to be + respected instead of overridden by content width) is applied via this + class instead of an inline style. + */} + + {label ?? uiSchemaField.name} + } + > + + {columns.map((column, index) => ( + - {column.columnHeader} - + style={{ width: `${columnWidths[index]}%` }} + /> ))} - - - - {rows.map((row, rowIndex) => { - const rowLabel = - row.cells[0]?.type === "plainText" - ? row.cells[0].staticContent - : undefined; - - return ( - - {row.cells.map((cell, cellIndex) => { - const cellId = `${uiSchemaField.name}-${rowIndex}-${cellIndex}`; - - return ( - - ); - })} - - ); - })} - -
- -
+ +
+ {column.columnHeader} +
+ +
+ ); } From e435ea16c1b13c5cab252d5ad84e1fd91c8ab1c6 Mon Sep 17 00:00:00 2001 From: nkhazi Date: Fri, 31 Jul 2026 16:00:10 -0400 Subject: [PATCH 2/8] Implemented reviewer's comments --- .../widgets/TableWidget/TableCell.test.tsx | 23 +-- .../widgets/TableWidget/TableCell.tsx | 24 +-- .../widgets/TableWidget/TableWidget.test.tsx | 188 +++++++++++++++++- .../widgets/TableWidget/TableWidget.tsx | 89 +++++---- 4 files changed, 246 insertions(+), 78 deletions(-) diff --git a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx index e570b999a0..f1a8c864ae 100644 --- a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx +++ b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx @@ -34,9 +34,6 @@ describe("TableCell", () => { expect(screen.getByTestId("read-only-cell-read-only")).toHaveTextContent( "$1,234.50", ); - expect(screen.getByTestId("read-only-cell-read-only")).toHaveTextContent( - "$1,234.50", - ); }); it("renders an editable numeric text input", () => { @@ -57,8 +54,6 @@ describe("TableCell", () => { expect(input).toHaveAttribute("type", "text"); expect(input).toHaveAttribute("inputmode", "decimal"); expect(input).toHaveValue("1250.5"); - expect(input).not.toHaveClass("width-full"); - expect(input).toHaveStyle("width: 100%"); }); it("updates the input display when the value prop changes", () => { @@ -204,14 +199,6 @@ describe("TableCell", () => { "tabindex", "-1", ); - - expect(screen.getByTestId("input-cell-read-only")).toHaveTextContent( - "100.00", - ); - expect(screen.getByTestId("input-cell-read-only")).toHaveAttribute( - "tabindex", - "-1", - ); }); it("supports keyboard focus for editable values", async () => { @@ -254,7 +241,8 @@ describe("TableCell", () => { "text-wrap", ); }); - it("never allows a numeric read-only value to break mid-number", () => { + + it("renders numeric read-only values as a single unbroken value in the current cell layout", () => { render( { ); const readOnlyEl = screen.getByTestId("read-only-cell-read-only"); - expect(readOnlyEl).toHaveStyle("white-space: nowrap"); + expect(readOnlyEl).toHaveClass("applyform-table-cell-value"); expect(readOnlyEl).not.toHaveStyle("overflow-wrap: anywhere"); expect(readOnlyEl).not.toHaveStyle("word-break: break-all"); expect(readOnlyEl).not.toHaveStyle("word-break: break-word"); }); - it("never allows a numeric input value to break mid-number", () => { + it("renders numeric input values without adding mid-number break styles", () => { render( { ); const input = screen.getByTestId("input-cell-input"); - expect(input).toHaveStyle("white-space: nowrap"); + expect(input).toHaveClass("applyform-table-cell-value"); expect(input).not.toHaveStyle("overflow-wrap: anywhere"); expect(input).not.toHaveStyle("word-break: break-all"); expect(input).not.toHaveStyle("word-break: break-word"); @@ -315,7 +303,6 @@ describe("TableCell", () => { "error-for-input-cell-with-errors", ); - // FieldErrors component should render the errors expect(screen.getByText("Must be greater than zero")).toBeInTheDocument(); expect(screen.getByText("Cannot exceed budget")).toBeInTheDocument(); }); diff --git a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx index 63d86bb049..7c6b96f763 100644 --- a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx +++ b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx @@ -6,7 +6,7 @@ import { ChangeEvent, useState } from "react"; import { FieldErrors } from "src/components/core/forms/FieldErrors"; const READ_ONLY_OUTPUT_CLASS = - "usa-input margin-0 display-inline-block border border-base-light bg-base-lightest text-right text-wrap"; + "usa-input margin-0 width-full overflow-x-auto display-block border border-base-light bg-base-lightest text-right text-wrap applyform-table-cell-value"; type TableCellProps = { /** The cell configuration from the table widget schema */ @@ -109,15 +109,6 @@ function TableCell({ className={READ_ONLY_OUTPUT_CLASS} data-testid={`${id}-read-only`} tabIndex={-1} - style={{ - display: "inline-block", - width: "100%", - boxSizing: "border-box", - overflow: "visible", - whiteSpace: "nowrap", - overflowWrap: "normal", - wordBreak: "normal", - }} > {renderedValue === "" ? "\u00A0" : renderedValue} @@ -139,7 +130,9 @@ function TableCell({ {hasError && } ); diff --git a/frontend/src/components/apply-form/widgets/TableWidget/TableWidget.test.tsx b/frontend/src/components/apply-form/widgets/TableWidget/TableWidget.test.tsx index 61c5a04406..73ce2f49dc 100644 --- a/frontend/src/components/apply-form/widgets/TableWidget/TableWidget.test.tsx +++ b/frontend/src/components/apply-form/widgets/TableWidget/TableWidget.test.tsx @@ -49,7 +49,7 @@ describe("TableWidget", () => { }, }; - it("applies the fixed table-layout class so colgroup widths are respected", () => { + it("applies the print-scoped table class used to force fixed table-layout in print", () => { render( { options={{}} />, ); - expect(screen.getByTestId("table")).toHaveClass( - "applyform-table-fixed-layout", - ); + expect(screen.getByTestId("table")).toHaveClass("applyform-budget-table"); }); it("renders configured table headers and cells", () => { @@ -524,4 +522,186 @@ describe("TableWidget", () => { expect(screen.queryByText("Total cost error")).not.toBeInTheDocument(); }); + + describe("print layout behavior", () => { + it("keeps the interactive on-screen header widths driven by configured column.width", () => { + const widthProps: TableWidgetProps = { + ...props, + uiSchemaField: { + ...props.uiSchemaField, + children: { + columns: [ + { columnHeader: "Item", width: 50 }, + { columnHeader: "First Value", width: 25 }, + { columnHeader: "Second Value", width: 25 }, + ], + rows: props.uiSchemaField.children.rows, + }, + }, + }; + + render( + , + ); + + const headers = screen.getAllByRole("columnheader"); + expect(headers[0]).toHaveStyle("width: 50%"); + expect(headers[1]).toHaveStyle("width: 25%"); + expect(headers[2]).toHaveStyle("width: 25%"); + }); + + it("computes print-only column widths from content, independent of configured column.width", () => { + const widthProps: TableWidgetProps = { + ...props, + uiSchemaField: { + ...props.uiSchemaField, + children: { + columns: [ + { columnHeader: "Item", width: 50 }, + { columnHeader: "First Value", width: 25 }, + { columnHeader: "Second Value", width: 25 }, + ], + rows: props.uiSchemaField.children.rows, + }, + }, + }; + + render( + , + ); + + const tableHtml = screen.getByTestId("table").innerHTML; + const colStyles = Array.from( + tableHtml.matchAll(/]*style="([^"]*)"/g), + ).map((match) => match[1]); + const printWidths = colStyles.map((style) => { + const match = /--applyform-print-col-width:\s*([\d.]+)%/.exec(style); + return match ? `${match[1]}%` : ""; + }); + + // Widths should sum to 100% but NOT equal the configured 50/25/25 split — + // proving print sizing is content-derived, not copied from column.width. + const numeric = printWidths.map((w) => parseFloat(w)); + const total = numeric.reduce((sum, w) => sum + w, 0); + + expect(total).toBeCloseTo(100, 1); + expect(numeric).not.toEqual([50, 25, 25]); + }); + + it("prevents a table row from splitting across a page when printed", () => { + render( + , + ); + + const stylesheet = Array.from(document.styleSheets).find((sheet) => + Array.from(sheet.cssRules).some((rule) => + rule.cssText.includes(".applyform-budget-table tr"), + ), + ); + expect(stylesheet).toBeDefined(); + const cssText = Array.from(stylesheet!.cssRules) + .map((rule) => rule.cssText) + .join("\n"); + + expect(cssText).toMatch( + /\.applyform-budget-table tr\s*{[^}]*break-inside:\s*avoid;/, + ); + expect(cssText).toMatch( + /\.applyform-budget-table tr\s*{[^}]*page-break-inside:\s*avoid;/, + ); + }); + + it("scopes print-only layout changes to @media print, leaving the interactive form untouched", () => { + render( + , + ); + + const stylesheet = Array.from(document.styleSheets).find( + (sheet) => + sheet.cssRules.length > 0 && + Array.from(sheet.cssRules).some((rule) => + rule.cssText.startsWith("@media print"), + ), + ); + expect(stylesheet).toBeDefined(); + + // No inline width leaks onto the table itself outside print. + expect(screen.getByTestId("table")).not.toHaveAttribute("style"); + + const tableHtml = screen.getByTestId("table").innerHTML; + const colStyleStrings = Array.from( + tableHtml.matchAll(/]*style="([^"]*)"/g), + ).map((match) => match[1]); + expect(colStyleStrings).not.toHaveLength(0); + colStyleStrings.forEach((style) => { + expect(style).toContain("--applyform-print-col-width"); + }); + }); + + it("assigns distinct print widths based on content for text and long numeric columns", () => { + const longValueProps: TableWidgetProps = { + ...props, + uiSchemaField: { + ...props.uiSchemaField, + children: { + columns: [ + { columnHeader: "Item" }, + { columnHeader: "First Value" }, + { columnHeader: "Second Value" }, + ], + rows: props.uiSchemaField.children.rows, + }, + }, + }; + + render( + , + ); + + const tableHtml = screen.getByTestId("table").innerHTML; + const colStyleStrings = Array.from( + tableHtml.matchAll(/]*style="([^"]*)"/g), + ).map((match) => match[1]); + const widths = colStyleStrings.map((style) => { + const match = /--applyform-print-col-width:\s*([\d.]+)%/.exec(style); + return match ? parseFloat(match[1]) : 0; + }); + const textColWidth = widths[0]; + const longNumberColWidth = widths[2]; + + expect(textColWidth).toBeGreaterThan(0); + expect(longNumberColWidth).toBeGreaterThan(0); + expect(textColWidth).not.toBe(longNumberColWidth); + }); + }); }); diff --git a/frontend/src/components/apply-form/widgets/TableWidget/TableWidget.tsx b/frontend/src/components/apply-form/widgets/TableWidget/TableWidget.tsx index bd47dcdc33..817254c1c7 100644 --- a/frontend/src/components/apply-form/widgets/TableWidget/TableWidget.tsx +++ b/frontend/src/components/apply-form/widgets/TableWidget/TableWidget.tsx @@ -52,16 +52,15 @@ function getRenderValue( : undefined; } -const TEXT_COLUMN_UNIT = 16; - -const MIN_COLUMN_WIDTH_PERCENT = 10; - -const NUMERIC_UNIT_PADDING = 2; +const PRINT_TEXT_COLUMN_UNIT = 16; +// Floor so no column collapses to an unusable sliver in print. +const PRINT_MIN_COLUMN_WIDTH_PERCENT = 10; /** * Determine whether a column is a "text" column (i.e. every populated cell * in that column is plainText). Text columns can wrap, so they're sized - * differently from numeric input/readOnly columns, which must stay on one line. + * differently from numeric input/readOnly columns, which must stay on one + * line when printed. */ function isPlainTextColumn( rows: UiSchemaTableRow[], @@ -74,8 +73,9 @@ function isPlainTextColumn( } /** - * Estimate how many "character units" a numeric (input/readOnly) column - * needs by finding the longest formatted value across all rows. + * Find the longest formatted value in a numeric (input/readOnly) column, + * across all rows — including computed subtotal/total rows, whose values + * can run a digit or two longer than any single input row. */ function getMaxFormattedLength( rows: UiSchemaTableRow[], @@ -96,23 +96,25 @@ function getMaxFormattedLength( } /** - * Compute column widths (as percentages summing to 100) based on the actual - * content each column needs to display, rather than a fixed width from the - * schema. Numeric columns get width proportional to their longest formatted - * value (since numbers can't wrap onto multiple lines); plainText columns get - * a fixed, smaller allotment since text can wrap. This keeps values from - * overflowing their cells both on screen and when printed. + * Compute print-only column widths (as percentages summing to 100) based on + * the actual content each column needs to display. Numeric columns get width + * proportional to their longest formatted value, with a safety buffer that + * scales with length (subtotal/total rows tend to be a digit or two longer + * than regular rows, and need enough breathing room not to overflow). + * plainText columns get a fixed, smaller allotment since text can wrap. */ -function computeColumnWidths( +function computePrintColumnWidths( columns: UiSchemaTableColumn[], rows: UiSchemaTableRow[], value: unknown, ): number[] { const units = columns.map((_, colIndex) => { if (isPlainTextColumn(rows, colIndex)) { - return TEXT_COLUMN_UNIT; + return PRINT_TEXT_COLUMN_UNIT; } - return getMaxFormattedLength(rows, colIndex, value) + NUMERIC_UNIT_PADDING; + const maxLen = getMaxFormattedLength(rows, colIndex, value); + const buffer = Math.max(2, Math.ceil(maxLen * 0.15)); + return maxLen + buffer; }); const total = units.reduce((sum, unit) => sum + unit, 0) || 1; @@ -120,7 +122,7 @@ function computeColumnWidths( // Enforce a minimum width so no column collapses, then renormalize to 100%. const clamped = rawPercentages.map((pct) => - Math.max(pct, MIN_COLUMN_WIDTH_PERCENT), + Math.max(pct, PRINT_MIN_COLUMN_WIDTH_PERCENT), ); const clampedTotal = clamped.reduce((sum, pct) => sum + pct, 0); @@ -231,8 +233,9 @@ function TableWidget({ (uiSchemaField as UiSchemaTableMultiField | undefined)?.children?.rows ?? []; - const columnWidths = useMemo( - () => computeColumnWidths(columns, rows, value), + // Only used by the print stylesheet below — does not affect on-screen widths. + const printColumnWidths = useMemo( + () => computePrintColumnWidths(columns, rows, value), [columns, rows, value], ); @@ -386,17 +389,35 @@ function TableWidget({ return ( <> {/* - @trussworks/react-uswds's TableProps intentionally omits `style`, so - table-layout: fixed (needed for the colgroup percentages below to be - respected instead of overridden by content width) is applied via this - class instead of an inline style. + Print-only overrides. On screen this changes nothing — table-layout + stays at its default (auto) and the colgroup widths below are inert + until @media print switches the table to a fixed layout and applies + them. This also stops a row's cells from splitting across a page + break, which the browser's default table pagination allows otherwise. */} - + ( ))} @@ -419,10 +444,7 @@ function TableWidget({ @@ -445,11 +467,6 @@ function TableWidget({
{column.columnHeader} Date: Mon, 3 Aug 2026 12:11:08 -0400 Subject: [PATCH 3/8] Created SF424C UI schema --- .../form_schema/forms/sf424c/1/0/form_json.py | 462 +++++++++++++++++- .../src/form_schema/forms/test_sf424c.py | 27 + 2 files changed, 481 insertions(+), 8 deletions(-) diff --git a/api/src/form_schema/forms/sf424c/1/0/form_json.py b/api/src/form_schema/forms/sf424c/1/0/form_json.py index 38fbff3fd7..0089e5688a 100644 --- a/api/src/form_schema/forms/sf424c/1/0/form_json.py +++ b/api/src/form_schema/forms/sf424c/1/0/form_json.py @@ -39,6 +39,7 @@ }, "construction": { "allOf": [{"$ref": "#/$defs/budget_row"}], + "required": ["total_cost"], }, "equipment": { "allOf": [{"$ref": "#/$defs/budget_row"}], @@ -64,6 +65,7 @@ "total_project_costs": { # Row 16 — Total project costs (row 14 - row 15) "allOf": [{"$ref": "#/$defs/budget_calculated_row"}], + }, }, }, @@ -145,12 +147,403 @@ "name": "Table1", "type": "section", "label": "Budget Information for Construction Programs", + "description": "NOTE: Certain Federal assistance programs require additional computations to arrive at the Federal share of project costs eligible for participation. If such is the case, you will be notified.", "children": [ { "type": "multiField", - "name": "Budget424cTable1", - "widget": "Budget424cTable1", + "name": "budget_424c_table_1", + "widget": "Table", "definition": ["/properties/budget_information"], + "children": { + "columns": [ + { + "columnHeader": "COST CLASSIFICATION", + "width": 40, + }, + { + "columnHeader": "a. Total Cost", + "width": 20, + }, + { + "columnHeader": "b. Costs Not Allowable \nfor Participation", + "width": 20, + }, + { + "columnHeader": "c. Total Allowable Costs \n(Columns a - b)", + "width": 20, + }, + ], + "rows": [ + { + "cells": [ + { + "type": "plainText", + "staticContent": "1. Administrative and legal expenses", + }, + { + "type": "input", + "definition": "/properties/administrative_and_legal_expenses/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/administrative_and_legal_expenses/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/administrative_and_legal_expenses/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "2. Land, structures, rights-of-way, appraisals, etc.", + }, + { + "type": "input", + "definition": "/properties/land_structures_rights_of_way/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/land_structures_rights_of_way/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/land_structures_rights_of_way/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "3. Relocation expenses and payments", + }, + { + "type": "input", + "definition": "/properties/relocation_expenses/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/relocation_expenses/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/relocation_expenses/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "4. Architectural and engineering fees", + }, + { + "type": "input", + "definition": "/properties/architectural_engineering_fees/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/architectural_engineering_fees/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/architectural_engineering_fees/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "5. Other architectural and engineering fees", + }, + { + "type": "input", + "definition": "/properties/other_architectural_engineering_fees/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/other_architectural_engineering_fees/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/other_architectural_engineering_fees/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "6. Project inspection fees", + }, + { + "type": "input", + "definition": "/properties/project_inspection_fees/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/project_inspection_fees/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/project_inspection_fees/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "7. Site work", + }, + { + "type": "input", + "definition": "/properties/site_work/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/site_work/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/site_work/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "8. Demolition and removal", + }, + { + "type": "input", + "definition": "/properties/demolition_and_removal/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/demolition_and_removal/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/demolition_and_removal/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "9. Construction", + }, + { + "type": "input", + "definition": "/properties/construction/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/construction/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/construction/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "10. Equipment", + }, + { + "type": "input", + "definition": "/properties/equipment/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/equipment/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/equipment/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "11. Miscellaneous", + }, + { + "type": "input", + "definition": "/properties/miscellaneous/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/miscellaneous/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/miscellaneous/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "12. SUBTOTAL (sum of lines 1-11)", + }, + { + "type": "readOnly", + "definition": "/properties/subtotal_1/properties/total_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/subtotal_1/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/subtotal_1/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "13. Contingencies", + }, + { + "type": "input", + "definition": "/properties/contingencies/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/contingencies/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/contingencies/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "14. SUBTOTAL", + }, + { + "type": "readOnly", + "definition": "/properties/subtotal_2/properties/total_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/subtotal_2/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/subtotal_2/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "15. Project (program) income", + }, + { + "type": "input", + "definition": "/properties/project_income/properties/total_cost", + "format": "dollar", + }, + { + "type": "input", + "definition": "/properties/project_income/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/project_income/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "16. TOTAL PROJECT COSTS (subtract 15 from 14)", + }, + { + "type": "readOnly", + "definition": "/properties/total_project_costs/properties/total_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/total_project_costs/properties/non_allowable_cost", + "format": "dollar", + }, + { + "type": "readOnly", + "definition": "/properties/total_project_costs/properties/total_allowable_cost", + "format": "dollar", + }, + ], + }, + ], + }, } ], }, @@ -158,17 +551,70 @@ "name": "Table2", "type": "section", "label": "Federal Funding", + "description": "Federal assistance requested, calculate as follows.", "children": [ { "type": "multiField", - "name": "Budget424cTable2", - "widget": "Budget424cTable2", + "name": "budget_424c_table_2", + "widget": "Table", "definition": ["/properties/federal_funding"], + "children": { + "columns": [ + { + "columnHeader": "Field", + "width": 60, + }, + { + "columnHeader": "Value", + "width": 40, + }, + ], + "rows": [ + { + "cells": [ + { + "type": "plainText", + "staticContent": "Total project costs", + }, + { + "type": "readOnly", + "definition": "/properties/total_project_costs", + "format": "dollar", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "Federal percentage share \n(Consult Federal agency for Federal percentage share.)", + }, + { + "type": "input", + "definition": "/properties/federal_percentage_share", + "format": "percentage", + }, + ], + }, + { + "cells": [ + { + "type": "plainText", + "staticContent": "Federal funding share", + }, + { + "type": "readOnly", + "definition": "/properties/federal_funding_share", + "format": "dollar", + }, + ], + }, + ], + }, } ], }, ] - FORM_RULE_SCHEMA = { ##### PRE-POPULATION RULES "budget_information": { @@ -467,9 +913,6 @@ "namespaces": { "SF424C_2_0": "http://apply.grants.gov/forms/SF424C_2_0-V2.0", "default": "http://apply.grants.gov/forms/SF424C_2_0-V2.0", - "glob": "http://apply.grants.gov/system/Global-V1.0", - "globLib": "http://apply.grants.gov/system/GlobalLibrary-V2.0", - "att": "http://apply.grants.gov/system/Attachments-V1.0", }, "xsd_url": "https://apply07.grants.gov/apply/forms/schemas/SF424C_2_0-V2.0.xsd", "xml_structure": { @@ -628,3 +1071,6 @@ sgg_version="1.0", is_deprecated=False, ) + + + diff --git a/api/tests/src/form_schema/forms/test_sf424c.py b/api/tests/src/form_schema/forms/test_sf424c.py index 1c8428e40a..db7fd7b6d7 100644 --- a/api/tests/src/form_schema/forms/test_sf424c.py +++ b/api/tests/src/form_schema/forms/test_sf424c.py @@ -130,6 +130,32 @@ def test_sf424c_v2_0_percentage_negative(sf424c_v2_0): assert validation_issues[0].type == "minimum" assert validation_issues[0].field == "$.federal_funding.federal_percentage_share" +def test_sf424c_v2_0_construction_total_cost_required(sf424c_v2_0): + """construction.total_cost is required — missing it fails schema validation, + unlike other budget rows (e.g. site_work) which stay fully optional.""" + data = { + "budget_information": { + "construction": { + "non_allowable_cost": "5000.00", + } + } + } + validation_issues = validate_json_schema_for_form(data, sf424c_v2_0) + assert len(validation_issues) == 1 + assert validation_issues[0].type == "required" + assert validation_issues[0].field == "$.budget_information.construction" + +def test_sf424c_v2_0_other_rows_total_cost_not_required(sf424c_v2_0): + """Unlike construction, other budget rows (e.g. site_work) don't require total_cost.""" + data = { + "budget_information": { + "site_work": { + "non_allowable_cost": "5000.00", + } + } + } + validation_issues = validate_json_schema_for_form(data, sf424c_v2_0) + assert len(validation_issues) == 0 def test_sf424c_v2_0_rules_empty_state( enable_factory_create, verify_no_warning_error_logs, sf424c_v2_0 @@ -306,3 +332,4 @@ def test_sf424c_v2_0_rules_subtotals_and_federal_funding( # Section 17 assert app_json["federal_funding"]["total_project_costs"] == "1030000.00" assert app_json["federal_funding"]["federal_funding_share"] == "824000.00" + From 8c9ddb42094514118c26bbd86605f0c7ae7aeda2 Mon Sep 17 00:00:00 2001 From: nkhazi Date: Mon, 3 Aug 2026 12:18:09 -0400 Subject: [PATCH 4/8] lint fixes --- api/src/form_schema/forms/sf424c/1/0/form_json.py | 6 +----- api/tests/src/form_schema/forms/test_sf424c.py | 4 +++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/api/src/form_schema/forms/sf424c/1/0/form_json.py b/api/src/form_schema/forms/sf424c/1/0/form_json.py index 0089e5688a..dfa7c6f22b 100644 --- a/api/src/form_schema/forms/sf424c/1/0/form_json.py +++ b/api/src/form_schema/forms/sf424c/1/0/form_json.py @@ -65,7 +65,6 @@ "total_project_costs": { # Row 16 — Total project costs (row 14 - row 15) "allOf": [{"$ref": "#/$defs/budget_calculated_row"}], - }, }, }, @@ -589,7 +588,7 @@ "type": "plainText", "staticContent": "Federal percentage share \n(Consult Federal agency for Federal percentage share.)", }, - { + { "type": "input", "definition": "/properties/federal_percentage_share", "format": "percentage", @@ -1071,6 +1070,3 @@ sgg_version="1.0", is_deprecated=False, ) - - - diff --git a/api/tests/src/form_schema/forms/test_sf424c.py b/api/tests/src/form_schema/forms/test_sf424c.py index db7fd7b6d7..edf77f0a62 100644 --- a/api/tests/src/form_schema/forms/test_sf424c.py +++ b/api/tests/src/form_schema/forms/test_sf424c.py @@ -130,6 +130,7 @@ def test_sf424c_v2_0_percentage_negative(sf424c_v2_0): assert validation_issues[0].type == "minimum" assert validation_issues[0].field == "$.federal_funding.federal_percentage_share" + def test_sf424c_v2_0_construction_total_cost_required(sf424c_v2_0): """construction.total_cost is required — missing it fails schema validation, unlike other budget rows (e.g. site_work) which stay fully optional.""" @@ -145,6 +146,7 @@ def test_sf424c_v2_0_construction_total_cost_required(sf424c_v2_0): assert validation_issues[0].type == "required" assert validation_issues[0].field == "$.budget_information.construction" + def test_sf424c_v2_0_other_rows_total_cost_not_required(sf424c_v2_0): """Unlike construction, other budget rows (e.g. site_work) don't require total_cost.""" data = { @@ -157,6 +159,7 @@ def test_sf424c_v2_0_other_rows_total_cost_not_required(sf424c_v2_0): validation_issues = validate_json_schema_for_form(data, sf424c_v2_0) assert len(validation_issues) == 0 + def test_sf424c_v2_0_rules_empty_state( enable_factory_create, verify_no_warning_error_logs, sf424c_v2_0 ): @@ -332,4 +335,3 @@ def test_sf424c_v2_0_rules_subtotals_and_federal_funding( # Section 17 assert app_json["federal_funding"]["total_project_costs"] == "1030000.00" assert app_json["federal_funding"]["federal_funding_share"] == "824000.00" - From b9c32a946e65342bf3b1259c7ef0794527e69110 Mon Sep 17 00:00:00 2001 From: nkhazi Date: Mon, 3 Aug 2026 14:25:00 -0400 Subject: [PATCH 5/8] fixed the wrapping issue --- api/src/form_schema/forms/sf424c/1/0/form_json.py | 2 ++ .../apply-form/widgets/TableWidget/TableCell.tsx | 13 ++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/api/src/form_schema/forms/sf424c/1/0/form_json.py b/api/src/form_schema/forms/sf424c/1/0/form_json.py index dfa7c6f22b..e4daad306c 100644 --- a/api/src/form_schema/forms/sf424c/1/0/form_json.py +++ b/api/src/form_schema/forms/sf424c/1/0/form_json.py @@ -912,6 +912,8 @@ "namespaces": { "SF424C_2_0": "http://apply.grants.gov/forms/SF424C_2_0-V2.0", "default": "http://apply.grants.gov/forms/SF424C_2_0-V2.0", + "globLib": "http://apply.grants.gov/system/GlobalLibrary-V1.0", + "att": "http://apply.grants.gov/system/Attachments-V1.0", }, "xsd_url": "https://apply07.grants.gov/apply/forms/schemas/SF424C_2_0-V2.0.xsd", "xml_structure": { diff --git a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx index 7c6b96f763..52a59c8847 100644 --- a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx +++ b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx @@ -127,7 +127,18 @@ function TableCell({ const inputId = name ?? id; return ( <> - {hasError && } + {hasError && ( +
+ +
+ )} Date: Mon, 3 Aug 2026 14:48:49 -0400 Subject: [PATCH 6/8] lint fixes --- api/src/form_schema/forms/sf424c/1/0/form_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/form_schema/forms/sf424c/1/0/form_json.py b/api/src/form_schema/forms/sf424c/1/0/form_json.py index e4daad306c..c65a838bc0 100644 --- a/api/src/form_schema/forms/sf424c/1/0/form_json.py +++ b/api/src/form_schema/forms/sf424c/1/0/form_json.py @@ -912,7 +912,7 @@ "namespaces": { "SF424C_2_0": "http://apply.grants.gov/forms/SF424C_2_0-V2.0", "default": "http://apply.grants.gov/forms/SF424C_2_0-V2.0", - "globLib": "http://apply.grants.gov/system/GlobalLibrary-V1.0", + "globLib": "http://apply.grants.gov/system/GlobalLibrary-V1.0", "att": "http://apply.grants.gov/system/Attachments-V1.0", }, "xsd_url": "https://apply07.grants.gov/apply/forms/schemas/SF424C_2_0-V2.0.xsd", From 60728785879f2479898814eafcf84f25544687ca Mon Sep 17 00:00:00 2001 From: nkhazi Date: Mon, 3 Aug 2026 15:25:59 -0400 Subject: [PATCH 7/8] Update SF424C form XML rules and TableCell test coverage --- .../form_schema/forms/sf424c/1/0/form_json.py | 3 ++- .../src/form_schema/forms/test_sf424c.py | 2 +- .../widgets/TableWidget/TableCell.test.tsx | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/api/src/form_schema/forms/sf424c/1/0/form_json.py b/api/src/form_schema/forms/sf424c/1/0/form_json.py index c65a838bc0..498e15fb1c 100644 --- a/api/src/form_schema/forms/sf424c/1/0/form_json.py +++ b/api/src/form_schema/forms/sf424c/1/0/form_json.py @@ -912,7 +912,8 @@ "namespaces": { "SF424C_2_0": "http://apply.grants.gov/forms/SF424C_2_0-V2.0", "default": "http://apply.grants.gov/forms/SF424C_2_0-V2.0", - "globLib": "http://apply.grants.gov/system/GlobalLibrary-V1.0", + "glob": "http://apply.grants.gov/system/Global-V1.0", + "globLib": "http://apply.grants.gov/system/GlobalLibrary-V2.0", "att": "http://apply.grants.gov/system/Attachments-V1.0", }, "xsd_url": "https://apply07.grants.gov/apply/forms/schemas/SF424C_2_0-V2.0.xsd", diff --git a/api/tests/src/form_schema/forms/test_sf424c.py b/api/tests/src/form_schema/forms/test_sf424c.py index edf77f0a62..2ee5a6f7f0 100644 --- a/api/tests/src/form_schema/forms/test_sf424c.py +++ b/api/tests/src/form_schema/forms/test_sf424c.py @@ -144,7 +144,7 @@ def test_sf424c_v2_0_construction_total_cost_required(sf424c_v2_0): validation_issues = validate_json_schema_for_form(data, sf424c_v2_0) assert len(validation_issues) == 1 assert validation_issues[0].type == "required" - assert validation_issues[0].field == "$.budget_information.construction" + assert validation_issues[0].field == "$.budget_information.construction.total_cost" def test_sf424c_v2_0_other_rows_total_cost_not_required(sf424c_v2_0): diff --git a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx index f1a8c864ae..31aa94d083 100644 --- a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx +++ b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx @@ -341,4 +341,25 @@ describe("TableCell", () => { expect(input).toHaveAttribute("aria-invalid", "false"); expect(input).not.toHaveAttribute("aria-describedby"); }); + it("wraps long error text without breaking words mid-way", () => { + render( + , + ); + + const errorText = screen.getByText( + /This is a very long validation error message/, + ); + const container = errorText.closest("div"); + + expect(container).toHaveStyle("white-space: normal"); + expect(container).not.toHaveStyle("word-break: break-all"); + expect(container).not.toHaveStyle("overflow-wrap: anywhere"); + }); }); From 8a39ce6a05c7676955316ff8127f5ee4cf15811d Mon Sep 17 00:00:00 2001 From: nkhazi Date: Tue, 4 Aug 2026 10:23:02 -0400 Subject: [PATCH 8/8] Lint fixes --- .../apply-form/widgets/TableWidget/TableCell.test.tsx | 11 +++++------ .../apply-form/widgets/TableWidget/TableCell.tsx | 1 + 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx index 31aa94d083..fc1fafec58 100644 --- a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx +++ b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.test.tsx @@ -353,13 +353,12 @@ describe("TableCell", () => { />, ); - const errorText = screen.getByText( - /This is a very long validation error message/, + const errorContainer = screen.getByTestId( + "input-cell-long-error-error-container", ); - const container = errorText.closest("div"); - expect(container).toHaveStyle("white-space: normal"); - expect(container).not.toHaveStyle("word-break: break-all"); - expect(container).not.toHaveStyle("overflow-wrap: anywhere"); + expect(errorContainer).toHaveStyle("white-space: normal"); + expect(errorContainer).not.toHaveStyle("word-break: break-all"); + expect(errorContainer).not.toHaveStyle("overflow-wrap: anywhere"); }); }); diff --git a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx index 52a59c8847..39f36fe994 100644 --- a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx +++ b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx @@ -130,6 +130,7 @@ function TableCell({ {hasError && (