Skip to main content
Version: Next 🚧

Search Data Flow

This page describes in detail the complete lifecycle of a search — from the moment the user types a query to when the results appear on screen. The main actor is SearchAllComponent (src/app2/pages/search/search-all.ts).

Overview​

URL ↔ Store Synchronization​

The URL and the QueryParamsStore are kept bidirectionally in sync by two Angular effects.

Effect 1 — URL → Store​

Fires whenever a URL query parameter changes (e.g., browser back/forward, direct link, internal navigation):

effect(() => {
const filters = this.f() ? JSON.parse(this.f() ?? "") : [];
this.queryParamsStore.patch({
text: this.q(),
tab: this.t(),
basket: this.b(),
sort: this.s(),
filters,
name: this.n(),
page: this.p(),
id: this.id(),
spellingCorrectionMode: this.c()
});
});

The q, t, b, s, f, n, p, id, c inputs are automatically bound from the URL thanks to withComponentInputBinding() in the router configuration.

Effect 2 — Store → URL​

Fires whenever any value in QueryParamsStore changes, keeping the URL up-to-date:

effect(() => {
const { id, text, filters, page, sort, tab, basket, name, spellingCorrectionMode }
= getState(this.queryParamsStore);

const queryParams = {
f: filters.length > 0 ? JSON.stringify(filters) : undefined,
p: page,
s: sort,
t: tab,
q: text,
b: basket,
n: name,
c: spellingCorrectionMode,
id
};

this.router.navigate([], {
relativeTo: this.route,
queryParamsHandling: "merge",
queryParams
});
});

This makes every search state bookmarkable and shareable via the URL.

URL Parameter Reference​

URL paramStore fieldTypeDescription
qtextstringFull-text search query
ttabstringActive tab name
ffiltersLegacyFilter[] (JSON)Active facet filters
ssortstringSort field name
bbasketstringBasket ID
nnamestringSinequa query name
ppagenumberInitial page number
cspellingCorrectionMode"auto" | "on" | "off"Spelling correction
ididstringRecord ID to auto-open in preview

Effect 3 — Detecting Meaningful Changes​

Not every store update should retrigger an HTTP request. Effect 3 computes a cache key from the fields that actually affect query results:

effect(() => {
const { tab, text, filters, sort, basket, name, page, spellingCorrectionMode }
= getState(this.queryParamsStore);

const r = { tab, text, filters, sort, basket, name, page, spellingCorrectionMode };

untracked(() => {
if (JSON.stringify(this.currentKeys()) !== JSON.stringify(r)) {
this.currentKeys.set(r);
}
});
});

Note that id is intentionally excluded — opening a preview (which writes id to the store) must not trigger a new search.

TanStack Infinite Query​

SearchAllComponent uses an infinite query to support incremental page loading:

query = injectInfiniteQuery<Result>(() => ({
queryKey: [`search-${this.t()}`, this.currentKeys(), this.userOverrideActive()],
queryFn: ({ pageParam }) => fetchServerPage(this.injector, pageParam, {
currentKeys: this.currentKeys(),
basket: this.b(),
id: this.id(),
q: this.queryParamsStore.getQuery(),
tab: this.t(),
spellingCorrectionMode: this.c()
}),
initialPageParam: this.p(),
getPreviousPageParam: firstPage => firstPage.previousPage ?? undefined,
getNextPageParam: lastPage => lastPage.nextPage ?? undefined
}));

Query Key Strategy​

The query key has three parts:

  1. `search-${tab}` — prevents cache collisions between tabs
  2. currentKeys() — triggers a full refetch when any search parameter changes
  3. userOverrideActive() — refetches when an admin overrides the acting user

Changing any part of the key causes TanStack Query to discard all accumulated pages and start from page 1.

Pagination​

PropertyBehavior
initialPageParamThe page number on first load (from the p URL param, defaults to 1)
getNextPageParamReads result.nextPage — the backend sets this to currentPage + 1 if more pages exist
getPreviousPageParamReads result.previousPage — used for reverse pagination if needed
query.hasNextPage()true while getNextPageParam returns a value
query.fetchNextPage()Appends the next page to the pages array

Effect 4 — Propagating Results​

Once the query succeeds, Effect 4 updates the local result signal and populates the AggregationsStore (used by the filters bar):

effect(() => {
this.query.isSuccess();
const result = this.query.data()?.pages[0];
if (!result) return;

this.result.set(result);
this.aggregationsStore.update(result.aggregations);
});

Only the first page is used here because result.rowCount and aggregations are the same across all pages of the same query.

Infinite Scroll​

All loaded pages are flattened into a single computed array:

allRows = computed(() =>
this.query.data()?.pages?.flatMap(page => page.records) ?? []
);

A sentinel <div> at the bottom of the result list listens for intersection:

<div class="h-3.75" infinity-scroll (onScroll)="nextPage()"></div>
nextPage() {
this.query.hasNextPage() && this.query.fetchNextPage();
}

When the sentinel enters the viewport, fetchNextPage() is called. TanStack Query calls fetchServerPage with pageParam = result.nextPage, and the new records are appended to allRows.

Dynamic Article Rendering​

For each record in allRows, the correct card component is resolved at runtime based on the document's format:

getArticleType(docType?: string): Type<unknown> {
return getComponentsForDocumentType(docType).articleComponent;
}

In the template:

@for (article of allRows(); track article.id) {
<ng-container *ngComponentOutlet="getArticleType(article.docformat)" />
}

The document-type-registry (src/registry/document-type-registry.ts) maps format strings to component pairs:

Document typeArticle componentPreview component
default (all others)RecordCardPreviewComponent
pptx, ppt, powerpointSlideCardPreviewComponent

UI States​

The template handles three distinct states:

Loading state​

Skeleton loaders are shown while query.isPending() is true:

@if (query.isPending()) {
<card-skeleton />
}

Results state​

The full UI is rendered when hasRowCount() is true:

  • Navbar tabs (if tab search is active)
  • Filters bar (populated from AggregationsStore)
  • SearchActionsComponent (sort, select all, did-you-mean, sponsored results)
  • Article list with infinite scroll
  • Preview panel or assistant overview (desktop)
  • Sheet previewer modal (mobile/tablet)

No results state​

<no-result> component is shown when the query succeeds but rowCount === 0.

Responsive Layout​

The component defines its own breakpoint at 1024 px (wider than the global mobile breakpoint of 768 px) to trigger the sheet-based preview on tablets as well as phones:

const MOBILE_BREAKPOINT = 1024; // px

isMobile = computed(() =>
this.breakpointService.isMobile() || this.currentInnerWidth() < MOBILE_BREAKPOINT
);
LayoutPreview behavior
Desktop (≥ 1024 px)Right-side panel with slide-in/out animation
Mobile / Tablet (< 1024 px)Sheet dialog (bottom drawer overlay)

Multi-selection​

SearchAllComponent tracks a tri-state checkbox to support bulk operations:

selectedAll = signal<"all" | "some" | "none">("none");

Effect 5 computes this state by comparing loaded record IDs against SelectionStore.multiSelection():

  • "all" — every loaded record is in the selection
  • "some" — at least one, but not all records are selected
  • "none" — no records are selected

selectAll() and unselectAll() iterate over all pages to add/remove records from SelectionStore.

Recent Searches​

Each time a non-empty search is executed (Effect 7), the current query is appended to the user's recent search history:

if (text && text !== "") {
void this.userSettingsStore.addCurrentSearch(query as QueryParams);
}

This powers the "Recent Searches" widget available at #/widgets/recent-searches.