Repository navigation
[DT-3313] [2/6] Derive filter options from the full corpus, not the current page - #3936
Conversation
Coverage Report for DUOS Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||
The Models and Workspaces grids each hand-rolled the same two column shapes: a chip list capped at three with the rest behind a "+N" tooltip, and an ellipsised single-line cell with the full text in a tooltip. Presentations and Publications keep their own tag columns; theirs use `nowrap` and no overflow tooltip, so folding them in would change behaviour rather than just deduplicate. Also seeds `parseFiltersFromUrl` with `EMPTY_FILTERS`, so a key missing from every param config reads as empty rather than arriving undefined and throwing on `filters[key].length`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3e28c46 to
2738b2f
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Address the two moderate findings and add the requested clinical-trial and biospecimen test coverage.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR updates Data Library filters to derive options from the full matching corpus instead of the current page while preserving selected values.
Changes:
- Adds full-corpus queries with active-tab filters cleared.
- Recomputes workspace, clinical-trial, and biospecimen options.
- Preserves selected values excluded by other filters.
- Adds corpus-wide behavior tests.
File summaries
| File | Summary | Final review findings |
|---|---|---|
test/hooks/useLibraryPageState.spec.tsx |
Tests full-corpus options and selected-value behavior. | No findings. |
src/hooks/useLibraryPageState.ts |
Builds corpus-wide dynamic filter options. | Moderate (2 votes): stale placeholder data may be used during corpus changes. Moderate (1 vote): avoid the extra corpus request for static tabs. Nit (1 vote): add clinical-trial and biospecimen coverage. |
src/components/data_library/filterRegistry.ts |
Retains selected filter values. | No findings. |
Review details
Suppressed comments (2)
src/hooks/useLibraryPageState.ts:213
- The new full-corpus path has separate calls for workspace, clinical-trial, and biospecimen transforms, but the added hook tests only assert workspace values. A regression in the registry entry or transform invocation for
clinicalTrialItemsorbiospecimenItemswould pass all of these tests; add cases with multiple nested trials/biospecimens and a small page size (including an active own filter) that assert the full-corpus options.
const clinicalTrialItems = fullCorpusItems<{ registry?: string }>(AssetType.CLINICAL_TRIALS)
const biospecimenItems = fullCorpusItems<{ optionalDataUse?: string }>(AssetType.BIOSPECIMENS)
src/hooks/useLibraryPageState.ts:135
- When the active tab is Publications, Presentations, Intellectual Property, or Funding Resources and one of its own filters is set, this clears those keys and line 135 starts a second full-corpus counts request.
availableFiltersonly callsfullCorpusItemsfor Workspaces, Clinical Trials, and Biospecimens (lines 211-213), so this response is never consumed for those tabs; keep the second hook onurlState.filtersunless the tab is one of the three dynamic-option assets.
const { data: visibleTabCorpus } = useLibraryTabCounts(libraryConfig, optionCorpusFilters, urlState.query ?? '')
- Files reviewed: 8/8 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
8ac602d to
c045362
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Address placeholder-data handling in useLibraryPageState.ts and add transition coverage.
Review details
Suppressed comments (1)
src/hooks/useLibraryPageState.ts:145
- This fallback only checks
isCorpusStale, but the counts query can also be serving its previous response as placeholder data after a filter edit. In that state both responses can represent the old study set; for a query-level filter such asaccessManagement,transformResponse(..., EMPTY_FILTERS)cannot remove the excluded studies, so the option list can expose values outside the current result set until the request completes. Please also account for the counts query's placeholder state (or avoid deriving options from placeholder data) and cover this transition with a test.
[assetType, assetType === urlState.tab && !isCorpusStale
? (visibleTabCorpus ?? tabCountsResponse)
: tabCountsResponse])),
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Lite
LibraryFilters kept its own key lists — CHECKBOX_FILTER_KEYS, BOOLEAN_FILTER_KEYS and a hardcoded chain of date keys — duplicating what FILTER_CONTROL_BY_KEY already records. A key registered in the registry but absent from the matching list fell through to `null`, so the filter existed everywhere except the panel. Every section already carries the registry's own `control`, so dispatch reads that. The two key types come from FilterState rather than a hand list, so the narrowing cannot drift either. `participantCount` and `biospecimenPostMortemInterval` stay explicit: both are `range` with their own renderers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bbb9e93 to
1bf6217
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Three unresolved moderate findings remain in src/hooks/useLibraryPageState.ts.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
src/hooks/useLibraryPageState.ts:200
- This treats
STUDIES_AGGas the entire corpus, but that aggregation is atermsquery capped at 10,000 study buckets (src/components/data_library/assets/definition.ts:43-47). If the matching library grows past that cap, any workspace/tool, platform, registry, or optional-data-use value found only in omitted studies will never be offered, so these filters are not actually corpus-complete. Use a paginated/composite aggregation (or otherwise remove the bucket cap) for option discovery before relying on this as the full corpus.
// Option lists are derived from the *entire* matching corpus, not just the
// active tab's current page: `tabCountsResponse` already carries every
// matching study (via STUDIES_AGG's `terms.size: 10000`), so re-running an
// asset's own `transformResponse` with an unbounded page size yields its
src/hooks/useLibraryPageState.ts:145
- When the query key changes (for example after a search or an external-filter edit), the first
useLibraryTabCountsobserver also serves its previous response as placeholder data. If the cleared-set observer is stale here, this fallback is therefore not necessarily scoped to the current filters and can temporarily offer values from studies excluded by the new query. Track the counts observer'sisPlaceholderDataand avoid usingtabCountsResponseas a current-corpus fallback while it is stale (or otherwise use only data keyed to the current filters).
[assetType, assetType === urlState.tab && !isCorpusStale
? (visibleTabCorpus ?? tabCountsResponse)
: tabCountsResponse])),
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Two moderate issues and one nit remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/hooks/useLibraryPageState.ts:158
tabCountsResponseis not guaranteed to be keyed by the live filters here: the firstuseLibraryTabCountsobserver uses the sameplaceholderDatabehavior, so it also serves the previous response after a filter or search change. WhenisCorpusStaleis true, this fallback can therefore derive options from that stale response and re-offer values excluded by the current filters. Expose the first observer's placeholder state and only fall back to a response whose query matches the current filters (otherwise keep the corpus empty while loading).
src/hooks/useLibraryPageState.ts:148
- When a derived tab has one of its own filters set, this mounts a second
useLibraryTabCountsrequest. That hook includesSTUDIES_AGGwith up to 10,000top_hitsand_source: ['study.*'], so one interaction now downloads and materializes two full study corpora; on large libraries this doubles Elasticsearch work, response size, and browser memory. Consider a lightweight distinct-value aggregation or a corpus query projecting only fields needed for these options.
const { data: visibleTabCorpus, isPlaceholderData: isCorpusStale } = useLibraryTabCounts(libraryConfig, optionCorpusFilters, urlState.query ?? '')
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
fe3f3e3 to
d518a95
Compare
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5d5345c to
79478ae
Compare
The Models and Workspaces grids each hand-rolled the same two column shapes: a chip list capped at three with the rest behind a "+N" tooltip, and an ellipsised single-line cell with the full text in a tooltip. Presentations and Publications keep their own tag columns; theirs use `nowrap` and no overflow tooltip, so folding them in would change behaviour rather than just deduplicate. Also extracts `tabCountsQueryOptions` and seeds `parseFiltersFromUrl` with `EMPTY_FILTERS`, so a key missing from every param config reads as empty rather than arriving undefined and throwing on `filters[key].length`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Workspace Tools, Workspace Platform, Registry and Optional Data Use option lists were read off `data.items`, which only holds the active tab's current page: they listed whatever happened to be on screen and went empty the moment the user switched tabs. They now come from the whole matching corpus. `tabCountsResponse` already carries every matching study, so re-running an asset's own transform with an unbounded page size yields its full item list whichever tab is rendering. The visible tab additionally reads a corpus with its own filter keys cleared. The filter clauses match whole studies, so selecting one value drops every study without it, and a sibling value living only in one of those studies could never be offered again. Filters owned by other tabs stay applied, so the options stay scoped the way the grid is. That clearing is a no-op until the tab has one of its own filters set, so the second query shares the counts query's key, and its request, until it does. A value excluded by another tab's filter is missing from that corpus too, so `withSelectedValues` re-adds anything currently selected — otherwise the checkbox disappears while the filter stays active, with nothing left to clear it but a full reset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`mock.calls` accumulated across tests, so the assertion on which filter sets were queried could have been satisfied by an earlier test's call. The test named for placeholder data was exercising the `?? tabCountsResponse` fallback instead, and its stubbed counts response was not narrowed by the active filter, so it demonstrated nothing about the fallback being safe. It now narrows that response and asserts the list is short until the cleared-set corpus lands, then widens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The corpus query serves the previous answer as placeholder data while a new key loads, so on a tab switch it is still scoped by the previous tab's cleared keys. Deriving options from it then offers values from studies the current filters exclude, and the panel disagrees with the grid. The counts response stands in while the corpus is placeholder: narrower for a moment, but scoped the way the grid is, and withSelectedValues still keeps an active selection listed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clearing the visible tab's keys was gated on it being any study-asset tab, so setting a filter on a tab whose options come from a static enum produced a second query key and a full STUDIES_AGG request that nothing consumed. Membership in CORPUS_DERIVED_OPTION_ASSETS is what earns the extra request now, and an asset joins in the same change that gives it a derived option. Also corrects the comment above the derivation: STUDIES_AGG caps at 10,000 study buckets, so this is every study the shared aggregation returns rather than the entire corpus. The cap is the one the badge counts and these grids already live with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
uniqueValues trims indexed values when building options, so the row-level predicates that compare against a selection need the same normalization or a filter drops the very row that produced its option. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its callers are all in the sibling asset PRs, so on this branch it is a dead export; each of those inlines the trim where it normalizes a field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deriving options from a second useLibraryTabCounts meant one interaction downloaded two full study corpora: the same 10,000-bucket STUDIES_AGG with `_source: ['study.*']`, plus count aggregations the options never read. useOptionCorpus asks for what options actually use — `study.studyId` and `study.assets.*`, no badge aggregations — and takes `enabled`, so nothing is fetched unless clearing the tab's own keys widens the corpus. Previously that was incidental on the two query keys matching; now it is explicit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6089499 to
f2c1770
Compare
There was a problem hiding this comment.
Looks reasonable, two findings identified with Claude:
1. Option corpus clears all visible filters, not just the corpus-derived ones: src/hooks/useLibraryPageState.ts:124
The option corpus clears every filter in assetFilterRegistry[tab].visibleFilters, not just the checkbox filters whose options come from that corpus.
- On Clinical Trials,
clinicalTrialDatesgets cleared, soclinicalTrialRegistryoptions are derived from trials outside the date window.fullCorpusItemsalso passesEMPTY_FILTERS, so the client-side date pass is skipped too. Checking such a registry makesmatchesClinicalTrialFiltersreject every row → empty grid,0badge. - Same on Biospecimens for
biospecimenCollectionDate,biospecimenPostMortemInterval, and the shared study-levelparticipantCountvs.biospecimenDataUse. - Workspaces is unaffected — its only visible filters are the two option filters.
This is a regression: options previously came from data.items, which was filtered by the full filter set, so an offered value could never match nothing.
Fix: clear only the corpus-derived checkbox keys.
2. withSelectedValues appends instead of merging, breaking alphabetical order: src/components/data_library/filterRegistry.ts:645
withSelectedValues concatenates missing selected values onto the end of the option array, breaking the alphabetical order the lists are built with. LibraryFilters.renderCheckboxSection renders in array order with no sorting or show-more, so a still-checked value re-added this way appears at the bottom of a long list.
Fix: merge into the sort instead of appending.
The corpus cleared every filter in the tab's visibleFilters, and the transform re-ran with EMPTY_FILTERS on top, so Clinical Trials offered registries found only on trials outside the active date window — checking one emptied the grid. Biospecimens had the same hole on its date, interval and participant filters. Clear only the keys whose options that corpus supplies and keep the rest applied, so an offered value always matches at least one visible row. A date-only edit now widens nothing and mounts no second aggregation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
withSelectedValues appended a still-checked value the corpus no longer offers, and the checkbox sections render in array order with no sort or show-more, so it landed below every other option in a long list. Merge it into the order instead, but only where the list is already alphabetical — the enum lists are deliberately ordered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Both fixed, thanks.
|



