Lookups and live search

A lookup fetches information from another system while the form is being filled in: the company behind a CVR number, the properties at a postcode, the valuation of a property, an address.

The form only names a lookup and says what to send. The portal's server decides where the data comes from. The browser never sees a URL or an API key, and the server can use the signed-in user.

  1. The browser asks your server to run lookup company with { cvr: "12345678" }.
  2. Your server runs the connector registered as company, which calls the CVR register with the server's own credentials.
  3. The answer ({ name: "Eksempel ApS", … }) goes back to the form.

In the form

dataSources:
  company:
    params: { cvr: applicant.cvr }
    when: 'MATCHES(applicant.cvr, "^[0-9]{8}$")'

A lookup runs when all its parameters are filled in and when (if given) is TRUE. It waits until people stop typing (300 ms), and replies that arrive after something has changed are ignored.

Use the result:

  • in formulas: @company.name, @valuation.value * 0.8
  • in text: {{ ds.company.name }}
  • as options: choices: "@properties" with choiceValue and choiceLabel

On the server: connectors

Register a connector for each lookup name. It gets the parameters and the caller.

builder.Services.AddQuillflow()
    .AddConnector("company", async (p, ctx, ct) =>
        await cvr.LookupAsync((string)p["cvr"]!, ctx.User, ct));
createHandler({
  connectors: {
    company: async ({ cvr }, { principal, signal }) => cvrRegister.lookup(String(cvr), principal, { signal }),
  },
  // …
});

Connectors can also be classes (IDataSourceConnector in .NET) with their own dependencies: an HttpClient with credentials, a cache, a circuit breaker.

What the server enforces for you:

  • The browser can only call lookups that the form declares, with exactly the declared parameters, and only plain values.
  • Lookup responses are sent with Cache-Control: no-store, because they may contain personal data.
  • When a connector fails, the browser gets a 502 without internal details. The error is logged on the server.
  • On submit, the lookups run again on the server, so rules and calculations use the server's own data.

Live search (for example addresses)

A search question suggests values while people type, and stores the one they choose.

dataSources:
  address_search:      # gives suggestions; gets what people type as `query`
    params: {}
    query: yes
  address:             # finds one address by its id
    params: { id: new_address }

fields:
  - name: new_address
    type: search
    choices: "@address_search"
    choiceValue: id
    choiceLabel: text
    verify: address
    label: { da: Ny adresse, en: New address }
  • The search lookup (query: yes) receives the typed text as query, together with its own parameters. Only the search box calls it, from the second character typed, 250 ms after typing stops.
  • verify names a lookup that must find the chosen value. The server runs it again on submit, so only real addresses are accepted, even if someone sends data without using the form. The verify lookup is also useful in itself: {{ ds.address.municipality }}.

The search box follows the WAI-ARIA combobox pattern. Arrow keys move through the suggestions, Enter chooses, Escape closes, and a screen reader hears how many suggestions there are. Changing the text after choosing clears the choice, so a half-edited address is never submitted.

An address connector might look like this. Adapt it to the address service you use, such as Danmarks Adresseregister (DAR) through Dataforsyningen or Datafordeleren:

.AddConnector("address_search", async (p, _, ct) =>
{
    var hits = await addresses.SearchAsync((string)p["query"]!, limit: 8, ct);
    return hits.Select(a => new Dictionary<string, object?> { ["id"] = a.Id, ["text"] = a.Text }).ToList();
})
.AddConnector("address", async (p, _, ct) => await addresses.FindAsync((string)p["id"]!, ct))

See examples/change-of-address for a complete form with a pretend register.