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 save

No

Run one action you know by name

ActionExecutor, or a call from another action

That action only

Do what a user does on a form and its lines: set, add a line, click, save

Headless forms

Yes — onNew or onLoad, onChange, onClick, the lines' hooks, onSave

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:

  1. Inject HeadlessForms.

  2. Open the form’s spec on a new record.

  3. Run the steps.

  4. 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.

items[#42]

The line of items with id 42.

items[-1]

The first line added in the session, items[-2] the second: new lines get negative ids, as in the browser. The path keeps naming the line after a save gives it an id.

items[0]

The first line of items as it stands when the step runs.

items[-1].taxes[#7]

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

FormStep.set(values), set(key, value)

Each value is written into its node, then that field’s onChange runs in the node’s context. A many-to-many takes a list of references.

FormStep.add(collection), add(collection, seed)

A new line: the line model’s defaults, its onNew in the line’s context, then the seed, without its onChange. add("items[-1].taxes") adds a line to a line.

FormStep.remove(line)

The line out of its collection; the collection’s onChange runs at once.

FormStep.click(button), click(node, button)

The button’s prompt, if any (a question), then its onClick chain, in the node’s context. The button’s name comes as _signal.

FormStep.run(node, chain)

A chain the form declares for the node — a hook by name (onLoad), or a chain as a field or button writes it — and nothing else. For a hook no step triggers, such as onLoad again after a change.

FormStep.commit(line)

The line into its collection: the collection’s onChange. Rarely needed; see Lines: when a line is left.

FormStep.save()

What the Save button does: the open lines committed, onSave, the record saved with its lines, read back, onLoad.

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’s onChange at 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 (editable on the field, else on its grid view)

The field’s own columns, else the grid view’s; onNew the field’s, else the grid view’s.

Rendered by an <editor>

As a grid edited in place: the editor’s items, and the editor’s onNew.

Otherwise

The field’s form-view — a tree-grid’s summary-view — else the model’s default form, with that form’s onNew and onLoad.

A tree-grid

As above; the lines of its x-tree-field are rows of the same kind, to any depth.

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

Target.newRecord(), newRecord(seed)

A new record: field defaults, the form’s onNew, then seed.

Target.record(id)

A saved record, read with the form’s fields, then onLoad.

Target.record(id, seed)

A saved record, onLoad, then seed: edits not saved yet, carried in from elsewhere.

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

CONTINUE

Yes: the chain carries on.

CANCEL

No: the alert’s own action runs if it has one, then the run ends with status CANCELLED.

SUSPEND

The run stops here with status SUSPENDED, and can carry on later. See Stop at a question and carry on later.

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

status

DONE (every step ran), ERROR, CANCELLED, CLOSED, REFUSED or SUSPENDED.

message

The error or refusal, when there is one.

record

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.

saved

Whether the record was saved during the session.

attrs

The display attributes actions set (readonly, hidden, required, title…), by node path ("" for the record), then by widget. Recorded, not enforced: see What a session does not do.

messages, fieldErrors

info, notify and error messages in order; field errors by field.

views, files, hints

What the browser would have acted on outside the form — views to open, files to download, signals such as refresh-app — returned rather than followed.

writes

The records the form’s actions saved themselves — an invoice made when an order is confirmed, the record itself saved by an onChange — once their transaction committed: model, id, and whether each was created, updated or deleted. They stay whatever the run did next, so a run that stops still tells them. What a user sees counts — a custom-model record, a file, a comment or an email; the framework’s bookkeeping (metadata, audit rows, followers, tracking notifications) does not.

question

The open question, when SUSPENDED.

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/action does: each action keeps its own — an action-record that saves, a transactional action-script, a @Transactional service. What an action commits stays, even when a later action or the save fails; writes tells 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 with ERROR and 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, so writes stays 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

CLIENT_EXPRESSION

showIf, hideIf, readonlyIf, requiredIf, validIf, x-bind on a field or button.

STATIC_ATTRIBUTE

readonly, hidden, required in the view.

ATTRS

An action-attrs in one of the form’s chains that sets readonly, hidden or required.

SELECT

An onSelect: it sets a pick list’s domain in the browser; a value is not checked against it.

WIDGET

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 chained save or validate, a field that is shown and required — by the view, the model or an action-attrs — must hold a value, or the run ends with ERROR and the browser’s "{0} is required" in fieldErrors. 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-view in 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 ERROR rather than running until the thread’s stack runs out.

  • Browser-only responses are refused. new, a signal other than refresh-app and refresh-tab, or canClose on a changed record ends the run with REFUSED rather than being skipped.