diff --git a/app/src/components/flagship/ParameterSearchBox.tsx b/app/src/components/flagship/ParameterSearchBox.tsx index 9580e8d4a..6cef8358c 100644 --- a/app/src/components/flagship/ParameterSearchBox.tsx +++ b/app/src/components/flagship/ParameterSearchBox.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react'; -import { IconFolder, IconSearch } from '@tabler/icons-react'; +import { IconArrowRight, IconFolder, IconInfoCircle, IconSearch } from '@tabler/icons-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { createParameterSearchIndex, @@ -22,8 +23,26 @@ interface ParameterSearchBoxProps { currentValueFor?: (entry: ParameterSearchEntry) => string | null; /** Derived concept clusters for variant-aware matching */ clusters?: string[][]; + /** State code → name, so the scope filter reads "California", not "CA only" */ + stateLabels?: Record; + /** + * Called with a folder's parameter path when its header is clicked. + * Search shows only the leaves that matched; this is how a reader gets + * to the siblings that did not. + */ + onOpenFolder?: (folderPath: string) => void; } +/** The parent path of a leaf: gov.irs.credits.ctc.amount → gov.irs.credits.ctc. */ +function parentPath(path: string): string | null { + const lastDot = path.lastIndexOf('.'); + return lastDot > 0 ? path.slice(0, lastDot) : null; +} + +const CONTRIB_EXPLANATION = + 'Policy options contributed to the model — proposed reforms and ' + + 'experimental provisions that are not current law.'; + const RESULT_LIMIT = 20; const badgeStyle: React.CSSProperties = { @@ -89,6 +108,8 @@ export default function ParameterSearchBox({ placeholder = 'Search any parameter, e.g. child tax credit amount', currentValueFor, clusters = [], + stateLabels = {}, + onOpenFolder, index: providedIndex, }: ParameterSearchBoxProps) { const [query, setQuery] = useState(''); @@ -99,7 +120,15 @@ export default function ParameterSearchBox({ () => providedIndex ?? createParameterSearchIndex(entries, clusters), [providedIndex, entries, clusters] ); - const stateCodes = useMemo(() => listStateCodes(entries), [entries]); + // Named states sort by name; any code the metadata does not name falls + // to the end of the list under its bare code. + const stateOptions = useMemo( + () => + listStateCodes(entries) + .map((code) => ({ code, label: stateLabels[code] ?? code.toUpperCase() })) + .sort((a, b) => a.label.localeCompare(b.label)), + [entries, stateLabels] + ); const groups = useMemo( () => groupSearchResults(searchParameters(index, query, RESULT_LIMIT, filters)), [index, query, filters] @@ -143,7 +172,7 @@ export default function ParameterSearchBox({ flexWrap: 'wrap', }} > - {stateCodes.length > 0 && ( + {stateOptions.length > 0 && ( )} - +
+ + {/* Outside the label: a control inside it would toggle the filter. */} + + + + + + {CONTRIB_EXPLANATION} + + +
1 && group.folder; return (
- {isFolder && ( -
{ + const folderPath = parentPath(group.entries[0].path); + const headerStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: spacing.xs, + width: '100%', padding: `${spacing.sm} ${spacing.lg} ${spacing.xs}`, fontSize: typography.fontSize.xs, fontFamily: typography.fontFamily.primary, fontWeight: typography.fontWeight.semibold, color: colors.text.secondary, - }} - > - - {group.folder} -
- )} + textAlign: 'left', + }; + // Only a folder with a resolvable path can be opened; + // otherwise the header stays the label it was. + if (!onOpenFolder || !folderPath) { + return ( +
+ + {group.folder} +
+ ); + } + return ( + + ); + })()} {group.entries.map((entry) => { runningIndex += 1; const i = runningIndex; @@ -294,6 +392,11 @@ export default function ParameterSearchBox({ fontFamily: typography.fontFamily.primary, color: colors.text.primary, fontWeight: typography.fontWeight.medium, + // Without a zero min-width this column collapses to + // its longest word when a value runs long, stacking + // the label one word per line. + flex: 1, + minWidth: 0, }} > {isFolder @@ -306,16 +409,22 @@ export default function ParameterSearchBox({ alignItems: 'center', gap: spacing.sm, flexShrink: 0, + // List-valued parameters (a dozen variable names) + // must not push the label out of its own row. + maxWidth: '45%', }} > {currentValueFor?.(entry) && ( {currentValueFor(entry)} diff --git a/app/src/components/flagship/ParameterTreeBrowser.tsx b/app/src/components/flagship/ParameterTreeBrowser.tsx index f0e62f1e4..c4df0c27d 100644 --- a/app/src/components/flagship/ParameterTreeBrowser.tsx +++ b/app/src/components/flagship/ParameterTreeBrowser.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { IconChevronDown, IconChevronRight, IconPlus } from '@tabler/icons-react'; import { Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; @@ -12,6 +12,18 @@ interface ParameterTreeBrowserProps { addablePaths: Set; /** Paths already in the draft — shown with an "In draft" tag. */ draftPaths: Set; + /** + * Folder path to reveal — its ancestors expand, it opens, and it + * scrolls into view. Set when a search result's folder is opened, so + * a near miss leads to the parameters around it. + */ + expandTo?: string | null; +} + +/** Every ancestor path of a dotted parameter path, plus the path itself. */ +function pathWithAncestors(path: string): string[] { + const segments = path.split('.'); + return segments.map((_, index) => segments.slice(0, index + 1).join('.')); } /** @@ -24,8 +36,24 @@ export default function ParameterTreeBrowser({ onSelectLeaf, addablePaths, draftPaths, + expandTo = null, }: ParameterTreeBrowserProps) { const [expanded, setExpanded] = useState>(new Set()); + const containerRef = useRef(null); + + useEffect(() => { + if (!expandTo) { + return; + } + setExpanded((prev) => new Set([...prev, ...pathWithAncestors(expandTo)])); + // The rows for those ancestors only exist after the expansion renders. + const frame = requestAnimationFrame(() => { + const rows = containerRef.current?.querySelectorAll('[data-path]') ?? []; + const target = [...rows].find((row) => row.getAttribute('data-path') === expandTo); + target?.scrollIntoView({ block: 'center' }); + }); + return () => cancelAnimationFrame(frame); + }, [expandTo]); if (!tree) { return ( @@ -62,6 +90,7 @@ export default function ParameterTreeBrowser({
- - - + + + {/* Folded, the count is all that is left to say what is in there. */} + {open ? SOURCE_NOTES[draft.source] : provisionCount} + + + {open && ( +
+ + + {draft.provisions.map((provision) => ( + - {formatValue(provision.baselineValue, provision.unit)} → - - - + + + {formatCompactBreadcrumb(provision.breadcrumb || provision.path)} + + + + + + {formatValue(provision.baselineValue, provision.unit)} → + + + + + ))} - ))} - - - - setDraftLabel(event.target.value)} - placeholder="Name this reform, e.g. CTC expansion 2026" - aria-label="Reform name" - style={{ - padding: `${spacing.sm} ${spacing.md}`, - border: `1px solid ${colors.border.light}`, - borderRadius: 8, - fontSize: typography.fontSize.sm, - fontFamily: typography.fontFamily.primary, - }} - /> - {!hasEditedValue && draft.provisions.length > 0 && ( - - Values match current law so far — edit a value above to make this a reform. - - )} - - - - {( - [ - { scope: 'national', label: 'Nationwide', enabled: true }, - { scope: 'household', label: 'A household', enabled: false }, - ] as const - ).map(({ scope, label, enabled }) => { - const active = draft.population.scope === scope; - return ( - - ); - })} - - - - - - Baseline: current law · {new Date().getFullYear()} - - {runReport.error && ( - - {runReport.error} - - )} - + /> + {!hasEditedValue && draft.provisions.length > 0 && ( + + Values match current law so far — edit a value above to make this a reform. + + )} + - - {saveMutation.isError && ( - - Could not save the reform. Try again. - - )} - - - - - - + {( + [ + { scope: 'national', label: 'Nationwide', enabled: true }, + { scope: 'household', label: 'A household', enabled: false }, + ] as const + ).map(({ scope, label, enabled }) => { + const active = draft.population.scope === scope; + return ( + + ); + })} + + + + + + Baseline: current law · {new Date().getFullYear()} + + {runReport.error && ( + + {runReport.error} + + )} + + + + {saveMutation.isError && ( + + Could not save the reform. Try again. + + )} + + + + + + +
+ )}
); } diff --git a/app/src/libs/metadataUtils.ts b/app/src/libs/metadataUtils.ts index 533514933..42c6760ae 100644 --- a/app/src/libs/metadataUtils.ts +++ b/app/src/libs/metadataUtils.ts @@ -58,6 +58,20 @@ export const getRegions = createSelector( })) || [] ); +/** + * Two-letter state code → the model's own name for it ("ca" → + * "California"), read off the region options rather than a name list. + */ +export const getStateLabels = createSelector( + (state: RootState) => state.metadata.economyOptions.region, + (regions): Record => + Object.fromEntries( + (regions ?? []) + .filter((region) => region.name?.startsWith('state/')) + .map((region) => [region.name.slice('state/'.length), region.label]) + ) +); + export const getBasicInputFields = createSelector( [ (state: RootState) => state.metadata.basicInputs, diff --git a/app/src/pages/flagship/Build.page.tsx b/app/src/pages/flagship/Build.page.tsx index a5e2e1d1d..9445121d3 100644 --- a/app/src/pages/flagship/Build.page.tsx +++ b/app/src/pages/flagship/Build.page.tsx @@ -1,11 +1,14 @@ +import { useState } from 'react'; +import { IconChevronDown } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import ParameterSearchBox from '@/components/flagship/ParameterSearchBox'; import ParameterTreeBrowser from '@/components/flagship/ParameterTreeBrowser'; import WorkspaceLayout from '@/components/flagship/WorkspaceLayout'; -import { Stack, Text, Title } from '@/components/ui'; +import { Button, Stack, Text, Title } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { addDraftProvision, provisionFromSearchEntry, useDraftReform } from '@/libs/draftReform'; +import { getStateLabels } from '@/libs/metadataUtils'; import { ParameterSearchEntry, selectAddableParameterPaths, @@ -28,11 +31,17 @@ export default function BuildPage() { const countryId = useCurrentCountry(); const entries = useSelector(selectParameterSearchEntries); const clusters = useSelector(selectConceptClusters); + const stateLabels = useSelector(getStateLabels); // Store-memoized: survives navigation, so Build mounts don't rebuild it. const searchIndex = useSelector(selectParameterSearchIndex); const parameters = useSelector((state: RootState) => state.metadata.parameters); const parameterTree = useSelector((state: RootState) => state.metadata.parameterTree); const draft = useDraftReform(); + // Search is the surface; the tree is the fallback for when you do not + // know what the thing is called, so it stays out of the way until asked for. + const [showTree, setShowTree] = useState(false); + // The folder a search result pointed at, revealed in the tree below. + const [treeFocus, setTreeFocus] = useState(null); // Store-memoized like the index: built once per metadata load, not // per navigation or render. @@ -51,55 +60,105 @@ export default function BuildPage() { return ( - - Build a reform - - Search {entries.length > 0 ? entries.length.toLocaleString() : 'every'} parameter - {entries.length > 0 ? 's' : ''} — click one to add it to your draft. - - + + + Build a reform + + Search {entries.length > 0 ? entries.length.toLocaleString() : 'every'} parameter + {entries.length > 0 ? 's' : ''} — click one to add it to your draft. + + - {entries.length > 0 ? ( - { - const value = getCurrentValue(parameters?.[entry.path]?.values); - return value === undefined ? null : formatValue(value, entry.unit); - }} - /> - ) : ( - - Loading the parameter index… - - )} + {entries.length > 0 ? ( + { + // Results stay up: the near miss is worth comparing + // against whatever the folder turns out to hold. + setShowTree(true); + setTreeFocus(folderPath); + }} + currentValueFor={(entry) => { + const value = getCurrentValue(parameters?.[entry.path]?.values); + return value === undefined ? null : formatValue(value, entry.unit); + }} + /> + ) : ( + + Loading the parameter index… + + )} - - - Or browse the policy tree - - { - const entry = entriesByPath.get(path); - if (entry) { - addEntry(entry); +
+ +
+ + {showTree && ( +
+ { + const entry = entriesByPath.get(path); + if (entry) { + addEntry(entry); + } + }} + /> +
+ )}
); diff --git a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx index f913cb18e..352063d31 100644 --- a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx +++ b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx @@ -182,7 +182,9 @@ describe('ParameterSearchBox', () => { test('given a state scope then only that state appears with its badge', async () => { // Given const user = userEvent.setup(); - render(); + render( + + ); await user.selectOptions(screen.getByRole('combobox', { name: /state scope/i }), 'ut'); // When @@ -191,7 +193,36 @@ describe('ParameterSearchBox', () => { // Then expect(screen.getByText('Utah → Income tax → Child tax credit → Amount')).toBeInTheDocument(); expect(screen.queryByText('IRS → Credits → Child tax credit → Amount')).not.toBeInTheDocument(); - expect(screen.getByText('UT')).toBeInTheDocument(); + // The badge keeps the code; only the filter option spells the state out. + expect(screen.getByRole('option', { name: 'Utah' })).toBeInTheDocument(); + expect(screen.getAllByText('UT').length).toBeGreaterThan(0); + }); + + test('given state labels then the scope filter names states instead of codes', () => { + // Given / When + render( + + ); + + // Then + expect(screen.getByRole('option', { name: 'Utah' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'UT only' })).not.toBeInTheDocument(); + }); + + test('given no label for a state then the filter falls back to its code', () => { + // Given / When + render(); + + // Then + expect(screen.getByRole('option', { name: 'UT' })).toBeInTheDocument(); + }); + + test('given the contributed filter then its meaning is available to the reader', () => { + // Given / When + render(); + + // Then + expect(screen.getByLabelText(/not current law/i)).toBeInTheDocument(); }); test('given a query with no match then no listbox renders', async () => { @@ -208,4 +239,35 @@ describe('ParameterSearchBox', () => { // Then expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); + + test('given a folder group then its header opens that folder in the tree', async () => { + // Given + const user = userEvent.setup(); + const onOpenFolder = vi.fn(); + render(); + + // When + await user.type(screen.getByRole('combobox', { name: /search parameters/i }), 'eitc'); + await user.click( + screen.getByRole('button', { name: /open irs → credits → eitc in the policy tree/i }) + ); + + // Then — the folder path, not the breadcrumb + expect(onOpenFolder).toHaveBeenCalledWith('gov.irs.credits.eitc'); + }); + + test('given no folder handler then the header stays a label', async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.type(screen.getByRole('combobox', { name: /search parameters/i }), 'eitc'); + + // Then + expect(screen.getByText('IRS → Credits → EITC')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /open irs → credits → eitc in the policy tree/i }) + ).not.toBeInTheDocument(); + }); }); diff --git a/app/src/tests/unit/components/flagship/ParameterTreeBrowser.test.tsx b/app/src/tests/unit/components/flagship/ParameterTreeBrowser.test.tsx index 70a0ed737..3de077a7c 100644 --- a/app/src/tests/unit/components/flagship/ParameterTreeBrowser.test.tsx +++ b/app/src/tests/unit/components/flagship/ParameterTreeBrowser.test.tsx @@ -118,4 +118,35 @@ describe('ParameterTreeBrowser', () => { expect(screen.getByText(/loading the policy tree/i)).toBeInTheDocument(); }); + + test('given expandTo then the folder and its ancestors open to reveal the parameter', () => { + // Given / When + render( + + ); + + // Then — no click needed; the ancestor chain expanded on its own + expect(screen.getByText('Child tax credit amount')).toBeInTheDocument(); + }); + + test('given no expandTo then nothing is expanded', () => { + // Given / When + render( + + ); + + // Then + expect(screen.queryByText('Child tax credit amount')).not.toBeInTheDocument(); + }); }); diff --git a/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx b/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx index 09bd3f43a..c200c4757 100644 --- a/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx +++ b/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx @@ -142,4 +142,47 @@ describe('ReformPreviewCard', () => { expect(screen.getByRole('button', { name: 'A household' })).toBeDisabled(); expect(getDraftReform()?.population).toEqual({ scope: 'national' }); }); + + test('given the header is clicked then the draft folds to its heading and count', async () => { + // Given + const user = userEvent.setup(); + seedDraft(); + renderCard(); + + // When + await user.click(screen.getByRole('button', { name: /here's your draft reform/i })); + + // Then — the count survives the fold; the editing controls do not + expect(screen.getByText('1 provision')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /run report/i })).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Reform name')).not.toBeInTheDocument(); + }); + + test('given a folded draft then clicking again restores the controls', async () => { + // Given + const user = userEvent.setup(); + seedDraft(); + renderCard(); + const header = screen.getByRole('button', { name: /here's your draft reform/i }); + + // When + await user.click(header); + await user.click(header); + + // Then + expect(screen.getByRole('button', { name: /run report/i })).toBeInTheDocument(); + expect(screen.getByLabelText('Reform name')).toBeInTheDocument(); + }); + + test('given the default view then the draft is open', () => { + // Given / When + seedDraft(); + renderCard(); + + // Then — an unseen draft is what this panel exists to prevent + expect(screen.getByRole('button', { name: /here's your draft reform/i })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + }); }); diff --git a/app/src/tests/unit/pages/flagship/Build.test.tsx b/app/src/tests/unit/pages/flagship/Build.test.tsx new file mode 100644 index 000000000..a0af54d52 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/Build.test.tsx @@ -0,0 +1,38 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import BuildPage from '@/pages/flagship/Build.page'; + +describe('BuildPage', () => { + test('given the default view then search leads and the tree is out of the way', () => { + render(); + + expect(screen.getByRole('button', { name: /or browse the policy tree/i })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + expect(screen.queryByText(/loading the policy tree/i)).not.toBeInTheDocument(); + }); + + test('given the toggle is clicked then the policy tree opens', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /or browse the policy tree/i })); + + expect(screen.getByText(/loading the policy tree/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /hide the policy tree/i })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + }); + + test('given the tree is open then clicking again closes it', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /or browse the policy tree/i })); + await user.click(screen.getByRole('button', { name: /hide the policy tree/i })); + + expect(screen.queryByText(/loading the policy tree/i)).not.toBeInTheDocument(); + }); +}); diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 4f987dfd6..7b103d1eb 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1 +1,3 @@ -- Find parameters by acronym or spelled-out name in parameter search, with acronyms derived from the model's own labels +- Name states in the parameter search filter, explain the contributed filter, and stop long parameter values from squeezing result labels +- Lead the build page with search, fold the policy tree behind a toggle, and open a search result's folder in the tree +- Let the draft reform panel fold away