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..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 @@ -39,6 +39,7 @@ }, "construction": { "allOf": [{"$ref": "#/$defs/budget_row"}], + "required": ["total_cost"], }, "equipment": { "allOf": [{"$ref": "#/$defs/budget_row"}], @@ -145,12 +146,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 +550,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": { diff --git a/api/tests/src/form_schema/forms/test_sf424c.py b/api/tests/src/form_schema/forms/test_sf424c.py index 1c8428e40a..2ee5a6f7f0 100644 --- a/api/tests/src/form_schema/forms/test_sf424c.py +++ b/api/tests/src/form_schema/forms/test_sf424c.py @@ -131,6 +131,35 @@ def test_sf424c_v2_0_percentage_negative(sf424c_v2_0): 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.total_cost" + + +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 ): 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..fc1fafec58 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,24 @@ 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 errorContainer = screen.getByTestId( + "input-cell-long-error-error-container", + ); + + 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 7c6b96f763..39f36fe994 100644 --- a/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx +++ b/frontend/src/components/apply-form/widgets/TableWidget/TableCell.tsx @@ -127,7 +127,19 @@ function TableCell({ const inputId = name ?? id; return ( <> - {hasError && } + {hasError && ( +
+ +
+ )}