Addresses
DT-3313 — 2 of 6, splitting #3926 per review. Stacked on #3935. Security risk: no.
Summary
This is the one carrying the risk, so it lands against the existing filter set only — no new filter keys — as suggested in review.
Workspace Tools, Workspace Platform, Registry and Optional Data Use derived their options from
data.items, which only holds the active tab's current page. They listed whatever happened to be on screen, and went empty the moment the user switched tabs. That's the bug this fixes.They now come from the whole matching corpus.
tabCountsResponsealready carries every matching study viaSTUDIES_AGG'sterms.size: 10000, so re-running an asset's owntransformResponsewith an unbounded page size yields its full flattened item list whichever tab is rendering. This mirrorslibraryCounts.ts, which already callstransformResponsea second time with different pagination purely to read.total. To be explicit, since it came up in review: that unbounded page size is client-side pagination over a response already in memory, not an Elasticsearch request.The visible tab additionally reads a corpus with its own filter keys cleared, which is the part worth the attention. The filter clauses match whole studies, so selecting one value drops every study without it, and a sibling value living only in one of those studies could never be offered again — no client-side pass can recover a study the response never carried. Filters owned by other tabs stay applied, so the options stay scoped the way the grid is. The clearing is a no-op until the tab actually has one of its own filters set, so the second
useLibraryTabCountsshares the counts query's key, and its request, until it does.A value excluded by another tab's filter is absent from that corpus too, since that filter stays applied.
withSelectedValuesre-adds anything currently selected, or the checkbox disappears while the filter stays active — and the external chips deliberately skip keys the current tab renders itself, so nothing would be left to clear it with short of resetting every filter.Eight new tests. Mutation-checked rather than trusted on coverage: collapsing the corpus map to the unfiltered response fails the self-exclusion test, and short-circuiting
withSelectedValuesfails its own. The first pass of tests missedwithSelectedValuesentirely — every case I'd written was already satisfied by self-exclusion — so the test that isolates it uses a filter owned by another tab, which is the only situation where self-exclusion cannot help.