Skip to content

feat(search): parametrise search facets + My Objects in the URL — ST-1b of #1825#1834

Merged
RamanDamayeu merged 2 commits into
mainfrom
contrib/CTRIB-049-search-url-facets
Jul 2, 2026
Merged

feat(search): parametrise search facets + My Objects in the URL — ST-1b of #1825#1834
RamanDamayeu merged 2 commits into
mainfrom
contrib/CTRIB-049-search-url-facets

Conversation

@odd-contributor

@odd-contributor odd-contributor Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Part of #1825 — ST-1b (the facet half of ST-1). Builds on ST-1a (#1833, merged).

What

Extend ST-1a's URL is the source of truth from the query to the 8 facets + My Objects, so a faceted search is shareable, bookmarkable, and back/forward-correct.

  • Reader: the Search page CREATEs a fresh session per distinct URL state (server search() = removeUnselected = REPLACE), because updateFacets MERGEs a delta and could never remove a facet the URL dropped (deselect / Clear-All / class-switch).
  • Mirror: a slice→URL writer (reacts to the redux facetState, gated by isFacetsStateSynced, debounced, pushed) serialises every facet change to the URL from all toggle sites — the Filters sidebar, chip-✕ deselect, Clear-All, and the Results class / My-Objects tabs — without touching them.
  • MainSearchInput merges q into the current params (function updater) so committing a query preserves the active filters.
  • Race-fix: updateSearchState carries pending-unsynced selections across the create REPLACE, so a rapid 2nd toggle is not lost.

Facet ids only (catalog metadata, no PII); recipient-scoped re-eval inherited from /api/search; fail-closed parse. Legacy /search/{sessionId} deep-links + the /api/search contract unchanged (D9).

Tests

  • Unit (node 24): 18/18 — searchUrlState facets/removal/fail-closed round-trips + the slice race-fix (pending-local carry) + useQueryParams (ST-1a, unchanged).
  • Integration IT-151 (odd-team e2e): class-tab → entityClasses[]= URL + refilter · All-tab removal · faceted-deep-link share · back/forward — GREEN on this branch, RED on main (f63d391).
  • Full regression green-for-change: feature-complete 331/2 (both contributor-independent), multi-stack 9/0, ingestion-e2e 15/0, known-bugs 3-RED-expected; every main-search test passes (ST-1a query, tsquery-poisoning, session-not-found/D9).

Milestone: 1.0.0
Docs: documentation@release/1.0.0 (search.md — facets now ride the shareable URL) — publishes with the 1.0.0 release.

🤖 Generated with Claude Code

Rework — review round 1 blocker fixed (02f0ee60)

The odd-team review rejected the first cut with a facet-sync blocker: after a sidebar-facet deselect / Clear-All (or any Statuses select), isFacetsStateSynced stranded false — the deselected id was carried as a phantom pending-local (the create echo omits unselected filters; statuses was never echoed at all), which froze the results re-fetch (Results.tsx fetches only when synced) and kept the facet→URL mirror armed to revert a later back/forward or query commit.

Three changes (root causes fixed at the source; Search.tsx / searchUrlState / MainSearchInput untouched):

  • FacetStateMapperImpl.mapDto echoes statuses like the other 7 facets — it is already filtered on (FORM_MAPPINGS), and FacetState.statuses is an existing optional contract field (additive; no client regen). Also fixes a status deep-link never rendering its chip.
  • updateSearchState reconciles optimistic-vs-requested (meta.arg.searchFormData.filters) instead of the !(id in serverFacet) heuristic: a resolved deselect re-syncs (results reload), a mid-flight deselect is preserved rather than resurrected (the symmetric twin of the mid-flight-selection race), and the selection carry is unchanged.
  • Chip labels survive the echo: a URL-derived create carries ids only and the echo returns name:null — which blanked every selected-filter chip ~1 s after selection (a regression vs the pre-ST-1b PUT flow, found by driving the running UI; wire + pixel captured). The reducer now keeps a label it already knows. A fresh deep-link still renders unlabelled chips (nothing client-side to preserve) — server-side name resolution in the echo is a tracked follow-up (which should then also make the echo honour SearchFilter.required: [id, name]).

Verification (each RED on the pre-fix build, GREEN on the fix — run, not reasoned): slice unit tests (resolved-deselect re-sync · pending-deselect preserved · label kept) + FacetStateMapperImplTest (statuses echo) + IT-151 extended with 2 e2e cases (sidebar deselect/Clear-All reloads + broadens the results, Back reproduces, no stale-mirror revert · a status select reaches the URL, refilters, and keeps its chip label; a statuses[]= deep-link reproduces the filtered results). Full :odd-platform-api:build green. Full regression re-run at this SHA: feature-complete 336 passed / 1 failed (the 1 = the known unmerged-#1815-Group-B favorites spec, contributor-independent), known-bugs 3-RED-expected, multi-stack + ingestion-e2e green.

…s[]=…&my=…) — ST-1b of #1825

Extend ST-1a's URL-as-source-of-truth from the query to the 8 facets + My Objects, so a FACETED
search is shareable, bookmarkable, and back/forward-correct.

- Reader: the Search page CREATEs a fresh session per distinct URL state (server search() =
  removeUnselected = REPLACE), because updateFacets MERGEs a delta and could never REMOVE a facet the
  URL dropped (deselect / Clear-All / class-switch) — the create-per-URL-state design.
- Mirror: a slice→URL writer (reacting to redux facetState, gated by isFacetsStateSynced, debounced,
  pushed) serialises every facet change to the URL from ALL toggle sites — the Filters sidebar,
  chip-✕ deselect, Clear-All, AND the Results class / My-Objects tabs — without touching them.
- MainSearchInput merges q into the current params (function updater) so committing a query preserves
  the active filters instead of clobbering them.
