Around a record
A record sheet sits in more than its card. Over it, Flectra's control panel: the breadcrumbs back to the list, the gear menu, and the pager — “3 / 42”. Beside it, the record's main attachment and its conversation. The page says what the menu holds; your app gives the records round this one and the trail to it.
The Vendor bill demo, in each framework, shows all of it.
The sheet's toolbar
{
"type": "sheet", "id": "sheet",
"toolbar": {
"menu": [
{ "id": "m-print", "label": "Invoice", "group": "print", "action": "print_invoice" },
{ "id": "m-debit", "label": "Debit Note", "action": "action_debit_note", "invisible": "state != 'posted'" },
{ "id": "m-archive", "builtin": "archive" },
{ "id": "m-unarchive", "builtin": "unarchive" },
{ "id": "m-duplicate", "builtin": "duplicate" },
{ "id": "m-delete", "builtin": "delete", "roles": ["account.group_account_manager"] }
],
"pager": true,
"breadcrumbs": true
},
"children": []
}
Each item of menu is pressed like a button: its steps, then its action, asking its confirm first; it shows by invisible and roles. "group": "print" puts it under Print, as Flectra's reports. A builtin item needs no words or steps of its own:
| Built-in | What it does |
|---|---|
archive · unarchive | Asks first, then archives through your data source's archive, or your app's action archive; the record is loaded again. Archive shows while the record's active is not false, Unarchive while it is. |
duplicate | Saves what changed, copies the record through copy — or your action duplicate, answering with the copy's record — and shows the copy. |
delete | Asks first, then deletes through delete, or your action delete. The pager moves to the next record; with none left, the breadcrumbs go back; else the form starts a new one. |
Give a built-in steps of its own and they run instead, without its question: Flectra's employee form, whose Archive opens the departure wizard, is { "id": "m-archive", "builtin": "archive", "steps": [{ "do": "open", "page": "hr-departure-wizard", "record": "id" }] }. The same steps — archive, unarchive, duplicate, delete — run from any button or moment, as Actions says.
A work item's head
A sheet's head draws the record as a work tracker draws a bug: its kind's strip of colour along the start edge and a badge — the kind's icon, its name in capitals, the record's number — over the title; the number before the title, with Copy title at its end while it is pointed at or focused; a row of people and counts under it, the record's buttons at its end; then a band of fields, their labels beside them, “Updated by” and the tabs at its end. The classic header bar, statusbar, badges and stat buttons give way to it; the gear menu is its ⋮.
{
"type": "sheet", "id": "bug",
"title": { "field": "title", "before": [{ "type": "field", "id": "t-number", "field": "number" }] },
"head": {
"kind": { "field": "work_type", "numberField": "number",
"looks": { "bug": { "colour": "#cc293d", "icon": "bug" }, "task": { "colour": "#f2cb1d", "icon": "task" } } },
"meta": [
{ "type": "field", "id": "h-assigned", "field": "assigned_to", "placeholder": "No one selected" },
{ "type": "link", "id": "h-comments", "icon": "chat", "field": "comment_count",
"words": { "one": "Comment", "other": "Comments" }, "target": "g-discussion" }
],
"actions": [
{ "type": "button", "id": "b-save-close", "label": "Save and Close", "icon": "save", "primaryWhenChanged": true,
"steps": [{ "do": "save" }, { "do": "close" }], "menu": [{ "id": "m-save", "label": "Save", "steps": [{ "do": "save" }] }] },
{ "type": "button", "id": "b-follow", "label": "Follow", "pressedLabel": "Following", "pressed": "following",
"icon": "eye", "action": "follow", "menuIcon": "gear", "menuLabel": "Notification settings",
"menu": [{ "id": "m-all", "label": "All changes", "action": "follow-all" }] },
{ "type": "button", "id": "b-undo", "label": "Revert changes", "icon": "undo", "iconOnly": true, "steps": [{ "do": "reset" }] }
],
"band": { "tracks": ["274px", "1fr"], "labelWidth": 76, "children": [
{ "type": "field", "id": "f-state", "field": "state", "widget": "dot", "hotkey": "e" },
{ "type": "field", "id": "f-reason", "field": "reason", "readonly": true, "readonlyReason": "Set by the workflow" }
] },
"updated": { "byField": "changed_by", "atField": "changed_date", "opens": "t-history" }
},
"children": [{ "type": "tabs", "id": "tabs", "place": "head", "children": [
{ "type": "tab", "id": "t-details", "label": "Details", "children": [] },
{ "type": "tab", "id": "t-history", "label": "History", "icon": "history", "iconOnly": true, "children": [] },
{ "type": "tab", "id": "t-links", "label": "Links", "icon": "link", "iconOnly": true, "count": "related", "children": [] }
] }]
}
- A button takes
iconOnly(its label its name and tooltip),pressedwithpressedLabelfor a toggle,menufor a split button — or a menu button with no steps of its own — andprimaryWhenChanged: quiet but usable with nothing changed, when Save and Close simply closes. Undo with nothing to put back says “Nothing has changed.” - Your app answers Follow with
{ stored: { following: true } }: values the server keeps, written as loaded ones are, never a change to save —form.setValues(values, { stored: true })does the same. - A field's
hotkeyunderlines that letter of its label; Alt with it takes the focus there. The page's"lockAt": "value"puts a locked field's lock at its value's start. "place": "head"puts the sheet's tabs at the band's end, their panels as the body; a tab'scountshows in a pill and in its name, “Links, 0”.- In
openFormDialog({ size: 'inset', backdrop: 'clear', maximise: true, resizable: true })the head is the dialog's own: no title bar or foot, its ⤢ and × at the badge row's end, Escape and × asking before changes go.
The pager and the breadcrumbs: your app's
const viewer = mountViewer(host, {
page, dataSource, recordId: 7,
// The list it was opened from: their ids in order…
records: [3, 7, 12, 19],
// …or how many there are, where this one is, and the id at a place.
// records: { total: 42, at: 2, id: (at) => api.idAt(at) },
breadcrumbs: [{ label: 'Vendor bills', open: () => router.back() }],
});
viewer.on('open', ({ recordId }) => router.replace(`/bills/${recordId}`));
The pager says where the record is, “2 / 4”, and goes round from the last to the first. Moving saves what changed first, as Flectra does; a refused save keeps the record. Alt+P and Alt+N press its buttons. A copy joins the records after the one it was made from; a deleted record leaves them. The form shows the other record in place — form.openRecord(id) — and its open event says which: in React onRecord, in Vue @record, in Angular (record), beside onArchive, onDuplicate and onDelete.
The breadcrumbs are a nav: each page before the record a link — href or open — and the record last, by its title field, with aria-current="page". A page a step opens in the record's place adds the record to the trail, as the way back. "pager": false or "breadcrumbs": false keeps them away even when your app gives them.
In a dialog there is no pager and no trail: they belong to the record under it. The gear menu still shows. On a phone the gear keeps only its picture and the trail folds to the page before this one, an arrow back.
The attachment beside the sheet
{ "type": "sheet", "id": "sheet", "attachmentPreview": {}, "children": [] }
Flectra's o_attachment_preview: the record's main attachment, a PDF in the browser's own viewer or a picture, beside the sheet on a wide form and under it on a narrow one, with Previous and Next through several and Open. { "field": "invoice_pdf" } shows a file field's file; left out, the record's attachments come from your data source's attachments — asked again when the form shows another record, and after a save. It hides while there is nothing to show. With the conversation's own files:
import { chatterAttachments } from '@fieldia/chatter';
mountViewer(host, { page, dataSource: { ...dataSource, attachments: chatterAttachments(source) } });
The chatter beside it
As Flectra's chatter, the side panel sits beside the sheet on a form 1534px wide or more, the page growing by its width so the sheet keeps its own; on a narrower one it goes under the sheet, which takes the whole width. "sidePanelBeside": "always" keeps it beside, narrower, down to 600px, the sheet's columns stacking inside it as they need. A grid of many columns beside it can "fit": "shrink": its columns go narrower, their headers wrapping, before it scrolls.
A post step puts words in the record's conversation — { "do": "post", "message": "Lost: {lost_reason}" }, a note unless "kind": "message" — and so does your app's answer, { post: "Debit note DN/0001 made" }. In a page opened over the record, such as the Mark as Lost wizard, it posts in the record's conversation. The chatter fetches again after a save and after the record is loaded again, so what your server posts — tracked changes, a server action's note — shows.
Flectra's own form controllers
Flectra pages with a js_class (account_move_form, crm_form, hr_employee_form…) carry JavaScript of their own. Fieldia does not copy them; what they did is data here, or your app's:
- An Archive that opens a wizard (hr_employee_form): a built-in item with its own steps.
- A probability that follows the stage, a won stage (crm_form): your data source's
onchangeand your server's. - Quick edit of an invoice (account_move_form): the form is editable as it is; a lock by state is
readonlyon the sheet's parts. - Anything else at a moment: the page's
onsteps, and acallof your app's action, whose answer can set values, post, reload, open a page or show another record.