diff --git a/app/src/components/flagship/report/ReportView.tsx b/app/src/components/flagship/report/ReportView.tsx index 6c6b05191..98bf59878 100644 --- a/app/src/components/flagship/report/ReportView.tsx +++ b/app/src/components/flagship/report/ReportView.tsx @@ -407,6 +407,7 @@ export default function ReportView({ baselinePolicyId={baselinePolicyId} reformPolicyId={reformPolicyId} reformPaths={provisions.map((provision) => provision.path)} + reformChangesNothing={!!output && isSocietyWideReportNoOp(output)} /> ) : ( <> diff --git a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx index e56423f66..b9ae43647 100644 --- a/app/src/components/flagship/report/household/HouseholdImpactSection.tsx +++ b/app/src/components/flagship/report/household/HouseholdImpactSection.tsx @@ -3,6 +3,12 @@ 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, + householdInputDescription, + 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'; @@ -11,8 +17,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'; @@ -67,12 +76,16 @@ interface HouseholdImpactSectionProps { reformPolicyId: string; /** The parameters the reform changes: the household inputs they read lead the variable search. */ reformPaths?: string[]; + /** The report found the reform changes nothing in its year: no input is suggested. */ + reformChangesNothing?: boolean; } /** * The household inputs the reform's parameters read, nearest first; none * until the dependency map loads, or when it traces none of them. */ +const PREFERRED_INPUTS = new Set(COMMON_HOUSEHOLD_INPUTS.map((input) => input.name)); + function useSuggestedInputs(paths: string[]): string[] { const metadata = useSelector((state: RootState) => state.metadata); const inputs = useMemo( @@ -90,7 +103,9 @@ function useSuggestedInputs(paths: string[]): string[] { loadParameterDependencies() .then((map) => { if (!cancelled) { - setSuggested(suggestedHouseholdInputs(pathsKey.split('\n'), map, inputs)); + setSuggested( + suggestedHouseholdInputs(pathsKey.split('\n'), map, inputs, undefined, PREFERRED_INPUTS) + ); } }) .catch(() => { @@ -117,8 +132,10 @@ export default function HouseholdImpactSection({ baselinePolicyId, reformPolicyId, reformPaths = [], + reformChangesNothing = false, }: HouseholdImpactSectionProps) { - const suggestedVariables = useSuggestedInputs(reformPaths); + // A reform that changes nothing in the year makes no input worth suggesting. + const suggestedVariables = useSuggestedInputs(reformChangesNothing ? [] : reformPaths); // Each household by a key of its own, so removing one keeps the others' inputs. const [keys, setKeys] = useState([]); const [nextKey, setNextKey] = useState(1); @@ -224,10 +241,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 +354,13 @@ function HouseholdCard({ variableGroupOf={householdVariableGroup} variableGroupOrder={HOUSEHOLD_VARIABLE_GROUPS} variableFilter={variableFilter} + variableSubgroupOf={householdVariableSubgroup} + variableSubgroupOrder={HOUSEHOLD_INCOME_SUBGROUPS} + searchByEntity + variablePickerCommon={COMMON_HOUSEHOLD_INPUTS} + variableGroupIcon={householdTopicIcon} + variableNameOf={householdInputName} + variableDescribe={householdInputDescription} />
= { + 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); +} + +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 76d8a61b2..33f479505 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'; @@ -76,6 +77,21 @@ export interface HouseholdBuilderFormProps { variableGroupOrder?: readonly string[]; /** Leaves variables out of the search, e.g. other states' programs. */ variableFilter?: (variable: VariableInfo) => 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[]; + /** With `variableGroupOf`: the picker's tiles, topic icons and display names. */ + variablePickerCommon?: CommonInput[]; + variableGroupIcon?: (group: string) => ReactNode; + variableNameOf?: (variable: VariableInfo) => string; + variableDescribe?: (variable: VariableInfo) => string | undefined; } /** @@ -199,6 +215,13 @@ export default function HouseholdBuilderForm({ variableGroupOf, variableGroupOrder, variableFilter, + searchByEntity = false, + variableSubgroupOf, + variableSubgroupOrder, + variablePickerCommon, + variableGroupIcon, + variableNameOf, + variableDescribe, disabled = false, isReadOnly = false, }: HouseholdBuilderFormProps) { @@ -237,6 +260,13 @@ 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 => ({ + ...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]); @@ -270,19 +300,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 { @@ -293,16 +336,35 @@ export default function HouseholdBuilderForm({ }; // Memoized filtered variables for person search - const filteredPersonVariables = useMemo( - () => filterVariables(personSearchValue), - [personSearchValue, allInputVariables, 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), - [householdSearchValue, allInputVariables, 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); @@ -635,7 +697,7 @@ export default function HouseholdBuilderForm({ return ( handlePersonVariableSelect(variable, person)} + title={`Add a variable to ${getPersonDisplayName(person)}`} getEntityHint={(variable) => { const { isPerson } = getVariableEntityDisplayInfo( variable.name, metadata ); - return { show: !isPerson, label: 'Household' }; + return { show: !searchByEntity && !isPerson, label: 'Household' }; }} disabled={disabled} onClose={() => { @@ -742,8 +811,9 @@ export default function HouseholdBuilderForm({ >
@@ -757,7 +827,7 @@ export default function HouseholdBuilderForm({ return ( - 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} + subgroupOf={variableSubgroupOf} + subgroupOrder={variableSubgroupOrder} + common={variablePickerCommon} + groupIcon={variableGroupIcon} + nameOf={variableNameOf} + describe={variableDescribe} + onSelect={handleHouseholdVariableSelect} + title="Add a variable to your household" + getEntityHint={(variable) => { + const { isPerson } = getVariableEntityDisplayInfo(variable.name, metadata); + return { show: !searchByEntity && 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/VariablePickerDialog.tsx b/app/src/components/household/VariablePickerDialog.tsx new file mode 100644 index 000000000..b7bd1b826 --- /dev/null +++ b/app/src/components/household/VariablePickerDialog.tsx @@ -0,0 +1,410 @@ +/** + * The household builder's variable picker as a dialog: the inputs most + * households need first, 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 the picker features on its first view. */ +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 { + 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[]; + /** Featured 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; + /** What to enter for an input, where the model's documentation is missing. */ + describe?: (variable: VariableInfo) => string | undefined; + placeholder?: string; + loading?: boolean; + disabled?: boolean; +} + +const FEATURED = 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, + describe, + 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 featured = useMemo( + () => + common + .filter((input) => byName.has(input.name)) + .slice(0, FEATURED) + .map((input) => byName.get(input.name)!), + [common, byName] + ); + const related = useMemo(() => { + const shown = new Set(featured.map((v) => v.name)); + const list = suggested + ? variables + .filter((v) => suggested.has(v.name) && !shown.has(v.name)) + .sort((a, b) => suggested.get(a.name)! - suggested.get(b.name)!) + .slice(0, RELATED_SHOWN) + : []; + // One lone input reads as noise, not as what the reform turns on. + return list.length >= 2 ? list : []; + }, [variables, suggested, featured]); + 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 [...featured, ...related]; + // `name` reads `nameOf`, which the dependencies cover. + }, [searching, topic, variables, featured, 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 ? ( +
+ {featured.length > 0 && ( +
+

Featured

+
+ {featured.map(row)} +
+
+ )} + {related.length > 0 && ( +
+

+ {suggestedLabel} +

+
+ {related.map((variable, at) => row(variable, featured.length + at))} +
+
+ )} + {/* 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)} +

+

+ {[describe?.(described) ?? described.documentation, inputKind(described)] + .filter(Boolean) + .join(' · ')} +

+ + ) : ( +

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

+ )} +
+
+
+ ); +} diff --git a/app/src/components/household/VariableRow.tsx b/app/src/components/household/VariableRow.tsx index fb1547df3..25c437a8b 100644 --- a/app/src/components/household/VariableRow.tsx +++ b/app/src/components/household/VariableRow.tsx @@ -66,10 +66,18 @@ export default function VariableRow({ } style={width ? { width } : undefined} > -
+
{stacked ? ( - // The same label as the builder's own fields, so stacked fields line up with them. - + // 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. + ) : ( {sentenceCaseLabel(variable.label)} )} diff --git a/app/src/components/household/VariableSearchDropdown.tsx b/app/src/components/household/VariableSearchDropdown.tsx index 67b3d35aa..a929b924b 100644 --- a/app/src/components/household/VariableSearchDropdown.tsx +++ b/app/src/components/household/VariableSearchDropdown.tsx @@ -3,8 +3,11 @@ * 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 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'; @@ -29,25 +32,27 @@ export interface VariableSearchDropdownProps { suggested?: ReadonlyMap; suggestedLabel?: 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. + * 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 picker's tiles, topic icons and display names. */ + common?: CommonInput[]; + groupIcon?: (group: string) => ReactNode; + nameOf?: (variable: VariableInfo) => string; + describe?: (variable: VariableInfo) => string | undefined; + /** With `groupOf`: the dialog's title. */ + title?: string; + /** + * 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; @@ -77,11 +82,16 @@ export default function VariableSearchDropdown({ suggested, suggestedLabel = 'Suggested', groupOf, + title = 'Add a variable', + subgroupOf, + subgroupOrder = [], + common, + groupIcon, + nameOf, + describe, 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); @@ -115,65 +125,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 })), - ]; - const chosen = all.find((section) => section.key === activeGroup); - 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, - ]); - // 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); @@ -186,6 +143,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(); @@ -226,6 +195,32 @@ export default function VariableSearchDropdown({ inputRef.current?.focus(); }, []); + // Grouped, the search is a dialog of its own, centered over the page. + if (groupOf) { + return ( + + ); + } + return (
@@ -251,10 +246,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 @@ -265,130 +257,7 @@ export default function VariableSearchDropdown({ />
- {isOpen && grouped && ( -
- {grouped.chips.length > 1 && ( -
- {[{ key: null, label: 'All' }, ...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); - return ( -
{ - e.preventDefault(); - handleSelect(variable); - }} - > -
-

- {sentenceCaseLabel(variable.label)} -

- {variable.documentation && ( -

- {variable.documentation} -

- )} -
- {entityHint?.show && ( - - {entityHint.label} - - )} -
- ); - })} - {section.more > 0 && ( - - )} -
- ); - }) - )} -
-
- )} - - {isOpen && !grouped && ( + {isOpen && (
, - 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/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..34705954c 100644 --- a/app/src/tests/unit/components/household/HouseholdBuilderForm.test.tsx +++ b/app/src/tests/unit/components/household/HouseholdBuilderForm.test.tsx @@ -81,6 +81,46 @@ 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$/ })); + 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']); + }); + 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/components/household/VariablePickerDialog.test.tsx b/app/src/tests/unit/components/household/VariablePickerDialog.test.tsx new file mode 100644 index 000000000..c0d007bf0 --- /dev/null +++ b/app/src/tests/unit/components/household/VariablePickerDialog.test.tsx @@ -0,0 +1,148 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import VariablePickerDialog, { inputKind } 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' }, + { name: 'not_offered_here', label: 'Gas' }, + ]} + {...props} + /> + ); + return { onSelect }; +} + +const rows = () => screen.queryAllByRole('option').map((option) => option.textContent); + +describe('VariablePickerDialog', () => { + test('given it opens then the featured inputs it offers lead, listed by name', async () => { + const user = userEvent.setup(); + const { onSelect } = renderPicker({ + nameOf: (v) => (v.name === 'pre_subsidy_rent' ? 'Rent' : v.label), + }); + + expect(screen.getByRole('dialog', { name: 'Add a variable to you' })).toBeInTheDocument(); + // 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('option', { 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 after the featured', () => { + renderPicker({ + 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(['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(); + }); + + test('given an input without documentation then our description and its kind describe it', async () => { + const user = userEvent.setup(); + renderPicker({ + searchValue: 'gas', + variables: [ + { ...input('gas_expense', 'Gas expense'), unit: 'currency-USD', definitionPeriod: 'year' }, + ] as VariableInfo[], + describe: () => 'What the household pays for natural gas in a year.', + }); + + await user.hover(screen.getByRole('option', { name: 'Gas expense' })); + + expect( + screen.getByText('What the household pays for natural gas in a year. · Dollars per year') + ).toBeInTheDocument(); + }); +}); + +describe('inputKind', () => { + test('given unit, type and period then it names the kind of value', () => { + const kind = (fields: Partial) => + inputKind({ ...input('x', 'X'), valueType: 'float', ...fields } as VariableInfo); + expect(kind({ unit: 'currency-USD', definitionPeriod: 'year' })).toBe('Dollars per year'); + expect(kind({ unit: 'currency-USD', definitionPeriod: 'month' })).toBe('Dollars per month'); + expect(kind({ valueType: 'bool' })).toBe('Yes or no'); + expect(kind({ valueType: 'Enum' })).toBe('One of a list'); + expect(kind({ valueType: 'int' })).toBe('Number'); + }); +}); diff --git a/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx b/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx index 24a20d115..0a7c9e859 100644 --- a/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx +++ b/app/src/tests/unit/components/household/VariableSearchDropdown.test.tsx @@ -54,30 +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(); - expect(screen.getAllByRole('option')).toHaveLength(3); - - await user.click(screen.getByRole('button', { name: /Housing/ })); - - expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual(['Rent']); - expect(screen.getByRole('button', { name: /Housing/ })).toHaveAttribute('aria-pressed', 'true'); - }); }); 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 + ); + }); +}); diff --git a/app/src/utils/VariableResolver.ts b/app/src/utils/VariableResolver.ts index 2f199f967..27cd521d4 100644 --- a/app/src/utils/VariableResolver.ts +++ b/app/src/utils/VariableResolver.ts @@ -27,6 +27,8 @@ export interface VariableInfo { moduleName: string; possibleValues?: Array<{ value: string; label: string }>; documentation?: string; + /** "year", "month", "day" or "eternity" */ + definitionPeriod?: string; } /** diff --git a/app/src/utils/variableMetadata.ts b/app/src/utils/variableMetadata.ts index 1d696d38a..c8f51385d 100644 --- a/app/src/utils/variableMetadata.ts +++ b/app/src/utils/variableMetadata.ts @@ -32,6 +32,7 @@ export function getNormalizedVariableMetadata(variable: Record | nu return { defaultValue: rawVariable.defaultValue ?? rawVariable.default_value, + definitionPeriod: rawVariable.definitionPeriod ?? rawVariable.definition_period, documentation: rawVariable.documentation || rawVariable.description, entity: rawVariable.entity ?? '', hiddenInput: rawVariable.hiddenInput ?? rawVariable.hidden_input ?? false,