Headless Forms
A form does more than save a record. Its fields run onChange actions, its buttons run
chains, its lines have their own hooks, onSave checks what is about to be saved, and each
response can set values, show a message or ask a question. Saving through a repository skips
all of that.
Headless forms (com.axelor.meta.headless) run a form’s actions on the server, with no
browser, in the context of the record — or of the line — they act on, and end with the record
as those actions left it. The requests are the ones the web client sends, so the form’s own
logic, the server’s checks and the workflows that watch the record all apply.
They are what AI procedures run on (Procedures), and they suit any code that must change a record "as if a user typed it": an import, a scheduled job, a backend test of a form’s logic.
When to use
| You want to… | Use | Runs the form’s logic? |
|---|---|---|
Change fields no form rule touches |
A repository |
No |
Run one action you know by name |
|
That action only |
Do what a user does on a form and its lines: set, add a line, click, save |
Headless forms |
Yes — |
Run a form
The running example is a sale order with lines, created from scratch. Its action-view opens
a form whose items are edited in their grid:
<action-view name="sale.orders" title="Sale orders" model="com.axelor.sale.db.Order">
<view type="grid" name="order-grid"/>
<view type="form" name="order-form"/>
</action-view>
To create an order with one line, as a user would:
-
Inject
HeadlessForms. -
Open the form’s spec on a new record.
-
Run the steps.
-
Read the result.
public class OrderCreator {
@Inject private HeadlessForms forms;
public Map<String, Object> create(long customerId, long productId) {
FormSession order =
forms.open(FormSpec.of("sale.orders"), Target.newRecord()); (1)
FormResult result =
order.run(
List.of(
FormStep.set("customer", Map.of("id", customerId)), (2)
FormStep.add("items"), (3)
FormStep.set("items[-1].product", Map.of("id", productId)), (4)
FormStep.click("items[-1]", "btnApplyDiscount"),
FormStep.click("btnCompute"), (5)
FormStep.save()), (6)
ActionInteraction.CANCEL_ALL); (7)
if (result.status() != FormResult.Status.DONE) {
throw new IllegalStateException(result.message()); (8)
}
return result.record();
}
}
| 1 | The action-view gives the model, the form and the context every action carries. The
form’s onNew runs on the first run, as when the browser opens a new record. |
| 2 | The value is written, then the field’s onChange runs. A reference is {id}. |
| 3 | A new line: its defaults, then its onNew. The first line added is items[-1]. |
| 4 | A step on a line runs in the line’s context: the line is the record, the order is
_parent, and what the response sets lands on the line. |
| 5 | A step on the order leaves the line first: items’ own `onChange runs, in the order’s
context, then the button’s onClick. |
| 6 | onSave, then the order and its lines saved in one request, read back, then onLoad. |
| 7 | How to answer a question the form asks — here, No to everything. See Answer the form’s questions. |
| 8 | A field error, an error message or a refused step ends the run early; the result says
which. |
The run acts as the current user (AuthUtils.getUser()), with that user’s permissions —
the same checks as the web client’s requests.
|
The record and its lines
A session holds the record as the client holds it — JSON-shaped values, the lines inside their collections — and names each node by a path:
| Path | Node |
|---|---|
(empty) |
The record. |
|
The line of |
|
The first line added in the session, |
|
The first line of |
|
A line of a line, to any depth. |
A field of a node is written after its path: items[-1].qty. A custom field is attrs.name
or its bare name; a field of a referenced record is customer.name.
Steps
| Step | What runs |
|---|---|
|
Each value is written into its node, then that field’s |
|
A new line: the line model’s defaults, its |
|
The line out of its collection; the collection’s |
|
The button’s |
|
A chain the form declares for the node — a hook by name ( |
|
The line into its collection: the collection’s |
|
What the Save button does: the open lines committed, |
A chain runs as in the browser: an action-group’s items in order, `save in a chain saving
the record, close ending the run, error stopping it. A line’s save, validate and
reload act on the record, as the line editor hands them to its form.
Before anything runs, the whole list is checked against the form (Check steps ahead of a run); the first
problem refuses the run with status REFUSED. A position is resolved when its step runs.
Lines: when a line is left
A line is open from the first step that touches it until the session leaves it. Leaving a
line writes it into its collection and runs the collection’s onChange once, in the parent’s
context — what the browser does when the user leaves an edited row, or presses OK on a line’s
form. The session leaves an open line:
-
before a step on another node — the record, or another line;
-
before a save;
-
on
commit(line), for the collection’sonChangeat a precise moment.
A line nothing changed is left without running it. A new line counts as changed.
A saved line that opens in its own form (below) is opened as that form’s editor opens it: read
with the form’s fields, then its onLoad in the line’s context. A line edited in its grid has
no form, so it is neither read nor loaded.
Which definition holds a line’s hooks is the client’s rule:
| The collection | A line’s fields, buttons and hooks |
|---|---|
Edited in its grid ( |
The field’s own columns, else the grid view’s; |
Rendered by an |
As a grid edited in place: the editor’s items, and the editor’s |
Otherwise |
The field’s |
A tree-grid |
As above; the lines of its |
A many-to-many |
References only: set the list; there are no lines to add or edit. |
A one-to-many’s line has no onSave of its own: it is saved with its record.
What a session opens on
| Target | Opens |
|---|---|
|
A new record: field defaults, the form’s |
|
A saved record, read with the form’s fields, then |
|
A saved record, |
A seed is written without its fields' onChange. What onNew sets leaves a new record
unchanged; what onLoad sets changes a saved one, as in the browser.
A spec is built from an action-view, FormSpec.of("sale.orders"), from a form of a model,
FormSpec.of(Order.class, "order-form") — without an action-view’s context — or from a model
alone, FormSpec.of(Order.class): its fields with no hooks and no buttons, for code that wants
the server’s save rules and nothing else.
Answer the form’s questions
Where the browser would put a dialog in front of the user — an action-validate alert, a
button’s prompt — the session asks your ActionInteraction instead:
ActionInteraction answer =
alert -> "action-order-check".equals(alert.source()) ? Decision.CONTINUE : Decision.CANCEL;
The ActionAlert it receives says what the user would have read: displayed (translated,
templates filled), message (as written in the action), source (the action that raised it,
or the button for a prompt), and the dialog’s title, confirmTitle and cancelTitle.
| Decision | Effect |
|---|---|
|
Yes: the chain carries on. |
|
No: the alert’s own action runs if it has one, then the run ends with status |
|
The run stops here with status |
ActionInteraction.CANCEL_ALL answers No to everything, CONTINUE_ALL Yes. A request to
re-authenticate (onIdentityCheck) is answered No unless you override it: nobody can do that
for the user.
Stop at a question and carry on later
When the answer must come from a person who is not there yet — a user in a chat, an approver — suspend, keep the state, and resume when the answer arrives:
FormResult result = order.run(steps, alert -> Decision.SUSPEND);
if (result.status() == FormResult.Status.SUSPENDED) {
ActionAlert question = result.question(); (1)
FormState state = order.state(); (2)
store(state.toMap());
}
// later, perhaps in another request or on another node
FormSession restored = forms.restore(FormState.fromMap(load()));
FormResult resumed = restored.resume(Decision.CONTINUE, ActionInteraction.CANCEL_ALL); (3)
| 1 | What to ask the person. |
| 2 | FormState holds the record and its open lines, the step and the place inside it, the
rest of the chain. toMap() gives it as JSON values for any JSON mapper to write, and
FormState.fromMap takes back what the mapper reads. |
| 3 | The answer to the open question; later questions go to the interaction. |
The state keeps the record and its lines with the versions they were read with. If someone
changes the record meanwhile, or edits, deletes or adds one of its lines, a save after the
resume fails on Axelor’s optimistic lock, as the browser’s would, and the run ends with status
ERROR.
A question inside a line’s onChange, or inside a collection’s onChange when a line is left,
suspends and resumes the same way.
The result
FormResult is what one run or resume came to:
| Field | Meaning |
|---|---|
|
|
|
The error or refusal, when there is one. |
|
The record and its lines as the session holds them: after a save, as read back; without one, as the actions left them — enough for a preview. |
|
Whether the record was saved during the session. |
|
The display attributes actions set ( |
|
|
|
What the browser would have acted on outside the form — views to open, files to download,
signals such as |
|
The records the form’s actions saved themselves — an invoice made when an order is
confirmed, the record itself saved by an |
|
The open question, when |
FormSession.record() gives the record at any moment; stored() gives it as the database last
gave it — as loaded, or as read back after the last save; afterSave() as the last save left
it, with what its onLoad set, so what record() holds beyond that was changed since and is not
saved. context(node) gives the context a chain run at a node would carry, for evaluating
an expression as the form’s own actions would see it.
Transactions
The caller chooses:
-
Outside a transaction, each request runs as the browser’s would. A read or a save runs in a transaction of its own, and an action chain in none, as
/ws/actiondoes: each action keeps its own — anaction-recordthat saves, atransactionalaction-script, a@Transactionalservice. What an action commits stays, even when a later action or the save fails;writestells it. What a chain leaves unflushed outside any transaction is dropped with it, as a browser’s request drops it. A save that fails, committing included, ends the run withERRORand keeps nothing it wrote. -
Inside one, every request joins it and the whole session is atomic: roll back on anything but
DONE, as an import that must apply all or nothing does. Nothing commits before the caller does, sowritesstays empty.
Check steps ahead of a run
To validate steps written ahead of time — a stored procedure, an import mapping — without running anything, ask the spec the session would run:
FormSpec spec = FormSpec.of("sale.orders");
spec.check(FormStep.set("items[0].qty", 2)); // null when a run would take the step, else why not
spec.line("items").buttons(); // what a line offers
spec.limits(); // what the browser would do that a run does not
check refuses a path, field, button or chain the form does not have, an add on a
collection with no lines (a many-to-many), a set of a one-to-many (add and remove its lines
instead), and a value a widget-bound field cannot hold. The action-view is read as defined:
a form chosen by an expression is not followed.
What a session does not do
A session runs the server’s side of a form. The browser’s side — widgets, what they show and
what they let a user do — is not run, and FormSpec.limits() lists, from the definitions,
where that matters:
| Kind | What is not run |
|---|---|
|
|
|
|
|
An |
|
An |
|
A widget whose behavior is more than holding a value: an image, a drawing, an HTML editor. |
A session runs past the first four — the server accepts what it accepts — and returns what
actions set in FormResult.attrs, so a caller can see that the form would have locked a field.
Whether that is acceptable is the caller’s decision: the procedure validator, for one, refuses
a step on a limited field or button unless the procedure has a when that says when a user
could do it. A WIDGET field refuses a value it cannot hold.
Also:
-
A save checks required fields as the browser does. Before
onSave, and on a chainedsaveorvalidate, a field that is shown and required — by the view, the model or anaction-attrs— must hold a value, or the run ends withERRORand the browser’s "{0} is required" infieldErrors. A field whose visibility or requirement depends on a client expression (showIf,hideIf,requiredIf, on it or on a panel holding it) is not checked, nor is a custom field; the server’s own validation stands for them. -
Personalized views are ignored. A user’s own copy of a view is the browser’s; a session uses the view as every user of the group gets it, with view processors applied.
-
Navigation is returned, not followed. An
action-viewin a chain, a download or a report comes back in the result. -
A run makes at most 500 action requests. A chain that sends the run back to itself, as a group that saves then runs itself again does, ends with
ERRORrather than running until the thread’s stack runs out. -
Browser-only responses are refused.
new, a signal other thanrefresh-appandrefresh-tab, orcanCloseon a changed record ends the run withREFUSEDrather than being skipped.