- Race-fix: create-per-URL-state REPLACE could drop an option toggled while a create is in flight (a
  rapid 2nd selection); updateSearchState now carries pending-unsynced locals across the new-session
  REPLACE so no selection is lost, and stays !synced so the mirror re-fires the newer state.

Facet ids only (catalog metadata, no PII); recipient-scoped re-eval inherited from /api/search;
fail-closed parse. Legacy /search/{sessionId} deep-links + the /api/search contract unchanged (D9).

Unit (node 24): searchUrlState facets/myObjects/removal/fail-closed round-trips + the slice race-fix
(pending-local carry) + useQueryParams (ST-1a, unchanged) — 18/18 green.

Part of #1825 — ST-1b (no closing keyword; the epic closes at ST-4 + ST-7..ST-13).

Consumer-read: Search.tsx, MainSearchInput.tsx, searchRoutes.ts, useQueryParams.ts,
dataEntitySearch.slice.ts, dataentitySearch.selectors.ts, dataentitiesSearch.thunks.ts, Results.tsx,
SearchResultsTabs.tsx, Filters.tsx, SingleFilterItem.tsx, SelectedFilterOption.tsx,
MultipleFilterItemAutocomplete.tsx, generated-sources/{FacetState,SearchFormData,SearchFormDataFilters,
SearchFilterState}; odd-platform-api SearchServiceImpl.java + FacetStateDto.java (merge-vs-replace).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

This comment has been minimized.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Backend Coverage

Overall Project 52.21% 🍏

There is no coverage information present for the Files changed

…resolve deselects, keep chip labels (ST-1b B1)

The /review of ST-1b found BLOCKER B1: after a sidebar-facet deselect / Clear-All (or any statuses
select), isFacetsStateSynced stranded false — the deselected id was carried as a phantom pending-local
(the create echo omits unselected filters; statuses was never echoed at all), which froze the results
re-fetch (Results.tsx fetches only when synced) and kept the facet→URL mirror armed to revert a later
back/forward or query commit. Root-caused on the running system; three changes:

- (A) FacetStateMapperImpl.mapDto echoes `statuses` like the other 7 facets (FORM_MAPPINGS already
  FILTERS on it; FacetState.statuses is an existing NOT_REQUIRED contract field — additive, no client
  regen). Without the echo a selected status is structurally indistinguishable from a pending local.
- (B′) updateSearchState's new-session branch reconciles the optimistic slice against what THIS create
  actually requested (meta.arg.searchFormData.filters) instead of the `!(id in serverFacet)` heuristic:
  an option whose `selected` differs from the request is a genuine pending change (kept; synced stays
  false so the mirror re-fires); everything the create covered takes the authoritative server value.
  Fixes the stranded-synced class AND the symmetric rapid double-DESELECT lost-update (a facet
  deselected mid-flight is still `selected` in that create's response — the old heuristic resurrected
  it).
- Label-preserve: a URL-derived create request carries facet IDS only, and the echo returns the
  request's names — name:null on the wire (captured live) — so the reducer keeps an already-known
  entityName when the echo has none. Without this every sidebar chip rendered a bare ✕ ~1s after
  selection (TextFormatted renders nothing for undefined). A FRESH deep-link still renders unlabelled
  chips (nothing known to preserve) — recipient-side label resolution is a tracked follow-up (the echo
  should then also honour SearchFilter.required:[id,name]).

Tests (all RED on the pre-fix base, GREEN on the fix; run, not reasoned):
- slice: a resolved deselect is dropped AND re-syncs (#3); a pending mid-flight deselect is preserved,
  not resurrected (#4); a name-less echo keeps the known chip label (#5); the in-flight SELECT carry
  (#1) + clean REPLACE (#2) stay green under the new predicate.
- FacetStateMapperImplTest: mapDto echoes selected statuses (getStatuses() null on the base).
- e2e IT-151 (odd-team): sidebar deselect/Clear-All reloads+broadens the results, Back reproduces the
  tagged state, no stale-mirror revert; a status select reaches the URL, refilters, and keeps its chip
  label; a statuses[]= deep-link reproduces the filtered results. 4/4 on the fix; the 2 new cases fail
  on the pre-fix build; all fail on the pre-ST-1b base.

Part of #1825 — ST-1b (no closing keyword; the epic closes at ST-4 + ST-7..ST-13).

Consumer-read: SearchServiceImpl.java, FacetStateDto.java, FacetStateMapperImpl.java,
SearchMapperImpl.java, SearchController.java, FTSConstants.java (STATUSES filter semantics),
DataEntityStatusDto.java, dataEntitySearch.slice.ts, dataentitySearch.selectors.ts, Search.tsx,
searchUrlState.ts, Results.tsx (synced fetch-gate), SelectedFilterOption.tsx, MultipleFilterItem.tsx,
MultipleFilterItemAutocomplete.tsx, TextFormatted.tsx, Filters.tsx,
generated-sources/{FacetState,SearchFilter,SearchFormData,SearchFilterState}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Test Results

164 files  164 suites   3m 46s ⏱️
638 tests 638 ✔️ 0 💤 0
669 runs  669 ✔️ 0 💤 0

Results for commit 02f0ee6.

@RamanDamayeu RamanDamayeu marked this pull request as ready for review July 2, 2026 09:50
@RamanDamayeu RamanDamayeu requested review from a team as code owners July 2, 2026 09:50
@RamanDamayeu RamanDamayeu self-requested a review July 2, 2026 09:50
@RamanDamayeu RamanDamayeu merged commit ab63b6d into main Jul 2, 2026
7 checks passed
@RamanDamayeu RamanDamayeu deleted the contrib/CTRIB-049-search-url-facets branch July 2, 2026 09:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant