Fieldia

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?" }]
  }
}
KeyWhat it does
columnsThe 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.
sortThe order the list opens in. A header click puts its column first, then the other way; this order breaks the ties.
pageSizeRecords to a page, 1 to 500; 40 when left out.
searchFieldsThe fields typed text is looked for in; the columns when left out.
filters · defaultFiltersNamed filters for the menu, and the ones the list starts with. A filter compares with values, never with another field.
groupByThe fields the menu offers to group by.
actionsButtons for the records chosen. A confirm asks first.
mapThe records on a map too, beside the table or instead of it: see on a map.
cellsEach 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.
choosefalse: no box to choose a row, and so no buttons for rows chosen.
sortablefalse: 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.
parentFieldRows 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.

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"
}
KeyWhat it does
point · latitude longitudeWhere 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.
pinIts 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.
cardIts 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.
clusterPins 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).
viewHow 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.
limitThe most records the map shows, 500 when left out; past it, the map says how many it shows of how many.
heightHow 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.
gesturesshared 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.
fitZoomThe 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