From ef214959c1cb68d5802f10f757fe0b87552efa05 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 19 Oct 2026 07:15:33 -0400 Subject: [PATCH 01/10] Household search: categories as a column, search on its own line The variable search's categories were one line of pill buttons that scrolled sideways. They are now a column beside the list, every category in view with its count; on a phone, one category choice above the list. In the report's household form, the open search takes the add link's place on a line of its own under the fields. Its width cap alone kept it in their row, where it had no label and sat above their boxes. On a phone the fields' block no longer grows wider than the screen when the list opens. Co-Authored-By: Claude Opus 5.5 --- .../household/HouseholdBuilderForm.tsx | 65 ++--- .../household/VariableSearchDropdown.tsx | 244 ++++++++++-------- .../household/VariableSearchDropdown.test.tsx | 36 ++- 3 files changed, 204 insertions(+), 141 deletions(-) diff --git a/app/src/components/household/HouseholdBuilderForm.tsx b/app/src/components/household/HouseholdBuilderForm.tsx index 76d8a61b2..6fb0c40f2 100644 --- a/app/src/components/household/HouseholdBuilderForm.tsx +++ b/app/src/components/household/HouseholdBuilderForm.tsx @@ -742,8 +742,9 @@ export default function HouseholdBuilderForm({ >
@@ -800,34 +801,40 @@ export default function HouseholdBuilderForm({ {/* Add variable search or link */} {!isReadOnly && isHouseholdSearchActive ? ( -
- 0 && !householdSearchValue.trim() - ? suggestedOrder - : undefined - } - suggestedLabel={suggestedLabel} - groupOf={variableGroupOf} - groupOrder={variableGroupOrder} - onSelect={handleHouseholdVariableSelect} - getEntityHint={(variable) => { - const { isPerson } = getVariableEntityDisplayInfo(variable.name, metadata); - return { show: isPerson, label: 'Person' }; - }} - disabled={disabled} - onClose={() => { - setIsHouseholdSearchActive(false); - setHouseholdSearchValue(''); - setIsHouseholdSearchFocused(false); - }} - /> -
+ <> + {/* In columns, the search takes the add link's place: a line of + its own under the fields. A width cap alone keeps it in their + row, where it has no label and sits above their boxes. */} + {columns &&
} +
+ 0 && !householdSearchValue.trim() + ? suggestedOrder + : undefined + } + suggestedLabel={suggestedLabel} + groupOf={variableGroupOf} + groupOrder={variableGroupOrder} + onSelect={handleHouseholdVariableSelect} + getEntityHint={(variable) => { + const { isPerson } = getVariableEntityDisplayInfo(variable.name, metadata); + return { show: isPerson, label: 'Person' }; + }} + disabled={disabled} + onClose={() => { + setIsHouseholdSearchActive(false); + setHouseholdSearchValue(''); + setIsHouseholdSearchFocused(false); + }} + /> +
+ ) : !isReadOnly ? ( <> {/* In columns, the add link starts a line of its own, under the fields. */} diff --git a/app/src/components/household/VariableSearchDropdown.tsx b/app/src/components/household/VariableSearchDropdown.tsx index 67b3d35aa..5678fdeac 100644 --- a/app/src/components/household/VariableSearchDropdown.tsx +++ b/app/src/components/household/VariableSearchDropdown.tsx @@ -186,6 +186,18 @@ export default function VariableSearchDropdown({ onFocusChange(false); }; + // Delayed to let a click on an option land; focus that moved into the + // list (the phone's category choice) keeps it open. + const closeUnlessFocusInside = () => { + setTimeout(() => { + const focused = document.activeElement; + if (focused && (focused === inputRef.current || listRef.current?.contains(focused))) { + return; + } + closeDropdown(); + }, 150); + }; + const handleSelect = (variable: VariableInfo) => { onSelect(variable); closeDropdown(); @@ -251,10 +263,7 @@ export default function VariableSearchDropdown({ }} onClick={openDropdown} onFocus={openDropdown} - onBlur={() => { - // Delay to allow click on option - setTimeout(closeDropdown, 150); - }} + onBlur={closeUnlessFocusInside} onKeyDown={handleKeyDown} disabled={disabled} autoFocus @@ -271,119 +280,134 @@ export default function VariableSearchDropdown({ className="tw:mt-1 tw:w-full tw:bg-white tw:border tw:border-gray-200 tw:rounded-lg tw:shadow-lg" > {grouped.chips.length > 1 && ( -
- {[{ key: null, label: 'All' }, ...grouped.chips].map((chip) => ( - - ))} + // On a phone the categories are one compact choice above the list. +
+
)} -
- {loading ? ( -
- +
+ {grouped.chips.length > 1 && ( + // Wider, the categories are a column beside the list: every one + // in view at once, read top to bottom, none scrolled out of sight. +
+ {[{ key: null, label: 'All categories' }, ...grouped.chips].map((chip) => ( + + ))}
- ) : displayVariables.length === 0 ? ( -

- No variables found -

- ) : ( - grouped.sections.map((section) => { - const offset = displayVariables.indexOf(section.items[0]); - return ( -
-

- {section.label} -

- {section.items.map((variable, at) => { - const index = offset + at; - const entityHint = getEntityHint?.(variable); - return ( -
+ {loading ? ( +
+ +
+ ) : displayVariables.length === 0 ? ( +

+ No variables found +

+ ) : ( + grouped.sections.map((section) => { + const offset = displayVariables.indexOf(section.items[0]); + return ( +
+

+ {section.label} +

+ {section.items.map((variable, at) => { + const index = offset + at; + const entityHint = getEntityHint?.(variable); + return ( +
{ + e.preventDefault(); + handleSelect(variable); + }} + > +
+

+ {sentenceCaseLabel(variable.label)} +

+ {variable.documentation && ( +

+ {variable.documentation} +

+ )} +
+ {entityHint?.show && ( + + {entityHint.label} + + )} +
+ ); + })} + {section.more > 0 && ( +
- ); - })} - {section.more > 0 && ( - - )} -
- ); - }) - )} + Show all {section.items.length + section.more} + + )} +
+ ); + }) + )} +
)} diff --git a/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx b/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx index 24a20d115..a07d2718d 100644 --- a/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx +++ b/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx @@ -73,11 +73,43 @@ describe('VariableSearchDropdown', () => { expect(screen.getByText('Income', { selector: 'p' })).toBeInTheDocument(); expect(screen.getByText('Housing', { selector: 'p' })).toBeInTheDocument(); - expect(screen.getAllByRole('option')).toHaveLength(3); + // The list's rows; the phone's category choice has options of its own. + const rows = () => + screen.getAllByRole('option').filter((option) => option.hasAttribute('data-option')); + expect(rows()).toHaveLength(3); await user.click(screen.getByRole('button', { name: /Housing/ })); - expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual(['Rent']); + expect(rows().map((option) => option.textContent)).toEqual(['Rent']); expect(screen.getByRole('button', { name: /Housing/ })).toHaveAttribute('aria-pressed', 'true'); }); + + test('given groups on a phone then one category choice filters the list', async () => { + const user = userEvent.setup(); + const groupOf = (v: VariableInfo) => (v.name === 'rent' ? 'Housing' : 'Income'); + render( + + ); + await user.click(screen.getByRole('textbox')); + + await user.selectOptions( + screen.getByRole('combobox', { name: 'Variable category' }), + 'Housing' + ); + + expect( + screen + .getAllByRole('option') + .filter((option) => option.hasAttribute('data-option')) + .map((option) => option.textContent) + ).toEqual(['Rent']); + }); }); From 9fa10cd813a085d83b40dc54c3333e079e596cff Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 19 Oct 2026 08:21:08 -0400 Subject: [PATCH 02/10] Household search: the grouped list floats over the page at its own width In a narrow person card the list was as wide as the search box, so the category column left no room for the variables. The grouped list now opens as a panel over the page, up to 40rem wide and kept on screen, at a fixed height so it does not move when a category changes. Co-Authored-By: Claude Opus 5.5 --- .../household/VariableSearchDropdown.tsx | 354 ++++++++++-------- 1 file changed, 193 insertions(+), 161 deletions(-) diff --git a/app/src/components/household/VariableSearchDropdown.tsx b/app/src/components/household/VariableSearchDropdown.tsx index 5678fdeac..3dd236b41 100644 --- a/app/src/components/household/VariableSearchDropdown.tsx +++ b/app/src/components/household/VariableSearchDropdown.tsx @@ -5,7 +5,16 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { IconChevronDown, IconSearch, IconX } from '@tabler/icons-react'; -import { Button, Spinner, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; +import { + Button, + Popover, + PopoverAnchor, + PopoverContent, + Spinner, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui'; import { cn } from '@/lib/utils'; import { VariableInfo } from '@/utils/VariableResolver'; @@ -85,6 +94,7 @@ export default function VariableSearchDropdown({ const [selectedIndex, setSelectedIndex] = useState(-1); const inputRef = useRef(null); const listRef = useRef(null); + const anchorRef = useRef(null); // Sort so matching-context variables appear first (within the suggested ones, then the rest) const sortedVariables = useMemo(() => { @@ -241,176 +251,198 @@ export default function VariableSearchDropdown({ return (
-
- - { - onSearchChange(e.currentTarget.value); - if (!isOpen) { - openDropdown(); - } - }} - onClick={openDropdown} - onFocus={openDropdown} - onBlur={closeUnlessFocusInside} - onKeyDown={handleKeyDown} - disabled={disabled} - autoFocus - /> - -
+ {/* A grouped list floats over the page at its own width: in a narrow + person card, a list as wide as the box leaves no room for both the + categories and the variables. */} + + +
+ + { + onSearchChange(e.currentTarget.value); + if (!isOpen) { + openDropdown(); + } + }} + onClick={openDropdown} + onFocus={openDropdown} + onBlur={closeUnlessFocusInside} + onKeyDown={handleKeyDown} + disabled={disabled} + autoFocus + /> + +
+
- {isOpen && grouped && ( -
- {grouped.chips.length > 1 && ( - // On a phone the categories are one compact choice above the list. -
- -
- )} -
+ {grouped && ( + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + onEscapeKeyDown={closeDropdown} + onInteractOutside={(e) => { + if (anchorRef.current?.contains(e.target as Node)) { + e.preventDefault(); + return; + } + closeDropdown(); + }} + className="tw:w-[min(40rem,calc(100vw-2rem))] tw:rounded-lg tw:border tw:border-gray-200 tw:bg-white tw:p-0 tw:shadow-lg" + > {grouped.chips.length > 1 && ( - // Wider, the categories are a column beside the list: every one - // in view at once, read top to bottom, none scrolled out of sight. -
- {[{ key: null, label: 'All categories' }, ...grouped.chips].map((chip) => ( - - ))} + // On a phone the categories are one compact choice above the list. +
+
)} -
- {loading ? ( -
- +
+ {grouped.chips.length > 1 && ( + // Wider, the categories are a column beside the list: every one + // in view at once, read top to bottom, none scrolled out of sight. +
+ {[{ key: null, label: 'All categories' }, ...grouped.chips].map((chip) => ( + + ))}
- ) : displayVariables.length === 0 ? ( -

- No variables found -

- ) : ( - grouped.sections.map((section) => { - const offset = displayVariables.indexOf(section.items[0]); - return ( -
-

- {section.label} -

- {section.items.map((variable, at) => { - const index = offset + at; - const entityHint = getEntityHint?.(variable); - return ( -
+ {loading ? ( +
+ +
+ ) : displayVariables.length === 0 ? ( +

+ No variables found +

+ ) : ( + grouped.sections.map((section) => { + const offset = displayVariables.indexOf(section.items[0]); + return ( +
+

+ {section.label} +

+ {section.items.map((variable, at) => { + const index = offset + at; + const entityHint = getEntityHint?.(variable); + return ( +
{ + e.preventDefault(); + handleSelect(variable); + }} + > +
+

+ {sentenceCaseLabel(variable.label)} +

+ {variable.documentation && ( +

+ {variable.documentation} +

+ )} +
+ {entityHint?.show && ( + + {entityHint.label} + + )} +
+ ); + })} + {section.more > 0 && ( +
- ); - })} - {section.more > 0 && ( - - )} -
- ); - }) - )} + Show all {section.items.length + section.more} + + )} +
+ ); + }) + )} +
-
-
- )} +
+ )} + {isOpen && !grouped && (
Date: Mon, 19 Oct 2026 08:26:17 -0400 Subject: [PATCH 03/10] Household search: the category column names each category without a count Co-Authored-By: Claude Opus 5.5 --- app/src/components/household/VariableSearchDropdown.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/src/components/household/VariableSearchDropdown.tsx b/app/src/components/household/VariableSearchDropdown.tsx index 3dd236b41..7cc5ba655 100644 --- a/app/src/components/household/VariableSearchDropdown.tsx +++ b/app/src/components/household/VariableSearchDropdown.tsx @@ -321,7 +321,7 @@ export default function VariableSearchDropdown({ {grouped.chips.map((chip) => ( ))} @@ -347,18 +347,13 @@ export default function VariableSearchDropdown({ setActiveGroup(chip.key); }} className={cn( - 'tw:flex tw:w-full tw:cursor-pointer tw:items-center tw:justify-between tw:gap-2 tw:rounded-md tw:px-2 tw:py-1.5 tw:text-left tw:text-sm tw:transition-colors', + 'tw:flex tw:w-full tw:cursor-pointer tw:items-center tw:rounded-md tw:px-2 tw:py-1.5 tw:text-left tw:text-sm tw:transition-colors', activeGroup === chip.key ? 'tw:bg-primary-50 tw:font-medium tw:text-primary-700' : 'tw:text-gray-700 tw:hover:bg-gray-50' )} > {chip.label} - {chip.key !== null && 'items' in chip && ( - - {chip.items.length} - - )} ))}
From e834585dc73f39179b04ffb2462e752b48c7bfcc Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 19 Oct 2026 08:31:14 -0400 Subject: [PATCH 04/10] Household search: the grouped search opens as a centered dialog The floating list still took its size from where it opened. The grouped search is now a dialog of a standard size (up to 56rem wide, 40rem tall, kept inside the screen), centered over the page: its title names who the variable is for, the search sits in its header, and the category column and the list fill the rest. Picking a variable adds it and closes the dialog; Esc, the close button or a click outside closes it too. The plain list (v2) is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../household/HouseholdBuilderForm.tsx | 2 + .../household/VariableSearchDropdown.tsx | 359 ++++++++++-------- .../household/VariableSearchDropdown.test.tsx | 2 + 3 files changed, 195 insertions(+), 168 deletions(-) diff --git a/app/src/components/household/HouseholdBuilderForm.tsx b/app/src/components/household/HouseholdBuilderForm.tsx index 6fb0c40f2..c7972eec9 100644 --- a/app/src/components/household/HouseholdBuilderForm.tsx +++ b/app/src/components/household/HouseholdBuilderForm.tsx @@ -689,6 +689,7 @@ export default function HouseholdBuilderForm({ groupOf={variableGroupOf} groupOrder={variableGroupOrder} onSelect={(variable) => handlePersonVariableSelect(variable, person)} + title={`Add a variable to ${getPersonDisplayName(person)}`} getEntityHint={(variable) => { const { isPerson } = getVariableEntityDisplayInfo( variable.name, @@ -822,6 +823,7 @@ export default function HouseholdBuilderForm({ groupOf={variableGroupOf} groupOrder={variableGroupOrder} onSelect={handleHouseholdVariableSelect} + title="Add a variable to your household" getEntityHint={(variable) => { const { isPerson } = getVariableEntityDisplayInfo(variable.name, metadata); return { show: isPerson, label: 'Person' }; diff --git a/app/src/components/household/VariableSearchDropdown.tsx b/app/src/components/household/VariableSearchDropdown.tsx index 7cc5ba655..be19cc1ef 100644 --- a/app/src/components/household/VariableSearchDropdown.tsx +++ b/app/src/components/household/VariableSearchDropdown.tsx @@ -7,9 +7,9 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { IconChevronDown, IconSearch, IconX } from '@tabler/icons-react'; import { Button, - Popover, - PopoverAnchor, - PopoverContent, + Dialog, + DialogContent, + DialogTitle, Spinner, Tooltip, TooltipContent, @@ -37,6 +37,8 @@ export interface VariableSearchDropdownProps { /** Variables listed first under their own heading; the rest follow under "All variables". */ suggested?: ReadonlyMap; suggestedLabel?: string; + /** With `groupOf`: the dialog's title. */ + title?: string; /** * Groups the list (with chips to show one group) when given; the plain * list otherwise. The order names the groups in the order they show. @@ -86,6 +88,7 @@ export default function VariableSearchDropdown({ suggested, suggestedLabel = 'Suggested', groupOf, + title = 'Add a variable', groupOrder = [], }: VariableSearchDropdownProps) { const [isOpen, setIsOpen] = useState(false); @@ -94,7 +97,6 @@ export default function VariableSearchDropdown({ const [selectedIndex, setSelectedIndex] = useState(-1); const inputRef = useRef(null); const listRef = useRef(null); - const anchorRef = useRef(null); // Sort so matching-context variables appear first (within the suggested ones, then the rest) const sortedVariables = useMemo(() => { @@ -248,15 +250,30 @@ export default function VariableSearchDropdown({ inputRef.current?.focus(); }, []); - return ( -
-
- {/* A grouped list floats over the page at its own width: in a narrow - person card, a list as wide as the box leaves no room for both the - categories and the variables. */} - - -
+ // Grouped, the search is a dialog of its own, centered over the page: a + // list in place takes the width of where it opens — in a person card, + // too narrow for the categories and the variables side by side. + if (grouped) { + return ( + { + if (!open) { + handleClose(); + } + }} + > + { + e.preventDefault(); + inputRef.current?.focus(); + }} + className="tw:flex tw:h-[min(40rem,85vh)] tw:flex-col tw:gap-0 tw:overflow-hidden tw:bg-white tw:p-0 tw:sm:max-w-[56rem]" + > +
+ {title} +
{ - onSearchChange(e.currentTarget.value); - if (!isOpen) { - openDropdown(); - } - }} - onClick={openDropdown} - onFocus={openDropdown} - onBlur={closeUnlessFocusInside} + onChange={(e) => onSearchChange(e.currentTarget.value)} onKeyDown={handleKeyDown} disabled={disabled} autoFocus /> -
- - - {grouped && ( - e.preventDefault()} - onCloseAutoFocus={(e) => e.preventDefault()} - onEscapeKeyDown={closeDropdown} - onInteractOutside={(e) => { - if (anchorRef.current?.contains(e.target as Node)) { - e.preventDefault(); - return; - } - closeDropdown(); - }} - className="tw:w-[min(40rem,calc(100vw-2rem))] tw:rounded-lg tw:border tw:border-gray-200 tw:bg-white tw:p-0 tw:shadow-lg" - > +
+
+ {grouped.chips.length > 1 && ( + // On a phone the categories are one compact choice above the list. +
+ +
+ )} +
{grouped.chips.length > 1 && ( - // On a phone the categories are one compact choice above the list. -
- + // Wider, the categories are a column beside the list: every one + // in view at once, read top to bottom, none scrolled out of sight. +
+ {[{ key: null, label: 'All categories' }, ...grouped.chips].map((chip) => ( + + ))}
)} -
- {grouped.chips.length > 1 && ( - // Wider, the categories are a column beside the list: every one - // in view at once, read top to bottom, none scrolled out of sight. -
- {[{ key: null, label: 'All categories' }, ...grouped.chips].map((chip) => ( - - ))} +
+ {loading ? ( +
+
- )} -
- {loading ? ( -
- -
- ) : displayVariables.length === 0 ? ( -

- No variables found -

- ) : ( - grouped.sections.map((section) => { - const offset = displayVariables.indexOf(section.items[0]); - return ( -
-

- {section.label} -

- {section.items.map((variable, at) => { - const index = offset + at; - const entityHint = getEntityHint?.(variable); - return ( -
{ - e.preventDefault(); - handleSelect(variable); - }} - > -
-

- {sentenceCaseLabel(variable.label)} -

- {variable.documentation && ( -

- {variable.documentation} -

- )} -
- {entityHint?.show && ( - - {entityHint.label} - - )} -
- ); - })} - {section.more > 0 && ( - - )} -
- ); - }) - )} -
+
+

+ {sentenceCaseLabel(variable.label)} +

+ {variable.documentation && ( +

+ {variable.documentation} +

+ )} +
+ {entityHint?.show && ( + + {entityHint.label} + + )} +
+ ); + })} + {section.more > 0 && ( + + )} +
+ ); + }) + )}
- - )} - +
+
+ +
+ ); + } + + return ( +
+
+
+ + { + onSearchChange(e.currentTarget.value); + if (!isOpen) { + openDropdown(); + } + }} + onClick={openDropdown} + onFocus={openDropdown} + onBlur={closeUnlessFocusInside} + onKeyDown={handleKeyDown} + disabled={disabled} + autoFocus + /> + +
{isOpen && !grouped && (
{ expect(screen.getByText('Income', { selector: 'p' })).toBeInTheDocument(); expect(screen.getByText('Housing', { selector: 'p' })).toBeInTheDocument(); + // Grouped, the search opens as a dialog of its own. + expect(screen.getByRole('dialog', { name: 'Add a variable' })).toBeInTheDocument(); // The list's rows; the phone's category choice has options of its own. const rows = () => screen.getAllByRole('option').filter((option) => option.hasAttribute('data-option')); From 576ffa8c1259e42b01cd05556e0db27f2b14d97e Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 19 Oct 2026 08:40:14 -0400 Subject: [PATCH 05/10] Household search: one entity per search, income in parts, survey inputs left out - searchByEntity (opt-in): a person's search lists person inputs only; the household's lists every group entity's (household, tax unit, SPM unit, family, marital unit, one of each in the builder). With every row the search's own entity, the per-row Person/Household badge goes. - A chosen category with parts reads part by part: Income splits into wages and work, self-employment and business, investments, retirement and other income, each in label order. - v3 leaves out inputs that do not describe a household: SPM-unit survey totals, CBO incidence inputs, reported amounts and deprecated inputs. Co-Authored-By: Claude Opus 5.5 --- .../household/HouseholdImpactSection.tsx | 11 ++- .../household/HouseholdBuilderForm.tsx | 52 +++++++--- .../household/VariableSearchDropdown.tsx | 96 +++++++++++++------ .../libs/flagship/householdVariableGroups.ts | 73 ++++++++++++++ .../household/HouseholdBuilderForm.test.tsx | 38 ++++++++ .../flagship/householdVariableGroups.test.ts | 65 ++++++++++++- 6 files changed, 293 insertions(+), 42 deletions(-) diff --git a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx index e56423f66..da2c4c624 100644 --- a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx +++ b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx @@ -11,8 +11,11 @@ import { colors, spacing, typography } from '@/designTokens'; import { useFlagshipHouseholdPayload } from '@/hooks/useFlagshipHousehold'; import { suggestedHouseholdInputs } from '@/libs/flagship/householdSuggestions'; import { + describesAHousehold, + HOUSEHOLD_INCOME_SUBGROUPS, HOUSEHOLD_VARIABLE_GROUPS, householdVariableGroup, + householdVariableSubgroup, listsForState, } from '@/libs/flagship/householdVariableGroups'; import { loadParameterDependencies } from '@/libs/flagship/parameterDependencies'; @@ -224,10 +227,11 @@ function HouseholdCard({ .flatMap(([, fields]) => fields), [basicInputFields] ); - // The variable search lists only the household's own state's programs. + // The variable search lists only the household's own state's programs, + // and only inputs that describe a household (no survey-reported totals). const stateCode = householdState(household, year); const variableFilter = useCallback( - (variable: VariableInfo) => listsForState(variable, stateCode), + (variable: VariableInfo) => listsForState(variable, stateCode) && describesAHousehold(variable), [stateCode] ); // Whether the household can run, as the household builder checks it. @@ -336,6 +340,9 @@ function HouseholdCard({ variableGroupOf={householdVariableGroup} variableGroupOrder={HOUSEHOLD_VARIABLE_GROUPS} variableFilter={variableFilter} + variableSubgroupOf={householdVariableSubgroup} + variableSubgroupOrder={HOUSEHOLD_INCOME_SUBGROUPS} + searchByEntity />
boolean; + /** + * Each search lists only its own entity's inputs: a person's, person + * variables; the household's, every group entity's (household, tax + * unit, SPM unit, family, marital unit — one of each here). Off, either + * search lists every input and routes a pick to its entity, with a note. + */ + searchByEntity?: boolean; + /** With `variableGroupOf`: a group's parts, as subheadings when that group is chosen. */ + variableSubgroupOf?: (variable: VariableInfo) => string | null; + variableSubgroupOrder?: readonly string[]; } /** @@ -199,6 +209,9 @@ export default function HouseholdBuilderForm({ variableGroupOf, variableGroupOrder, variableFilter, + searchByEntity = false, + variableSubgroupOf, + variableSubgroupOrder, disabled = false, isReadOnly = false, }: HouseholdBuilderFormProps) { @@ -270,19 +283,32 @@ export default function HouseholdBuilderForm({ ...list.filter((variable) => !suggestedOrder.has(variable.name)), ]; + // Each entity's own inputs, when each search lists only its own. + const scopedInputs = useMemo(() => { + if (!searchByEntity) { + return { person: allInputVariables, group: allInputVariables }; + } + const person: VariableInfo[] = []; + const group: VariableInfo[] = []; + for (const variable of allInputVariables) { + (resolveEntity(variable.name, metadata)?.isPerson === false ? group : person).push(variable); + } + return { person, group }; + }, [allInputVariables, metadata, searchByEntity]); + // Filter variables by search value with result limit for performance - const filterVariables = (searchValue: string) => { + const filterVariables = (searchValue: string, inputs: VariableInfo[] = allInputVariables) => { if (!searchValue.trim()) { return { - variables: leadWithSuggested(allInputVariables).slice(0, MAX_DROPDOWN_RESULTS), - truncated: allInputVariables.length > MAX_DROPDOWN_RESULTS, + variables: leadWithSuggested(inputs).slice(0, MAX_DROPDOWN_RESULTS), + truncated: inputs.length > MAX_DROPDOWN_RESULTS, }; } const search = searchValue.toLowerCase(); const filtered = searchRanking === 'relevance' - ? rankVariablesBySearch(allInputVariables, searchValue) - : allInputVariables.filter( + ? rankVariablesBySearch(inputs, searchValue) + : inputs.filter( (v) => v.label.toLowerCase().includes(search) || v.name.toLowerCase().includes(search) ); return { @@ -294,14 +320,14 @@ export default function HouseholdBuilderForm({ // Memoized filtered variables for person search const filteredPersonVariables = useMemo( - () => filterVariables(personSearchValue), - [personSearchValue, allInputVariables, suggestedOrder] + () => filterVariables(personSearchValue, scopedInputs.person), + [personSearchValue, scopedInputs, suggestedOrder] ); // Memoized filtered variables for household search const filteredHouseholdVariables = useMemo( - () => filterVariables(householdSearchValue), - [householdSearchValue, allInputVariables, suggestedOrder] + () => filterVariables(householdSearchValue, scopedInputs.group), + [householdSearchValue, scopedInputs, suggestedOrder] ); const resolveDefaultGroupInstanceName = (variableName: string): string | undefined => { @@ -688,6 +714,8 @@ export default function HouseholdBuilderForm({ suggestedLabel={suggestedLabel} groupOf={variableGroupOf} groupOrder={variableGroupOrder} + subgroupOf={variableSubgroupOf} + subgroupOrder={variableSubgroupOrder} onSelect={(variable) => handlePersonVariableSelect(variable, person)} title={`Add a variable to ${getPersonDisplayName(person)}`} getEntityHint={(variable) => { @@ -695,7 +723,7 @@ export default function HouseholdBuilderForm({ variable.name, metadata ); - return { show: !isPerson, label: 'Household' }; + return { show: !searchByEntity && !isPerson, label: 'Household' }; }} disabled={disabled} onClose={() => { @@ -822,11 +850,13 @@ export default function HouseholdBuilderForm({ suggestedLabel={suggestedLabel} groupOf={variableGroupOf} groupOrder={variableGroupOrder} + subgroupOf={variableSubgroupOf} + subgroupOrder={variableSubgroupOrder} onSelect={handleHouseholdVariableSelect} title="Add a variable to your household" getEntityHint={(variable) => { const { isPerson } = getVariableEntityDisplayInfo(variable.name, metadata); - return { show: isPerson, label: 'Person' }; + return { show: !searchByEntity && isPerson, label: 'Person' }; }} disabled={disabled} onClose={() => { diff --git a/app/src/components/household/VariableSearchDropdown.tsx b/app/src/components/household/VariableSearchDropdown.tsx index be19cc1ef..63df669e1 100644 --- a/app/src/components/household/VariableSearchDropdown.tsx +++ b/app/src/components/household/VariableSearchDropdown.tsx @@ -37,6 +37,12 @@ export interface VariableSearchDropdownProps { /** Variables listed first under their own heading; the rest follow under "All variables". */ suggested?: ReadonlyMap; suggestedLabel?: string; + /** + * With `groupOf`: a group's parts, shown as subheadings when that group + * is chosen (Income → wages, investments, …), in `subgroupOrder`. + */ + subgroupOf?: (variable: VariableInfo) => string | null; + subgroupOrder?: readonly string[]; /** With `groupOf`: the dialog's title. */ title?: string; /** @@ -89,6 +95,8 @@ export default function VariableSearchDropdown({ suggestedLabel = 'Suggested', groupOf, title = 'Add a variable', + subgroupOf, + subgroupOrder = [], groupOrder = [], }: VariableSearchDropdownProps) { const [isOpen, setIsOpen] = useState(false); @@ -159,7 +167,21 @@ export default function VariableSearchDropdown({ .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b)) .map(([group, items]) => ({ key: group, label: group, items, more: 0 })), ]; - const chosen = all.find((section) => section.key === activeGroup); + let chosen = all.find((section) => section.key === activeGroup); + // A chosen group with parts reads part by part, each in label order; + // a typed search keeps its own ranking. + if (chosen && subgroupOf && !searchValue.trim()) { + const partRank = (variable: VariableInfo) => { + const index = subgroupOrder.indexOf(subgroupOf(variable) ?? ''); + return index === -1 ? subgroupOrder.length : index; + }; + chosen = { + ...chosen, + items: [...chosen.items].sort( + (a, b) => partRank(a) - partRank(b) || a.label.localeCompare(b.label) + ), + }; + } const sections = chosen ? [chosen] : all.map((section) => ({ @@ -176,6 +198,9 @@ export default function VariableSearchDropdown({ suggestedCount, suggestedLabel, activeGroup, + subgroupOf, + subgroupOrder, + searchValue, ]); // The options in the order they show: the grouped list's, or the plain one. const displayVariables = grouped @@ -368,38 +393,53 @@ export default function VariableSearchDropdown({ {section.items.map((variable, at) => { const index = offset + at; const entityHint = getEntityHint?.(variable); + const part = + activeGroup === section.key && subgroupOf ? subgroupOf(variable) : null; + const partStarts = + !!part && (at === 0 || subgroupOf!(section.items[at - 1]) !== part); return ( -
{ - e.preventDefault(); - handleSelect(variable); - }} - > -
-

- {sentenceCaseLabel(variable.label)} + + {partStarts && ( +

+ {part}

- {variable.documentation && ( -

- {variable.documentation} + )} +

{ + e.preventDefault(); + handleSelect(variable); + }} + > +
+

+ {sentenceCaseLabel(variable.label)}

+ {variable.documentation && ( +

+ {variable.documentation} +

+ )} +
+ {entityHint?.show && ( + + {entityHint.label} + )}
- {entityHint?.show && ( - - {entityHint.label} - - )} -
+ ); })} {section.more > 0 && ( diff --git a/app/src/libs/flagship/householdVariableGroups.ts b/app/src/libs/flagship/householdVariableGroups.ts index 2533ff43f..936ae2512 100644 --- a/app/src/libs/flagship/householdVariableGroups.ts +++ b/app/src/libs/flagship/householdVariableGroups.ts @@ -67,3 +67,76 @@ export function listsForState(variable: VariableInfo, stateCode: string | null): const state = programState(variable); return state === null || (!!stateCode && state === stateCode.toLowerCase()); } + +/** Income's parts, in the order a reader looks for them. */ +export const HOUSEHOLD_INCOME_SUBGROUPS = [ + 'Wages and work', + 'Self-employment and business', + 'Investments', + 'Retirement', + 'Other income', +] as const; + +const WORK_MODULES = new Set([ + 'hourly_wage', + 'hours_worked_last_week', + 'is_paid_hourly', + 'is_union_member_or_covered', + 'tip_income', + 'fsla_overtime_premium', + 'weekly_hours_worked', + 'dependent_care_employer_benefits', +]); +const BUSINESS_MODULES = new Set(['self_employment', 'farm', 'estate']); +const INVESTMENT_MODULES = new Set([ + 'capital_gains', + 'dividends', + 'interest', + 'us_govt_interest_person', + 'property_sales_net_capital_gain', + 'form_4972_lumpsum_distributions', +]); + +/** + * The part of its group a variable reads under, from its module: Income + * splits into wages, business, investments, retirement and the rest. + * Null for groups without parts. + */ +export function householdVariableSubgroup(variable: VariableInfo): string | null { + if (householdVariableGroup(variable) !== 'Income') { + return null; + } + // `household.income.person.dividends…` → "dividends"; a module at the top keeps its own name. + const parts = (variable.moduleName ?? '').split('.'); + const leaf = ['person', 'tax_unit', 'spm_unit', 'household'].includes(parts[2]) + ? parts[3] + : parts[2]; + if (WORK_MODULES.has(leaf)) { + return 'Wages and work'; + } + if (BUSINESS_MODULES.has(leaf)) { + return 'Self-employment and business'; + } + if (INVESTMENT_MODULES.has(leaf)) { + return 'Investments'; + } + if (leaf === 'retirement') { + return 'Retirement'; + } + return 'Other income'; +} + +/** + * Inputs a reader does not describe a household with: amounts the survey + * reports for a whole unit, analysts' incidence inputs, and inputs the + * model keeps only for old API callers. + */ +export function describesAHousehold(variable: VariableInfo): boolean { + const module = variable.moduleName ?? ''; + return !( + /^deprecated/i.test(variable.documentation ?? '') || + /(^|_)reported($|_)/.test(variable.name) || + module.startsWith('household.income.spm_unit') || + module.startsWith('household.income.household.cbo') + ); +} diff --git a/app/src/tests/unit/components/household/HouseholdBuilderForm.test.tsx b/app/src/tests/unit/components/household/HouseholdBuilderForm.test.tsx index 6ab42d65d..ec658f320 100644 --- a/app/src/tests/unit/components/household/HouseholdBuilderForm.test.tsx +++ b/app/src/tests/unit/components/household/HouseholdBuilderForm.test.tsx @@ -81,6 +81,44 @@ describe('rankVariablesBySearch', () => { ]; const names = (list: VariableInfo[]) => list.map((entry) => entry.name); + test('given searchByEntity then a person lists person inputs and the household the rest', async () => { + const variable = (name: string, label: string, entity: string) => ({ + name, + label, + entity, + isInputVariable: true, + valueType: 'float', + moduleName: 'household.income.person.misc', + }); + const metadata = { + entities: { + person: { plural: 'people', label: 'Person', is_person: true }, + tax_unit: { plural: 'tax_units', label: 'Tax unit' }, + household: { plural: 'households', label: 'Household' }, + }, + variables: { + tip_income: variable('tip_income', 'Tip income', 'person'), + rent: variable('rent', 'Rent', 'household'), + tax_unit_childcare: variable('tax_unit_childcare', 'Childcare', 'tax_unit'), + }, + }; + const groupOf = () => 'Income'; + const optionsIn = () => + screen + .getAllByRole('option') + .filter((option) => option.hasAttribute('data-option')) + .map((option) => option.textContent); + const { unmount } = renderForm({ metadata, searchByEntity: true, variableGroupOf: groupOf }); + + fireEvent.click(screen.getByRole('button', { name: /Add variable to you$/ })); + expect(optionsIn()).toEqual(['Tip income']); + unmount(); + + renderForm({ metadata, searchByEntity: true, variableGroupOf: groupOf }); + fireEvent.click(screen.getByRole('button', { name: 'Add variable to your household' })); + expect(optionsIn().sort()).toEqual(['Childcare', 'Rent']); + }); + test('given a word then whole words lead, and parts of words follow', () => { expect(names(rankVariablesBySearch(VARIABLES, 'rent'))).toEqual([ 'rent', diff --git a/app/src/tests/unit/libs/flagship/householdVariableGroups.test.ts b/app/src/tests/unit/libs/flagship/householdVariableGroups.test.ts index 9655b5945..1f9ae09fb 100644 --- a/app/src/tests/unit/libs/flagship/householdVariableGroups.test.ts +++ b/app/src/tests/unit/libs/flagship/householdVariableGroups.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { householdVariableGroup, listsForState } from '@/libs/flagship/householdVariableGroups'; +import { + describesAHousehold, + householdVariableGroup, + householdVariableSubgroup, + listsForState, +} from '@/libs/flagship/householdVariableGroups'; import type { VariableInfo } from '@/utils/VariableResolver'; const variable = (moduleName: string) => ({ name: 'x', label: 'X', moduleName }) as VariableInfo; @@ -36,3 +41,61 @@ describe('listsForState', () => { expect(listsForState(variable('gov.usda.snap'), null)).toBe(true); }); }); + +describe('householdVariableSubgroup', () => { + test('given income modules then each reads under its part', () => { + expect(householdVariableSubgroup(variable('household.income.person.hourly_wage'))).toBe( + 'Wages and work' + ); + expect(householdVariableSubgroup(variable('household.income.person.farm'))).toBe( + 'Self-employment and business' + ); + expect(householdVariableSubgroup(variable('household.income.person.dividends'))).toBe( + 'Investments' + ); + expect( + householdVariableSubgroup( + variable('household.income.tax_unit.property_sales_net_capital_gain') + ) + ).toBe('Investments'); + expect(householdVariableSubgroup(variable('household.income.person.retirement'))).toBe( + 'Retirement' + ); + expect(householdVariableSubgroup(variable('household.income.illicit_income'))).toBe( + 'Other income' + ); + }); + + test('given a group without parts then there is none', () => { + expect(householdVariableSubgroup(variable('household.expense.health'))).toBeNull(); + }); +}); + +describe('describesAHousehold', () => { + const input = (name: string, moduleName: string, documentation?: string) => + ({ name, label: name, moduleName, documentation }) as VariableInfo; + + test('given survey totals, incidence inputs or deprecated inputs then they are left out', () => { + expect( + describesAHousehold(input('spm_unit_snap', 'household.income.spm_unit.spm_unit_snap')) + ).toBe(false); + expect( + describesAHousehold(input('cbo_excise_tax', 'household.income.household.cbo_excise_tax')) + ).toBe(false); + expect(describesAHousehold(input('ssi_reported', 'household.income.person.ssi_reported'))).toBe( + false + ); + expect( + describesAHousehold( + input('use_reported_ssi', 'household.income.person.use_reported_ssi', 'Deprecated: …') + ) + ).toBe(false); + }); + + test('given what a household is described by then it is kept', () => { + expect(describesAHousehold(input('rent', 'household.expense.housing.rent'))).toBe(true); + expect(describesAHousehold(input('tip_income', 'household.income.person.tip_income'))).toBe( + true + ); + }); +}); From a2152b61a46bdf300b51468aa9a3f8ace0f25561 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 19 Oct 2026 08:51:47 -0400 Subject: [PATCH 06/10] Household search: rows show names only; the pointed-at one is described below Each row's description made the list a wall of text. Rows are now one line, the variable's name. The row under the pointer, or the one the arrow keys reach, is described once, in a footer under the list. Co-Authored-By: Claude Opus 5.5 --- .../household/VariableSearchDropdown.tsx | 36 +++++++++++++------ .../household/VariableSearchDropdown.test.tsx | 25 +++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/app/src/components/household/VariableSearchDropdown.tsx b/app/src/components/household/VariableSearchDropdown.tsx index 63df669e1..c5b4d8a17 100644 --- a/app/src/components/household/VariableSearchDropdown.tsx +++ b/app/src/components/household/VariableSearchDropdown.tsx @@ -418,21 +418,17 @@ export default function VariableSearchDropdown({ ? 'tw:bg-primary-50' : 'tw:hover:bg-gray-50' )} + // Pointing at a row shows its description below the list. + onMouseEnter={() => setSelectedIndex(index)} onMouseDown={(e) => { e.preventDefault(); handleSelect(variable); }} > -
-

- {sentenceCaseLabel(variable.label)} -

- {variable.documentation && ( -

- {variable.documentation} -

- )} -
+ {/* A name only: the description shows once, below the list. */} +

+ {sentenceCaseLabel(variable.label)} +

{entityHint?.show && ( {entityHint.label} @@ -461,6 +457,26 @@ export default function VariableSearchDropdown({
+ {/* What the pointed-at (or arrowed-to) variable is, in one place. */} +
+ {displayVariables[selectedIndex] ? ( + <> +

+ {sentenceCaseLabel(displayVariables[selectedIndex].label)} +

+

+ {displayVariables[selectedIndex].documentation || 'No description in the model.'} +

+ + ) : ( +

+ Point to a variable to see what it means. +

+ )} +
); diff --git a/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx b/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx index 59a8dbb15..fea55dcc6 100644 --- a/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx +++ b/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx @@ -114,4 +114,29 @@ describe('VariableSearchDropdown', () => { .map((option) => option.textContent) ).toEqual(['Rent']); }); + + test('given a grouped list then rows show names, and the pointed-at one is described below', async () => { + const user = userEvent.setup(); + const described = [ + { ...VARIABLES[2], documentation: 'Rent paid for the home' }, + VARIABLES[0], + ] as VariableInfo[]; + render( + 'Housing'} + /> + ); + + expect(screen.queryByText('Rent paid for the home')).not.toBeInTheDocument(); + expect(screen.getByText('Point to a variable to see what it means.')).toBeInTheDocument(); + + await user.hover(screen.getByRole('option', { name: 'Rent' })); + + expect(screen.getByText('Rent paid for the home')).toBeInTheDocument(); + }); }); From a0a852976aec04765847762a52187a6157d2914e Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 19 Oct 2026 09:07:52 -0400 Subject: [PATCH 07/10] Household picker: common inputs first, as tiles; topics to browse The variable dialog opens on what most households need: up to nine common inputs as tiles with short names and icons (rent, self-employment income, tips, unemployment, Social Security, pension, interest, medical expenses; child care, utilities, mortgage and assets for the household), then the reform's related inputs. Topics, each with an icon, sit in a column beside it (tiles on a phone). A topic lists its inputs by name, one line each, part by part; a search lists its matches. The input under the pointer is described once, below the list. The dialog is its own component, VariablePickerDialog, which the dropdown opens when given groups; the inline list (v2) is unchanged. A scoped search also leaves out inputs the person or household already has. Co-Authored-By: Claude Opus 5.5 --- .../household/HouseholdImpactSection.tsx | 8 + .../household/householdPickerInputs.tsx | 139 +++++++ .../household/HouseholdBuilderForm.tsx | 51 ++- .../household/VariablePickerDialog.tsx | 385 ++++++++++++++++++ .../household/VariableSearchDropdown.tsx | 342 ++-------------- .../household/HouseholdBuilderForm.test.tsx | 2 + .../household/VariablePickerDialog.test.tsx | 109 +++++ .../household/VariableSearchDropdown.test.tsx | 85 ---- 8 files changed, 724 insertions(+), 397 deletions(-) create mode 100644 app/src/components/flagship/report/household/householdPickerInputs.tsx create mode 100644 app/src/components/household/VariablePickerDialog.tsx create mode 100644 app/src/tests/unit/components/household/VariablePickerDialog.test.tsx diff --git a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx index da2c4c624..ee829fb54 100644 --- a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx +++ b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx @@ -3,6 +3,11 @@ import { IconPencil, IconPlus, IconTrash } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import { STATE_NAMES } from '@/api/billFeed'; import HouseholdImpactResult from '@/components/flagship/report/household/HouseholdImpactResult'; +import { + COMMON_HOUSEHOLD_INPUTS, + householdInputName, + householdTopicIcon, +} from '@/components/flagship/report/household/householdPickerInputs'; import RunProgress from '@/components/flagship/report/RunProgress'; import { ErrorState } from '@/components/flagship/states'; import HouseholdBuilderForm from '@/components/household/HouseholdBuilderForm'; @@ -343,6 +348,9 @@ function HouseholdCard({ variableSubgroupOf={householdVariableSubgroup} variableSubgroupOrder={HOUSEHOLD_INCOME_SUBGROUPS} searchByEntity + variablePickerCommon={COMMON_HOUSEHOLD_INPUTS} + variableGroupIcon={householdTopicIcon} + variableNameOf={householdInputName} />
}, + { + name: 'self_employment_income_before_lsr', + label: 'Self-employment income', + icon: , + }, + { name: 'tip_income', label: 'Tips', icon: }, + { + name: 'unemployment_compensation', + label: 'Unemployment benefits', + icon: , + }, + { + name: 'social_security_retirement', + label: 'Social Security retirement', + icon: , + }, + { + name: 'social_security_disability', + label: 'Social Security disability', + icon: , + }, + { + name: 'taxable_private_pension_income', + label: 'Pension income', + icon: , + }, + { name: 'taxable_interest_income', label: 'Interest income', icon: }, + { + name: 'other_medical_expenses', + label: 'Medical expenses', + icon: , + }, + { + name: 'qualified_dividend_income', + label: 'Qualified dividends', + icon: , + }, + { + name: 'child_support_received', + label: 'Child support received', + icon: , + }, + { + name: 'health_insurance_premiums', + label: 'Health insurance premiums', + icon: , + }, + { name: 'student_loan_interest', label: 'Student loan interest', icon: }, + // The household's + { + name: 'spm_unit_pre_subsidy_childcare_expenses', + label: 'Child care expenses', + icon: , + }, + { name: 'pre_subsidy_electricity_expense', label: 'Electricity', icon: }, + { name: 'gas_expense', label: 'Gas', icon: }, + { name: 'water_expense', label: 'Water', icon: }, + { name: 'mortgage_payments', label: 'Mortgage payments', icon: }, + { + name: 'homeowners_insurance', + label: 'Homeowners insurance', + icon: , + }, + { name: 'spm_unit_assets', label: 'Savings and assets', icon: }, + { name: 'household_vehicles_owned', label: 'Vehicles owned', icon: }, +]; + +const TOPIC_ICONS: Record = { + Income: IconCash, + People: IconUsers, + 'Housing and utilities': IconHome, + Health: IconStethoscope, + 'Other expenses': IconWallet, + Assets: IconPigMoney, + 'Benefits and taxes': IconReceiptTax, + 'State and local': IconBuildingCommunity, + Other: IconDots, +}; + +export function householdTopicIcon(group: string) { + const Icon = TOPIC_ICONS[group] ?? IconDots; + return ; +} + +const SHORT_NAMES = new Map(COMMON_HOUSEHOLD_INPUTS.map((input) => [input.name, input.label])); + +/** + * An input's name as the picker lists it: a tile's short name, else the + * model's label in sentence case without its modeling notes. + */ +export function householdInputName(variable: VariableInfo): string { + const short = SHORT_NAMES.get(variable.name); + if (short) { + return short; + } + const label = variable.label.replace(/\s*\(before labor supply responses\)/i, '').trim(); + return label.charAt(0).toUpperCase() + label.slice(1); +} diff --git a/app/src/components/household/HouseholdBuilderForm.tsx b/app/src/components/household/HouseholdBuilderForm.tsx index f12a961de..e0380ab3e 100644 --- a/app/src/components/household/HouseholdBuilderForm.tsx +++ b/app/src/components/household/HouseholdBuilderForm.tsx @@ -8,7 +8,7 @@ * - Inline search for adding custom variables per person or household-level */ -import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'; import { IconInfoCircle, IconPlus } from '@tabler/icons-react'; import { Accordion, @@ -41,6 +41,7 @@ import { VariableInfo, } from '@/utils/VariableResolver'; import SPMGeographyChoice from './SPMGeographyChoice'; +import type { CommonInput } from './VariablePickerDialog'; import VariableRow from './VariableRow'; import VariableSearchDropdown from './VariableSearchDropdown'; @@ -86,6 +87,10 @@ export interface HouseholdBuilderFormProps { /** With `variableGroupOf`: a group's parts, as subheadings when that group is chosen. */ variableSubgroupOf?: (variable: VariableInfo) => string | null; variableSubgroupOrder?: readonly string[]; + /** With `variableGroupOf`: the picker's tiles, topic icons and display names. */ + variablePickerCommon?: CommonInput[]; + variableGroupIcon?: (group: string) => ReactNode; + variableNameOf?: (variable: VariableInfo) => string; } /** @@ -212,6 +217,9 @@ export default function HouseholdBuilderForm({ searchByEntity = false, variableSubgroupOf, variableSubgroupOrder, + variablePickerCommon, + variableGroupIcon, + variableNameOf, disabled = false, isReadOnly = false, }: HouseholdBuilderFormProps) { @@ -319,16 +327,35 @@ export default function HouseholdBuilderForm({ }; // Memoized filtered variables for person search - const filteredPersonVariables = useMemo( - () => filterVariables(personSearchValue, scopedInputs.person), - [personSearchValue, scopedInputs, suggestedOrder] - ); + const filteredPersonVariables = useMemo(() => { + // Scoped, the search leaves out what the person already has. + const has = new Set( + searchByEntity && activePersonSearch + ? [...basicPersonFields, ...household.getPersonLevelVariables(activePersonSearch)] + : [] + ); + return filterVariables( + personSearchValue, + has.size > 0 ? scopedInputs.person.filter((v) => !has.has(v.name)) : scopedInputs.person + ); + }, [personSearchValue, scopedInputs, suggestedOrder, activePersonSearch, household]); // Memoized filtered variables for household search - const filteredHouseholdVariables = useMemo( - () => filterVariables(householdSearchValue, scopedInputs.group), - [householdSearchValue, scopedInputs, suggestedOrder] - ); + const filteredHouseholdVariables = useMemo(() => { + // Scoped, the search leaves out what the household already has. + const has = new Set( + searchByEntity + ? [ + ...basicNonPersonFields, + ...household.getHouseholdLevelVariables().map(({ name }) => name), + ] + : [] + ); + return filterVariables( + householdSearchValue, + has.size > 0 ? scopedInputs.group.filter((v) => !has.has(v.name)) : scopedInputs.group + ); + }, [householdSearchValue, scopedInputs, suggestedOrder, household]); const resolveDefaultGroupInstanceName = (variableName: string): string | undefined => { const entityInfo = resolveEntity(variableName, metadata); @@ -716,6 +743,9 @@ export default function HouseholdBuilderForm({ groupOrder={variableGroupOrder} subgroupOf={variableSubgroupOf} subgroupOrder={variableSubgroupOrder} + common={variablePickerCommon} + groupIcon={variableGroupIcon} + nameOf={variableNameOf} onSelect={(variable) => handlePersonVariableSelect(variable, person)} title={`Add a variable to ${getPersonDisplayName(person)}`} getEntityHint={(variable) => { @@ -852,6 +882,9 @@ export default function HouseholdBuilderForm({ groupOrder={variableGroupOrder} subgroupOf={variableSubgroupOf} subgroupOrder={variableSubgroupOrder} + common={variablePickerCommon} + groupIcon={variableGroupIcon} + nameOf={variableNameOf} onSelect={handleHouseholdVariableSelect} title="Add a variable to your household" getEntityHint={(variable) => { diff --git a/app/src/components/household/VariablePickerDialog.tsx b/app/src/components/household/VariablePickerDialog.tsx new file mode 100644 index 000000000..848148fd5 --- /dev/null +++ b/app/src/components/household/VariablePickerDialog.tsx @@ -0,0 +1,385 @@ +/** + * The household builder's variable picker as a dialog: the inputs most + * households need first, as tiles, then topics to browse, and a search + * across everything. A chosen topic lists its inputs by name, part by + * part; the input under the pointer is described once, below the list. + */ +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { IconArrowLeft, IconLayoutGrid, IconPlus, IconSearch } from '@tabler/icons-react'; +import { Dialog, DialogContent, DialogTitle, Spinner } from '@/components/ui'; +import { cn } from '@/lib/utils'; +import { VariableInfo } from '@/utils/VariableResolver'; + +/** An input offered as a tile on the picker's first view. */ +export interface CommonInput { + name: string; + /** A short name for the tile, e.g. "Rent" for `pre_subsidy_rent`. */ + label: string; + icon: ReactNode; +} + +export interface VariablePickerDialogProps { + title: string; + searchValue: string; + onSearchChange: (value: string) => void; + /** The inputs this picker offers, ranked by the search when there is one. */ + variables: VariableInfo[]; + onSelect: (variable: VariableInfo) => void; + onClose: () => void; + groupOf: (variable: VariableInfo) => string; + groupOrder?: readonly string[]; + groupIcon?: (group: string) => ReactNode; + subgroupOf?: (variable: VariableInfo) => string | null; + subgroupOrder?: readonly string[]; + /** Tiles on the first view; those not among `variables` are left out. */ + common?: CommonInput[]; + /** Inputs the report's reform makes worth filling in, listed on the first view. */ + suggested?: ReadonlyMap; + suggestedLabel?: string; + /** A display name for an input, where the model's label is long or technical. */ + nameOf?: (variable: VariableInfo) => string; + placeholder?: string; + loading?: boolean; + disabled?: boolean; +} + +const MOST_USED = 9; +const RELATED_SHOWN = 6; +const SEARCH_SHOWN = 80; + +function sentenceCase(label: string): string { + return label ? label.charAt(0).toUpperCase() + label.slice(1) : label; +} + +export default function VariablePickerDialog({ + title, + searchValue, + onSearchChange, + variables, + onSelect, + onClose, + groupOf, + groupOrder = [], + groupIcon, + subgroupOf, + subgroupOrder = [], + common = [], + suggested, + suggestedLabel = 'Related to this reform', + nameOf, + placeholder = 'Search inputs, e.g. rent', + loading = false, + disabled = false, +}: VariablePickerDialogProps) { + const inputRef = useRef(null); + const listRef = useRef(null); + const [topic, setTopic] = useState(null); + const [pointed, setPointed] = useState(-1); + const searching = searchValue.trim().length > 0; + const name = (variable: VariableInfo) => nameOf?.(variable) ?? sentenceCase(variable.label); + + const byName = useMemo(() => new Map(variables.map((v) => [v.name, v])), [variables]); + const tiles = useMemo( + () => common.filter((input) => byName.has(input.name)).slice(0, MOST_USED), + [common, byName] + ); + const related = useMemo( + () => + suggested + ? variables + .filter((v) => suggested.has(v.name)) + .sort((a, b) => suggested.get(a.name)! - suggested.get(b.name)!) + .slice(0, RELATED_SHOWN) + : [], + [variables, suggested] + ); + const topics = useMemo(() => { + const present = new Set(variables.map(groupOf)); + const rank = (group: string) => { + const index = groupOrder.indexOf(group); + return index === -1 ? groupOrder.length : index; + }; + return [...present].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); + }, [variables, groupOf, groupOrder]); + + // The rows the list shows: the search's matches, a topic's inputs part by + // part, or, on the first view, the reform's related inputs. + const rows = useMemo(() => { + if (searching) { + return variables.slice(0, SEARCH_SHOWN); + } + if (topic) { + const partRank = (v: VariableInfo) => { + const index = subgroupOrder.indexOf(subgroupOf?.(v) ?? ''); + return index === -1 ? subgroupOrder.length : index; + }; + return variables + .filter((v) => groupOf(v) === topic) + .sort((a, b) => partRank(a) - partRank(b) || name(a).localeCompare(name(b))); + } + return related; + // `name` reads `nameOf`, which the dependencies cover. + }, [searching, topic, variables, related, groupOf, subgroupOf, subgroupOrder, nameOf]); + + useEffect(() => setPointed(-1), [rows]); + useEffect(() => { + if (pointed >= 0) { + const options = listRef.current?.querySelectorAll('[data-option]'); + options?.[pointed]?.scrollIntoView({ block: 'nearest' }); + } + }, [pointed]); + + const openTopic = (group: string | null) => { + setTopic(group); + onSearchChange(''); + inputRef.current?.focus(); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setPointed((at) => Math.min(at + 1, rows.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setPointed((at) => Math.max(at - 1, 0)); + } else if (e.key === 'Enter' && rows[pointed]) { + e.preventDefault(); + onSelect(rows[pointed]); + } + }; + + const described = rows[pointed]; + const firstView = !searching && !topic; + + const row = (variable: VariableInfo, index: number) => ( +
setPointed(index)} + onMouseDown={(e) => { + e.preventDefault(); + onSelect(variable); + }} + className={cn( + 'tw:group tw:flex tw:cursor-pointer tw:items-center tw:justify-between tw:gap-3 tw:border-b tw:border-gray-100 tw:px-1 tw:py-2.5 tw:text-sm tw:text-gray-900', + index === pointed && 'tw:bg-primary-50/60' + )} + > + {name(variable)} + +
+ ); + + // A topic's rows under their parts' headings. + const topicRows = () => + rows.map((variable, index) => { + const part = subgroupOf?.(variable) ?? null; + const starts = !!part && (index === 0 || subgroupOf?.(rows[index - 1]) !== part); + return ( +
+ {starts && ( +

+ {part} +

+ )} + {row(variable, index)} +
+ ); + }); + + return ( + { + if (!open) { + onClose(); + } + }} + > + { + e.preventDefault(); + inputRef.current?.focus(); + }} + className="tw:flex tw:h-[min(42rem,88vh)] tw:flex-col tw:gap-0 tw:overflow-hidden tw:bg-white tw:p-0 tw:sm:max-w-[56rem]" + > +
+ {title} +
+ + onSearchChange(e.currentTarget.value)} + onKeyDown={onKeyDown} + placeholder={placeholder} + disabled={disabled} + aria-label="Search inputs" + className="tw:h-11 tw:w-full tw:rounded-lg tw:border tw:border-gray-200 tw:bg-gray-50 tw:pr-3 tw:pl-10 tw:text-sm tw:outline-none tw:focus:border-primary-500 tw:focus:bg-white tw:focus:ring-1 tw:focus:ring-primary-500" + /> +
+
+ +
+ {/* The topics, always in reach beside the list on a wide screen. */} + + +
+ {loading ? ( +
+ +
+ ) : firstView ? ( +
+ {tiles.length > 0 && ( +
+

Most used

+
+ {tiles.map((input) => ( + + ))} +
+
+ )} + {related.length > 0 && ( +
+

+ {suggestedLabel} +

+
+ {related.map(row)} +
+
+ )} + {/* On a phone the topics have no column: they are tiles here. */} +
+

+ Browse by topic +

+
+ {topics.map((group) => ( + + ))} +
+
+
+ ) : ( + <> + {topic && !searching && ( + + )} +

+ {searching ? `Results for “${searchValue.trim()}”` : topic} +

+ {rows.length === 0 ? ( +

+ No inputs match. Try another word. +

+ ) : ( +
+ {searching ? rows.map(row) : topicRows()} +
+ )} + + )} +
+
+ + {/* What the pointed-at input is, in one place. */} +
+ {described ? ( + <> +

+ {name(described)} +

+

+ {described.documentation || 'No description in the model.'} +

+ + ) : ( +

+ Point to an input to see what it means. +

+ )} +
+
+
+ ); +} diff --git a/app/src/components/household/VariableSearchDropdown.tsx b/app/src/components/household/VariableSearchDropdown.tsx index c5b4d8a17..67e66b87e 100644 --- a/app/src/components/household/VariableSearchDropdown.tsx +++ b/app/src/components/household/VariableSearchDropdown.tsx @@ -3,18 +3,12 @@ * Renders the results list in normal document flow so parent layouts can expand. */ -import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; +import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { IconChevronDown, IconSearch, IconX } from '@tabler/icons-react'; -import { - Button, - Dialog, - DialogContent, - DialogTitle, - Spinner, - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui'; +import VariablePickerDialog, { + type CommonInput, +} from '@/components/household/VariablePickerDialog'; +import { Button, Spinner, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; import { cn } from '@/lib/utils'; import { VariableInfo } from '@/utils/VariableResolver'; @@ -43,28 +37,21 @@ export interface VariableSearchDropdownProps { */ subgroupOf?: (variable: VariableInfo) => string | null; subgroupOrder?: readonly string[]; + /** With `groupOf`: the picker's tiles, topic icons and display names. */ + common?: CommonInput[]; + groupIcon?: (group: string) => ReactNode; + nameOf?: (variable: VariableInfo) => string; /** With `groupOf`: the dialog's title. */ title?: string; /** - * Groups the list (with chips to show one group) when given; the plain - * list otherwise. The order names the groups in the order they show. + * Opens the search as a picker dialog (VariablePickerDialog), its inputs + * in these groups, when given; the plain inline list otherwise. The + * order names the groups in the order they show. */ groupOf?: (variable: VariableInfo) => string; groupOrder?: readonly string[]; } -/** In the grouped list's "All" view, each group shows this many before "Show all". */ -const GROUP_PREVIEW = 6; -const SUGGESTED_GROUP = '__suggested'; - -interface VariableSection { - key: string; - label: string; - items: VariableInfo[]; - /** Variables in the group beyond those shown. */ - more: number; -} - // Fixed width for close button column (matches VariableRow's REMOVE_COLUMN_WIDTH) const CLOSE_COLUMN_WIDTH = 22; @@ -97,11 +84,12 @@ export default function VariableSearchDropdown({ title = 'Add a variable', subgroupOf, subgroupOrder = [], + common, + groupIcon, + nameOf, groupOrder = [], }: VariableSearchDropdownProps) { const [isOpen, setIsOpen] = useState(false); - // The group the grouped list shows; all of them when null. - const [activeGroup, setActiveGroup] = useState(null); const [selectedIndex, setSelectedIndex] = useState(-1); const inputRef = useRef(null); const listRef = useRef(null); @@ -135,82 +123,12 @@ export default function VariableSearchDropdown({ ? sortedVariables.filter((v) => suggested.has(v.name)).length : 0; - // The grouped list: its chips (every group with a match) and its sections. - const grouped = useMemo(() => { - if (!groupOf) { - return null; - } - const byGroup = new Map(); - for (const variable of sortedVariables) { - if (suggested?.has(variable.name)) { - continue; - } - const group = groupOf(variable); - byGroup.set(group, [...(byGroup.get(group) ?? []), variable]); - } - const rank = (group: string) => { - const index = groupOrder.indexOf(group); - return index === -1 ? groupOrder.length : index; - }; - const all: VariableSection[] = [ - ...(suggestedCount > 0 - ? [ - { - key: SUGGESTED_GROUP, - label: suggestedLabel, - items: sortedVariables.filter((v) => suggested?.has(v.name)), - more: 0, - }, - ] - : []), - ...[...byGroup.entries()] - .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b)) - .map(([group, items]) => ({ key: group, label: group, items, more: 0 })), - ]; - let chosen = all.find((section) => section.key === activeGroup); - // A chosen group with parts reads part by part, each in label order; - // a typed search keeps its own ranking. - if (chosen && subgroupOf && !searchValue.trim()) { - const partRank = (variable: VariableInfo) => { - const index = subgroupOrder.indexOf(subgroupOf(variable) ?? ''); - return index === -1 ? subgroupOrder.length : index; - }; - chosen = { - ...chosen, - items: [...chosen.items].sort( - (a, b) => partRank(a) - partRank(b) || a.label.localeCompare(b.label) - ), - }; - } - const sections = chosen - ? [chosen] - : all.map((section) => ({ - ...section, - items: section.items.slice(0, GROUP_PREVIEW), - more: Math.max(0, section.items.length - GROUP_PREVIEW), - })); - return { chips: all, sections }; - }, [ - groupOf, - groupOrder, - sortedVariables, - suggested, - suggestedCount, - suggestedLabel, - activeGroup, - subgroupOf, - subgroupOrder, - searchValue, - ]); - // The options in the order they show: the grouped list's, or the plain one. - const displayVariables = grouped - ? grouped.sections.flatMap((section) => section.items) - : sortedVariables; + const displayVariables = sortedVariables; // Reset selected index when list changes useEffect(() => { setSelectedIndex(-1); - }, [sortedVariables, activeGroup]); + }, [sortedVariables]); const openDropdown = () => { setIsOpen(true); @@ -275,210 +193,28 @@ export default function VariableSearchDropdown({ inputRef.current?.focus(); }, []); - // Grouped, the search is a dialog of its own, centered over the page: a - // list in place takes the width of where it opens — in a person card, - // too narrow for the categories and the variables side by side. - if (grouped) { + // Grouped, the search is a dialog of its own, centered over the page. + if (groupOf) { return ( - { - if (!open) { - handleClose(); - } - }} - > - { - e.preventDefault(); - inputRef.current?.focus(); - }} - className="tw:flex tw:h-[min(40rem,85vh)] tw:flex-col tw:gap-0 tw:overflow-hidden tw:bg-white tw:p-0 tw:sm:max-w-[56rem]" - > -
- {title} -
- - onSearchChange(e.currentTarget.value)} - onKeyDown={handleKeyDown} - disabled={disabled} - autoFocus - /> -
-
-
- {grouped.chips.length > 1 && ( - // On a phone the categories are one compact choice above the list. -
- -
- )} -
- {grouped.chips.length > 1 && ( - // Wider, the categories are a column beside the list: every one - // in view at once, read top to bottom, none scrolled out of sight. -
- {[{ key: null, label: 'All categories' }, ...grouped.chips].map((chip) => ( - - ))} -
- )} -
- {loading ? ( -
- -
- ) : displayVariables.length === 0 ? ( -

- No variables found -

- ) : ( - grouped.sections.map((section) => { - const offset = displayVariables.indexOf(section.items[0]); - return ( -
-

- {section.label} -

- {section.items.map((variable, at) => { - const index = offset + at; - const entityHint = getEntityHint?.(variable); - const part = - activeGroup === section.key && subgroupOf ? subgroupOf(variable) : null; - const partStarts = - !!part && (at === 0 || subgroupOf!(section.items[at - 1]) !== part); - return ( - - {partStarts && ( -

- {part} -

- )} -
setSelectedIndex(index)} - onMouseDown={(e) => { - e.preventDefault(); - handleSelect(variable); - }} - > - {/* A name only: the description shows once, below the list. */} -

- {sentenceCaseLabel(variable.label)} -

- {entityHint?.show && ( - - {entityHint.label} - - )} -
-
- ); - })} - {section.more > 0 && ( - - )} -
- ); - }) - )} -
-
-
- {/* What the pointed-at (or arrowed-to) variable is, in one place. */} -
- {displayVariables[selectedIndex] ? ( - <> -

- {sentenceCaseLabel(displayVariables[selectedIndex].label)} -

-

- {displayVariables[selectedIndex].documentation || 'No description in the model.'} -

- - ) : ( -

- Point to a variable to see what it means. -

- )} -
-
-
+ ); } @@ -518,7 +254,7 @@ export default function VariableSearchDropdown({ />
- {isOpen && !grouped && ( + {isOpen && (
{ const { unmount } = renderForm({ metadata, searchByEntity: true, variableGroupOf: groupOf }); fireEvent.click(screen.getByRole('button', { name: /Add variable to you$/ })); + fireEvent.mouseDown(screen.getAllByRole('button', { name: 'Income' })[0]); expect(optionsIn()).toEqual(['Tip income']); unmount(); renderForm({ metadata, searchByEntity: true, variableGroupOf: groupOf }); fireEvent.click(screen.getByRole('button', { name: 'Add variable to your household' })); + fireEvent.mouseDown(screen.getAllByRole('button', { name: 'Income' })[0]); expect(optionsIn().sort()).toEqual(['Childcare', 'Rent']); }); diff --git a/app/src/tests/unit/components/household/VariablePickerDialog.test.tsx b/app/src/tests/unit/components/household/VariablePickerDialog.test.tsx new file mode 100644 index 000000000..63764105d --- /dev/null +++ b/app/src/tests/unit/components/household/VariablePickerDialog.test.tsx @@ -0,0 +1,109 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import VariablePickerDialog from '@/components/household/VariablePickerDialog'; +import type { VariableInfo } from '@/utils/VariableResolver'; + +const input = (name: string, label: string, documentation?: string) => + ({ name, label, documentation }) as VariableInfo; + +const VARIABLES = [ + input('pre_subsidy_rent', 'Pre subsidy rent', 'Rent paid for the home'), + input('tip_income', 'Tip income'), + input('qualified_dividend_income', 'qualified dividend income'), + input('hourly_wage', 'Hourly wage'), +]; +const GROUPS: Record = { + pre_subsidy_rent: 'Housing and utilities', + tip_income: 'Income', + qualified_dividend_income: 'Income', + hourly_wage: 'Income', +}; +const PARTS: Record = { + tip_income: 'Wages and work', + hourly_wage: 'Wages and work', + qualified_dividend_income: 'Investments', +}; + +function renderPicker(props: Partial> = {}) { + const onSelect = vi.fn(); + render( + GROUPS[v.name]} + groupOrder={['Income', 'Housing and utilities']} + subgroupOf={(v) => PARTS[v.name] ?? null} + subgroupOrder={['Wages and work', 'Investments']} + common={[ + { name: 'pre_subsidy_rent', label: 'Rent', icon: null }, + { name: 'not_offered_here', label: 'Gas', icon: null }, + ]} + {...props} + /> + ); + return { onSelect }; +} + +const rows = () => screen.queryAllByRole('option').map((option) => option.textContent); + +describe('VariablePickerDialog', () => { + test('given it opens then the common inputs it offers lead, as tiles', async () => { + const user = userEvent.setup(); + const { onSelect } = renderPicker(); + + expect(screen.getByRole('dialog', { name: 'Add a variable to you' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Rent' })).toBeInTheDocument(); + // A tile for an input this picker does not offer is left out. + expect(screen.queryByRole('button', { name: 'Gas' })).not.toBeInTheDocument(); + expect(rows()).toEqual([]); + + await user.click(screen.getByRole('button', { name: 'Rent' })); + expect(onSelect).toHaveBeenCalledWith(VARIABLES[0]); + }); + + test('given a topic then its inputs list by name, part by part', async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getAllByRole('button', { name: 'Income' })[0]); + + expect(rows()).toEqual(['Hourly wage', 'Tip income', 'Qualified dividend income']); + expect(screen.getByText('Wages and work')).toBeInTheDocument(); + expect(screen.getByText('Investments')).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Income' })[0]).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); + + test('given a search then its matches list in its order', () => { + renderPicker({ searchValue: 'income', variables: [VARIABLES[2], VARIABLES[1]] }); + + expect(screen.getByText('Results for “income”')).toBeInTheDocument(); + expect(rows()).toEqual(['Qualified dividend income', 'Tip income']); + }); + + test('given the pointer on a row then the input is described below the list', async () => { + const user = userEvent.setup(); + renderPicker({ searchValue: 'rent', variables: [VARIABLES[0]], nameOf: () => 'Rent' }); + + expect(screen.queryByText('Rent paid for the home')).not.toBeInTheDocument(); + await user.hover(screen.getByRole('option', { name: 'Rent' })); + + expect(screen.getByText('Rent paid for the home')).toBeInTheDocument(); + }); + + test('given the reform suggests inputs then the first view lists them', () => { + renderPicker({ + suggested: new Map([['hourly_wage', 0]]), + suggestedLabel: 'Related to this reform', + }); + + expect(screen.getByText('Related to this reform')).toBeInTheDocument(); + expect(rows()).toEqual(['Hourly wage']); + }); +}); diff --git a/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx b/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx index fea55dcc6..0a7c9e859 100644 --- a/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx +++ b/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx @@ -54,89 +54,4 @@ describe('VariableSearchDropdown', () => { 'Alimony income', ]); }); - - test('given groups then chips filter the list, and each group shows under its heading', async () => { - const user = userEvent.setup(); - const groupOf = (v: VariableInfo) => (v.name === 'rent' ? 'Housing' : 'Income'); - render( - - ); - await user.click(screen.getByRole('textbox')); - - expect(screen.getByText('Income', { selector: 'p' })).toBeInTheDocument(); - expect(screen.getByText('Housing', { selector: 'p' })).toBeInTheDocument(); - // Grouped, the search opens as a dialog of its own. - expect(screen.getByRole('dialog', { name: 'Add a variable' })).toBeInTheDocument(); - // The list's rows; the phone's category choice has options of its own. - const rows = () => - screen.getAllByRole('option').filter((option) => option.hasAttribute('data-option')); - expect(rows()).toHaveLength(3); - - await user.click(screen.getByRole('button', { name: /Housing/ })); - - expect(rows().map((option) => option.textContent)).toEqual(['Rent']); - expect(screen.getByRole('button', { name: /Housing/ })).toHaveAttribute('aria-pressed', 'true'); - }); - - test('given groups on a phone then one category choice filters the list', async () => { - const user = userEvent.setup(); - const groupOf = (v: VariableInfo) => (v.name === 'rent' ? 'Housing' : 'Income'); - render( - - ); - await user.click(screen.getByRole('textbox')); - - await user.selectOptions( - screen.getByRole('combobox', { name: 'Variable category' }), - 'Housing' - ); - - expect( - screen - .getAllByRole('option') - .filter((option) => option.hasAttribute('data-option')) - .map((option) => option.textContent) - ).toEqual(['Rent']); - }); - - test('given a grouped list then rows show names, and the pointed-at one is described below', async () => { - const user = userEvent.setup(); - const described = [ - { ...VARIABLES[2], documentation: 'Rent paid for the home' }, - VARIABLES[0], - ] as VariableInfo[]; - render( - 'Housing'} - /> - ); - - expect(screen.queryByText('Rent paid for the home')).not.toBeInTheDocument(); - expect(screen.getByText('Point to a variable to see what it means.')).toBeInTheDocument(); - - await user.hover(screen.getByRole('option', { name: 'Rent' })); - - expect(screen.getByText('Rent paid for the home')).toBeInTheDocument(); - }); }); From 05a77b55954f72eb66511a2e0e25b8680d2fc2c1 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 19 Oct 2026 09:11:36 -0400 Subject: [PATCH 08/10] Household fields: one-line labels with short names; the picker's first view is Featured A stacked field's label wrapped to two lines when the model's label was long, pushing its box below its neighbours'. Stacked labels now keep to one line (the full name on hover), and the builder's fields take the picker's short names (Child care expenses, not Spm unit pre subsidy child care expenses). The picker's first view is called Featured. Co-Authored-By: Claude Opus 5.5 --- .../components/household/HouseholdBuilderForm.tsx | 12 ++++++++---- .../components/household/VariablePickerDialog.tsx | 8 ++++---- app/src/components/household/VariableRow.tsx | 9 ++++++--- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/app/src/components/household/HouseholdBuilderForm.tsx b/app/src/components/household/HouseholdBuilderForm.tsx index e0380ab3e..3431d7a4b 100644 --- a/app/src/components/household/HouseholdBuilderForm.tsx +++ b/app/src/components/household/HouseholdBuilderForm.tsx @@ -258,6 +258,10 @@ export default function HouseholdBuilderForm({ [allInputVariables] ); + // A field's name as the picker lists it, where the builder is given one. + const named = (variable: VariableInfo): VariableInfo => + variableNameOf ? { ...variable, label: variableNameOf(variable) } : variable; + // Get list of people, sorted in display order (you, partner, dependents) const people = useMemo(() => household.getSortedPersonNames(), [household]); @@ -688,7 +692,7 @@ export default function HouseholdBuilderForm({ return ( new Map(variables.map((v) => [v.name, v])), [variables]); const tiles = useMemo( - () => common.filter((input) => byName.has(input.name)).slice(0, MOST_USED), + () => common.filter((input) => byName.has(input.name)).slice(0, FEATURED), [common, byName] ); const related = useMemo( @@ -265,7 +265,7 @@ export default function VariablePickerDialog({ {group ? groupIcon?.(group) : } - {group ?? 'Most used'} + {group ?? 'Featured'} ); })} @@ -280,7 +280,7 @@ export default function VariablePickerDialog({
{tiles.length > 0 && (
-

Most used

+

Featured

{tiles.map((input) => ( - ))} +

Featured

+
+ {featured.map(row)}
)} @@ -304,7 +296,7 @@ export default function VariablePickerDialog({ {suggestedLabel}

- {related.map(row)} + {related.map((variable, at) => row(variable, featured.length + at))}
)} diff --git a/app/src/libs/flagship/householdSuggestions.ts b/app/src/libs/flagship/householdSuggestions.ts index f2991b986..18393b51a 100644 --- a/app/src/libs/flagship/householdSuggestions.ts +++ b/app/src/libs/flagship/householdSuggestions.ts @@ -10,22 +10,32 @@ import { variablesReachedByPaths, } from '@/libs/flagship/parameterDependencies'; -/** Steps upstream from the changed variables: enough for rent → housing cost → SNAP. */ -const MAX_UPSTREAM = 8; -const DEFAULT_LIMIT = 12; +/** + * Steps upstream from the changed variables: enough for rent → housing + * cost → SNAP. Further, nearly every input is upstream of a tax credit + * through adjusted gross income, and the list stops saying anything. + */ +const MAX_UPSTREAM = 4; +const DEFAULT_LIMIT = 40; /** * Inputs a reader does not describe a household with: survey weights, * reported amounts and take-up draws the microdata sets, and ids. */ const NOT_HOUSEHOLD_FACTS = - /(^|_)(weight|reported|takes_up|takeup|would_claim|id|seed|imputed)($|_)/; + /(^|_)(weight|reported|takes_up|takeup|would_claim|would_file|id|seed|imputed)($|_)/; + +/** A state program's own input (`il_pass_through_…`, `ut_additions`): not a household's fact. */ +const STATE_PROGRAM_INPUT = + /^(al|ak|az|ar|ca|co|ct|de|dc|fl|ga|hi|id|il|in|ia|ks|ky|la|me|md|ma|mi|mn|ms|mo|mt|ne|nv|nh|nj|nm|ny|nc|nd|oh|ok|or|pa|ri|sc|sd|tn|tx|ut|vt|va|wa|wv|wi|wy)_/; export function suggestedHouseholdInputs( paths: string[], map: ParameterDependencyMap, inputs: Set, - limit: number = DEFAULT_LIMIT + limit: number = DEFAULT_LIMIT, + /** Inputs a household commonly has, listed ahead of the rest at any distance. */ + preferred: ReadonlySet = new Set() ): string[] { // The variables the parameters feed, and the ones one step on. const start = variablesReachedByPaths(paths, map, 1).map((entry) => entry.variable); @@ -55,8 +65,16 @@ export function suggestedHouseholdInputs( frontier = next; } return [...distance.entries()] - .filter(([variable]) => inputs.has(variable) && !NOT_HOUSEHOLD_FACTS.test(variable)) - .sort(([a, da], [b, db]) => da - db || a.localeCompare(b)) + .filter( + ([variable]) => + inputs.has(variable) && + !NOT_HOUSEHOLD_FACTS.test(variable) && + !STATE_PROGRAM_INPUT.test(variable) + ) + .sort( + ([a, da], [b, db]) => + Number(preferred.has(b)) - Number(preferred.has(a)) || da - db || a.localeCompare(b) + ) .slice(0, limit) .map(([variable]) => variable); } diff --git a/app/src/tests/unit/components/household/VariablePickerDialog.test.tsx b/app/src/tests/unit/components/household/VariablePickerDialog.test.tsx index 63764105d..b80a17fe4 100644 --- a/app/src/tests/unit/components/household/VariablePickerDialog.test.tsx +++ b/app/src/tests/unit/components/household/VariablePickerDialog.test.tsx @@ -39,8 +39,8 @@ function renderPicker(props: Partial PARTS[v.name] ?? null} subgroupOrder={['Wages and work', 'Investments']} common={[ - { name: 'pre_subsidy_rent', label: 'Rent', icon: null }, - { name: 'not_offered_here', label: 'Gas', icon: null }, + { name: 'pre_subsidy_rent', label: 'Rent' }, + { name: 'not_offered_here', label: 'Gas' }, ]} {...props} /> @@ -51,17 +51,18 @@ function renderPicker(props: Partial screen.queryAllByRole('option').map((option) => option.textContent); describe('VariablePickerDialog', () => { - test('given it opens then the common inputs it offers lead, as tiles', async () => { + test('given it opens then the featured inputs it offers lead, listed by name', async () => { const user = userEvent.setup(); - const { onSelect } = renderPicker(); + const { onSelect } = renderPicker({ + nameOf: (v) => (v.name === 'pre_subsidy_rent' ? 'Rent' : v.label), + }); expect(screen.getByRole('dialog', { name: 'Add a variable to you' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Rent' })).toBeInTheDocument(); - // A tile for an input this picker does not offer is left out. - expect(screen.queryByRole('button', { name: 'Gas' })).not.toBeInTheDocument(); - expect(rows()).toEqual([]); + // An input this picker does not offer is left out. + expect(rows()).toEqual(['Rent']); + expect(screen.getByRole('listbox', { name: 'Featured' })).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Rent' })); + await user.click(screen.getByRole('option', { name: 'Rent' })); expect(onSelect).toHaveBeenCalledWith(VARIABLES[0]); }); @@ -97,13 +98,22 @@ describe('VariablePickerDialog', () => { expect(screen.getByText('Rent paid for the home')).toBeInTheDocument(); }); - test('given the reform suggests inputs then the first view lists them', () => { + test('given the reform suggests inputs then the first view lists them after the featured', () => { renderPicker({ - suggested: new Map([['hourly_wage', 0]]), + suggested: new Map([ + ['hourly_wage', 0], + ['tip_income', 1], + ]), suggestedLabel: 'Related to this reform', }); expect(screen.getByText('Related to this reform')).toBeInTheDocument(); - expect(rows()).toEqual(['Hourly wage']); + expect(rows()).toEqual(['Pre subsidy rent', 'Hourly wage', 'Tip income']); + }); + + test('given the reform suggests one input only then no related list shows', () => { + renderPicker({ suggested: new Map([['hourly_wage', 0]]) }); + + expect(screen.queryByText('Related to this reform')).not.toBeInTheDocument(); }); }); From 31e23345049a0e32ccf5c11dfc098f337472c31a Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 19 Oct 2026 09:27:58 -0400 Subject: [PATCH 10/10] Household picker: describe featured inputs, and say what kind of value each takes The model documents only 2 of the 17 featured inputs. Each featured input now carries a one-line description of what to enter, shown in the picker's footer and as the field label's hover text; the model's own documentation wins where it has one. The footer also says what kind of value an input takes, from its unit, type and period ("Dollars per year", "Yes or no", "Number"), for every input. Variable metadata now carries its definition period. Co-Authored-By: Claude Opus 5.5 --- .../household/HouseholdImpactSection.tsx | 2 + .../household/householdPickerInputs.tsx | 89 ++++++++++++++++--- .../household/HouseholdBuilderForm.tsx | 11 ++- .../household/VariablePickerDialog.tsx | 35 +++++++- app/src/components/household/VariableRow.tsx | 7 +- .../household/VariableSearchDropdown.tsx | 3 + .../household/VariablePickerDialog.test.tsx | 33 ++++++- app/src/utils/VariableResolver.ts | 2 + app/src/utils/variableMetadata.ts | 1 + 9 files changed, 166 insertions(+), 17 deletions(-) diff --git a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx index 277540b70..b9ae43647 100644 --- a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx +++ b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx @@ -5,6 +5,7 @@ import { STATE_NAMES } from '@/api/billFeed'; import HouseholdImpactResult from '@/components/flagship/report/household/HouseholdImpactResult'; import { COMMON_HOUSEHOLD_INPUTS, + householdInputDescription, householdInputName, householdTopicIcon, } from '@/components/flagship/report/household/householdPickerInputs'; @@ -359,6 +360,7 @@ function HouseholdCard({ variablePickerCommon={COMMON_HOUSEHOLD_INPUTS} variableGroupIcon={householdTopicIcon} variableNameOf={householdInputName} + variableDescribe={householdInputDescription} />
= { @@ -109,3 +167,12 @@ export function householdInputName(variable: VariableInfo): string { const label = variable.label.replace(/\s*\(before labor supply responses\)/i, '').trim(); return label.charAt(0).toUpperCase() + label.slice(1); } + +const DESCRIPTIONS = new Map( + COMMON_HOUSEHOLD_INPUTS.map((input) => [input.name, input.description]) +); + +/** What to enter for an input: the model's documentation, else ours for a featured one. */ +export function householdInputDescription(variable: VariableInfo): string | undefined { + return variable.documentation || DESCRIPTIONS.get(variable.name); +} diff --git a/app/src/components/household/HouseholdBuilderForm.tsx b/app/src/components/household/HouseholdBuilderForm.tsx index 3431d7a4b..33f479505 100644 --- a/app/src/components/household/HouseholdBuilderForm.tsx +++ b/app/src/components/household/HouseholdBuilderForm.tsx @@ -91,6 +91,7 @@ export interface HouseholdBuilderFormProps { variablePickerCommon?: CommonInput[]; variableGroupIcon?: (group: string) => ReactNode; variableNameOf?: (variable: VariableInfo) => string; + variableDescribe?: (variable: VariableInfo) => string | undefined; } /** @@ -220,6 +221,7 @@ export default function HouseholdBuilderForm({ variablePickerCommon, variableGroupIcon, variableNameOf, + variableDescribe, disabled = false, isReadOnly = false, }: HouseholdBuilderFormProps) { @@ -259,8 +261,11 @@ export default function HouseholdBuilderForm({ ); // A field's name as the picker lists it, where the builder is given one. - const named = (variable: VariableInfo): VariableInfo => - variableNameOf ? { ...variable, label: variableNameOf(variable) } : variable; + const named = (variable: VariableInfo): VariableInfo => ({ + ...variable, + label: variableNameOf ? variableNameOf(variable) : variable.label, + documentation: variableDescribe?.(variable) ?? variable.documentation, + }); // Get list of people, sorted in display order (you, partner, dependents) const people = useMemo(() => household.getSortedPersonNames(), [household]); @@ -750,6 +755,7 @@ export default function HouseholdBuilderForm({ common={variablePickerCommon} groupIcon={variableGroupIcon} nameOf={variableNameOf} + describe={variableDescribe} onSelect={(variable) => handlePersonVariableSelect(variable, person)} title={`Add a variable to ${getPersonDisplayName(person)}`} getEntityHint={(variable) => { @@ -889,6 +895,7 @@ export default function HouseholdBuilderForm({ common={variablePickerCommon} groupIcon={variableGroupIcon} nameOf={variableNameOf} + describe={variableDescribe} onSelect={handleHouseholdVariableSelect} title="Add a variable to your household" getEntityHint={(variable) => { diff --git a/app/src/components/household/VariablePickerDialog.tsx b/app/src/components/household/VariablePickerDialog.tsx index f663ee2a8..b7bd1b826 100644 --- a/app/src/components/household/VariablePickerDialog.tsx +++ b/app/src/components/household/VariablePickerDialog.tsx @@ -15,6 +15,34 @@ export interface CommonInput { name: string; /** A short name, e.g. "Rent" for `pre_subsidy_rent`. */ label: string; + /** One line on what to enter, where the model has none. */ + description?: string; +} + +/** + * What kind of value an input takes, from the model's unit, type and + * period: "Dollars per year", "Yes or no", "Number". + */ +export function inputKind(variable: VariableInfo): string { + const per = + variable.definitionPeriod === 'month' + ? ' per month' + : variable.definitionPeriod === 'year' + ? ' per year' + : ''; + if (variable.valueType === 'bool') { + return 'Yes or no'; + } + if (variable.valueType === 'Enum') { + return 'One of a list'; + } + if (variable.unit === 'currency-USD' || variable.unit === 'currency-GBP') { + return `${variable.unit === 'currency-GBP' ? 'Pounds' : 'Dollars'}${per}`; + } + if (variable.unit === '/1') { + return 'Rate'; + } + return 'Number'; } export interface VariablePickerDialogProps { @@ -37,6 +65,8 @@ export interface VariablePickerDialogProps { suggestedLabel?: string; /** A display name for an input, where the model's label is long or technical. */ nameOf?: (variable: VariableInfo) => string; + /** What to enter for an input, where the model's documentation is missing. */ + describe?: (variable: VariableInfo) => string | undefined; placeholder?: string; loading?: boolean; disabled?: boolean; @@ -66,6 +96,7 @@ export default function VariablePickerDialog({ suggested, suggestedLabel = 'Related to this reform', nameOf, + describe, placeholder = 'Search inputs, e.g. rent', loading = false, disabled = false, @@ -362,7 +393,9 @@ export default function VariablePickerDialog({ {name(described)}

- {described.documentation || 'No description in the model.'} + {[describe?.(described) ?? described.documentation, inputKind(described)] + .filter(Boolean) + .join(' · ')}

) : ( diff --git a/app/src/components/household/VariableRow.tsx b/app/src/components/household/VariableRow.tsx index 2d186a677..25c437a8b 100644 --- a/app/src/components/household/VariableRow.tsx +++ b/app/src/components/household/VariableRow.tsx @@ -70,7 +70,12 @@ export default function VariableRow({ {stacked ? ( // The same label as the builder's own fields, so stacked fields line up // with them: one line, so a long name does not push its box down. -