@sinequa/atomic

Autocomplete

Search-as-you-type suggestions, grouped by category, with the debouncing and cancellation that keep them in order.

Suggestions come from an autocomplete web service the administrator configured: which sources feed it, how many characters trigger it, whether results are grouped. Your side is a debounced call and a list.

Concept — a suggest query

The autocomplete service draws on named suggest queries — a dictionary of terms, a list of people, past searches. Each suggestion comes back tagged with the category it came from, which is what lets you group them under headings.

Two calls, two purposes

Prop

Type

A Suggestion carries display (what to show), category (what to group by), and optionally frequency, id and normalized.

A search box with suggestions

The whole difficulty is that the user types faster than the server answers. Debounce the input, and ignore any reply that is no longer current — otherwise suggestions for a prefix overwrite those for the full word.

Debounced, cancelled, grouped by category
src/autocomplete.ts
import { createAtomicClient, sysLang } from "@sinequa/atomic";
import type { Suggestion } from "@sinequa/atomic";

const client = createAtomicClient({
  app: "<your-app>",
  backendUrl: "https://<your-sinequa-server>",
});

await client.initializeConfig();
await client.auth.login();

// The autocomplete service to use is configuration, not a constant to invent.
const app = await client.api.app.get();
const suggestService = app.autocomplete;

let timer: ReturnType<typeof setTimeout> | undefined;
let generation = 0;

export function onInput(text: string, render: (groups: Map<string, Suggestion[]>) => void) {
  clearTimeout(timer);

  // Below a couple of characters the server matches nearly everything.
  if (text.length < 2) {
    render(new Map());
    return;
  }

  timer = setTimeout(async () => {
    // Each request claims a generation; a late reply from an older one is dropped.
    const mine = ++generation;
    const suggestions = await client.api.suggest.get(suggestService, text);
    if (mine !== generation) return;

    render(groupByCategory(suggestions));
  }, 150);
}

function groupByCategory(suggestions: Suggestion[]) {
  const groups = new Map<string, Suggestion[]>();

  for (const suggestion of suggestions) {
    // The category is a raw, possibly multilingual label.
    const heading = sysLang(suggestion.category ?? "", navigator.language) || "Other";
    const list = groups.get(heading) ?? [];
    list.push(suggestion);
    groups.set(heading, list);
  }

  return groups;
}

Completing a filter value instead

When the user is filling a facet rather than the search box, suggest.field returns matching values of columns:

completing an author name
const authors = await client.api.suggest.field("smi", ["authors"]);
// → suggestions whose display is an actual value of the authors column

query.filters = filter.eq("authors", authors[0].display);

Passing the current query scopes the suggestions to the result set in view, so you offer values that will actually match something.

What the configuration decides

CCAutocomplete — reachable as app.webServices[app.autocomplete] — carries the behaviour you should respect rather than reinvent: whether autocomplete is enabled, which suggestQueries feed it, the inputLengthTrigger below which nothing should be requested, and groupSuggestionsByCategory.

Reading inputLengthTrigger instead of hardcoding two characters is the difference between an interface that follows the deployment and one that fights it.

Pitfalls

What's next

On this page