Lists and search
A page whose layout is a list shows its model's records as a table: a page at a time, in the order a header asks for, each row opening its record. A search bar narrows it, groups it, and keeps searches as favourites; the page's buttons act on the records chosen.
{
"fieldia": "0.1",
"id": "customers",
"title": "Customers",
"data": { "kind": "record", "model": "partner" },
"fields": {
"name": { "type": "char", "label": "Name" },
"country_id": { "type": "many2one", "label": "Country", "relation": "country" },
"state": { "type": "selection", "label": "Status", "options": [
{ "value": "active", "label": "Active" }, { "value": "blocked", "label": "Blocked" } ] },
"credit_limit": { "type": "monetary", "label": "Credit limit", "currencyField": "currency_id" },
"currency_id": { "type": "many2one", "label": "Currency", "relation": "currency" }
},
"layout": {
"type": "list",
"id": "customers-list",
"columns": ["name", "country_id", "state", "credit_limit"],
"sort": [{ "field": "name" }],
"pageSize": 40,
"searchFields": ["name", "country_id", "state"],
"filters": [
{ "id": "active", "label": "Active", "filter": [{ "field": "state", "op": "=", "value": "active" }] },
{ "id": "big", "label": "Credit over 100,000", "filter": [{ "field": "credit_limit", "op": ">", "value": 100000 }] }
],
"defaultFilters": ["active"],
"groupBy": ["country_id", "state"],
"actions": [{ "type": "button", "id": "archive", "label": "Archive", "action": "archive", "confirm": "Archive the chosen customers?" }]
}
}
| Key | What it does |
|---|---|
columns | The fields shown, in order. Each value reads as the form shows it: a choice by its label, a link by its record's name, an amount in its currency — a currencyField is fetched though it is not a column. |
sort | The order the list opens in. A header click puts its column first, then the other way; this order breaks the ties. |
pageSize | Records to a page, 1 to 500; 40 when left out. |
searchFields | The fields typed text is looked for in; the columns when left out. |
filters · defaultFilters | Named filters for the menu, and the ones the list starts with. A filter compares with values, never with another field. |
groupBy | The fields the menu offers to group by. |
actions | Buttons for the records chosen. A confirm asks first. |
map | The records on a map too, beside the table or instead of it: see on a map. |
cells | Each column's cells, by its field: a widget (dot, a state's dot in options.colours or tones), a lead icon from another field's value, a width in characters, or in pixels for the whole column ("200px") as a tracker sizes its grid's (words past it end in “…”, a header cut short shows its whole name when pointed at; every column sized, the table keeps them and scrolls sideways in its card when wider), an align, and its header's rule where the theme parts the columns (Fluent's): false for none, as a tracker draws a column people cannot size, or "42px" to set it in from the column's end, before the room a tracker keeps there for a row's tools. See a backlog. |
choose | false: no box to choose a row, and so no buttons for rows chosen. |
sortable | false: a ranked list, such as a backlog, keeps its own order (sort): its headers are words, with no press and no arrow, the order still said by aria-sort. |
search · filterFields · filterLabels | "fields": a keyword box and a list of values to tick for each of filterFields, in place of the search bar; filterLabels gives a filter its own, shorter name on the bar, by field ({ "iteration": "Iteration" } for Iteration Path), translated with the page's words. |
parentField | Rows nested under their parent by this link to the list's own records, with Expand all and Collapse all. |
A list shows records, so its page's data is a record's: the page check refuses a list of responses, and a column, a sort, a search field or a group that names no field.
The records, from your data source
Two more methods of the data source feed a list:
list?(request: { model, fields, filter, sort, offset, limit }): Promise<{ records: { id, values }[]; total: number }>;
groups?(request: { model, field, filter }): Promise<{ value, label, count }[]>;
filter arrives resolved, as a link's search filter does: { any: [...] } is an OR, { all: [...] } an AND. sort is [{ field, desc? }]; for a map's Nearest first, [{ field, near: { lat, lng, longitude? } }]: the records nearest that place first, those with no place last (the field a GeoJSON point, or a latitude whose pair longitude names). A source that cannot sort by distance leaves its order as it is. For groups, a group's value is what records share (a linked record's id), null for the records with none; the list asks for each group's records with that value as one more condition. createMemoryDataSource() does both, empty values last whichever way a list is sorted.
The search bar
- Typing offers the ways to search it: Search Name for: nile in each text field and link, a choice whose label matches (Status: Active), a number where it is one. ↑ ↓ and Enter take one, Escape puts them away.
- Chips show what applies. Values searched in one field share a chip, any of them (Name: nile or amira); chips all apply together. Backspace in the empty box takes the last one away; each has its ×.
- Filters turn on and off from the menu, any of the ones on; Add a custom filter builds one from a field, a condition that suits its kind, and a value.
- Group By groups by one field, then another inside it: a row for each value, with how many records have it, opening into the next field's groups or into its records, a page at a time.
- Favourites keep the search on screen by name, one of them used by default when the list opens. They are kept where the viewer keeps a person's choices: its
preferencesstore, the browser's storage unless you give one.
A backlog: cells, a filter bar, rows under their parent
A list can read as a work tracker's backlog. "search": "fields" puts a filter bar over it: Filter by keyword looks in the search fields, and each field of filterFields has a list of its values to tick — a choice's options, yes and no, the records a link or links hold — its button muted until values are ticked, then saying them. ↓ opens a list, the arrows move, Space ticks, Escape closes; × clears every filter, and the gear chooses which fields the bar offers, kept in the viewer's preferences. A default favourite search starts it with values ticked.
{
"type": "list", "id": "backlog", "columns": ["order", "work_type", "title", "state"],
"choose": false, "sortable": false,
"search": "fields", "filterFields": ["work_type", "state", "area", "iteration", "tags"],
"filterLabels": { "iteration": "Iteration" },
"parentField": "parent_id",
"cells": {
"order": { "align": "start", "width": "59px", "rule": false },
"work_type": { "width": "36px" },
"title": { "width": "911px", "rule": "42px", "lead": { "field": "work_type", "looks": { "bug": { "icon": "bug", "colour": "#cc293d" } } } },
"state": { "width": "68px", "widget": "dot", "options": { "colours": { "new": "#b2b2b2", "active": "#007acc" } } }
}
}
Rows under a parent on the same page sit under it, each depth further in; a parent's toggle, or → and ← on its row (turned round right to left), folds it. A row opened in a dialog comes back: when onOpenRecord answers with a promise, its row is lit (aria-current) while the record is open, the list asks for its records again once it settles, and the row has the focus back. handle.openRecord(id) opens one the same way from the app; for a record named in the address as the page loads, { lit: false } leaves its row unlit, as no row was pressed. handle.reload() asks again, after a new record is made. With the Fluent theme the table reads as the tracker draws it: small muted headers parted by short rules and standing a little in from the words under them, rows 36 pixels tall with no lines between them, a slim scroll bar. Try the backlog.
Opening and acting on records
A row opens its record by a click or by Enter; the app decides what that means. A list's button hands the app the records chosen, as recordIds, in the order they were chosen:
mountViewer(host, {
page: customers,
dataSource,
onOpenRecord: (id) => router.navigate(`/customers/${id}`),
onAction: ({ action, recordIds }) => action === 'archive' && api.archive(recordIds),
});
In React it is the onOpenRecord prop, in Vue the @open-record event, in Angular the (openRecord) output. After a button, the list asks for its records again.
On a map
With map, a list shows its records on a map as well, drawn by @fieldia/map on the provider the app chooses. The map and the table share one search and one choice: a pin lights its row and a row its pin, a home chosen on its card is ticked in the table, and the list's buttons act on it. A pin opens a card with the record's picture, a few of its fields, a box to choose it and a button to open it; pins that crowd become a bubble with their count. Once the map is moved, Search this area puts the area into the search bar as a search of its own; the map's own tools draw one instead — a polygon, a box or a circle, by mouse, finger or keys — searched as In the area drawn.
"map": {
"latitude": "lat",
"longitude": "lng",
"pin": { "label": "price", "money": { "home": "EGP" }, "tones": [{ "tone": "info", "when": "offer == 'rent'" }] },
"card": {
"title": "name", "image": "photo", "href": "link", "labels": false, "lead": true,
"fields": ["price", ["district", { "field": "bedrooms", "unit": { "one": "bedroom", "other": "bedrooms" } }]]
},
"view": "both"
}
| Key | What it does |
|---|---|
point · latitude longitude | Where each record is: a json field holding a GeoJSON Point, or two number fields. A record with neither is left off the map, and the map says how many are. |
pin | Its look — pin, label (its words: a price, a name, minutes away), icon (a car, a scooter, a bike, a shop, Fieldia's own or the app's), photo (a round picture, a courier's face), dot; label the field it says, short as a pin needs (E£9.5M); icon, or iconField for each record's own; image; turn, a number field in degrees, which way it faces; tones, its colour while a condition holds, as a field's are; or widget, the app's own pin by name. An amount's money on the pin, over its field's own (how an amount is written): short unless short is false, home the currency left unsaid — 13.9M, and $ 450K for another — and its field's per, 25K/month on a rent. |
card | Its title (the first column when left out), its image, the lines it shows (fields), and open: a button to open the record unless false. A line is a field, a field with words after its value — { "field": "area_m2", "unit": "m²" }, or { "one": "bedroom", "other": "bedrooms" } for the plural the number needs — or several on one line, parted by a dot: Sheikh Zayed · 4 bedrooms · 280 m². labels: false writes the lines bare, their labels said to a screen reader alone; lead: true makes the first line larger and bold, as a home's price heads its card; href: a text field holding the record's address on the web, so its title and picture are links, opened in a new tab or copied as links are; choose: false leaves the box to choose off a map people only look at, a buyer's. Each of the record's words keeps its own direction: an English name on an Arabic page reads as it was typed. |
cluster | Pins that crowd become a bubble, unless false: then each is drawn on its own, and pins near one another overlap, one under another out of a pointer's reach (the keys still reach each). |
view | How the list opens: list, map or both side by side (the map over the table on a narrow screen). People switch between them over the records. |
limit | The most records the map shows, 500 when left out; past it, the map says how many it shows of how many. |
height | How tall the map is: pixels, or fill, as tall as the element it is put in (in a page of your own: a map in your page). As tall as the window allows, 320 to 640 pixels, when left out. |
gestures | shared when left out: one finger and the wheel scroll the page past the map, two fingers or Ctrl with the wheel move it. all: the map takes every gesture, one finger pans and the wheel zooms, for a page that is mostly its map. |
fitZoom | The closest the map comes when it fits its records in view: 16 when left out; 14 keeps a neighbourhood round a lone record. |
The app gives the list its map, as it gives the viewer widgets:
import { mapListViews, azureMaps } from '@fieldia/map';
mountViewer(host, {
page: homes,
dataSource,
listViews: mapListViews({ provider: azureMaps({ key }), center: { lat: 30.04, lng: 31.24 } }),
});
An app's own pin is a function given by name, pins: { courier: (record, { document }) => element }, and drawn wherever a page says "widget": "courier". homeCurrency: 'EGP' in the map's settings is the currency the app's pins leave unsaid, unless a page says its own. Search this area asks the data source for the records within the map's view: between on a latitude and a longitude, or the within condition on a point, { south, west, north, east } in degrees. An area drawn asks for within with { polygon: [[lng, lat], …] } or { center: [lng, lat], radius } in metres, and on a latitude field longitude names the field of its longitude. createMemoryDataSource() answers them all; a server's adapter turns them into its own query. Once the map is moved, the button says how many records the view holds, Search this area (24), when the records on hand can tell; with countArea: true in the maps' settings the data source is asked for the rest (one record, its total), a moment after the map stops. Each time the map stops it sends fd-map-view from its element, { center, zoom, bounds, byPerson }, for an app that keeps the view in its address. Try the homes · try the couriers.
What it takes care of
- A plain table. Headers that sort say so with
aria-sort; rows take the focus, ↑ ↓ move between them and Space chooses one. It reads the same in every binding and in the script bundle. - Each value in its own direction. On a right-to-left page a phone number or an amount still reads left to right.
- A narrow screen. The table scrolls inside its own box; the menu's groups stack.
- Four languages, as all of Fieldia's own words are.