enhancement
major
minor
major
minor
Problem
A <generic-command> (GenericViewCommand, com.top_logic.layout.view.command) is a list of ViewAction`s run by `ViewActionChain.run: each action receives the result of the previous one (ViewAction.execute(context, input), or the asynchronous form with a Continuation) and hands its own result on. <execute-script>, <with-transaction>, <write-channel>, <open-dialog>, <close-dialog>, <navigate-pop> (#29530) and <show-object> (#29532) all compose this way.
The only control flow is linear. A chain can end early - <confirm> aborts it when the user cancels, <throw-error> fails it - but it cannot do one of two things depending on the value it carries. Cases from the issue tracker built on the view layer:
- A search input: if a ticket with the entered key exists, show it; otherwise open the ticket list with the term as its search.
- A workflow command: if the ticket is still new, accept it and assign it to the current user; otherwise only assign it.
- A drop or create handler: if the target already contains the object, do nothing; otherwise move it and select it.
The workarounds: two commands with complementary <visible-if> rules (which only works when the condition depends on the input channel, not on a value computed inside the chain), or a single <execute-script> that performs both alternatives itself and returns nothing - which gives up the composition with the declarative actions, since a script cannot open a dialog, show an object or leave a frame.
Solution
A branching action whose test is a TL-Script function over the value the chain carries:
{{{#!xml <generic-command>
<execute-script function="key -> all(my.module:Ticket).filter(t -> $t.get(my.module:Ticket#name) == $key).firstElement()"/>
<if test="ticket -> $ticket != null">
<then>
<show-object/>
</then>
<else>
<write-channel name="searchTerm"/>
</else>
</if>
</generic-command> }}}
- <if test="…"> (IfAction) evaluates the function on the current chain value (with optional inputs channels as further arguments, as <execute-script> and <confirm> take them; the result is a condition in TL-Script's fuzzy sense) and runs the actions of <then> or <else> as a nested chain. The nested chain starts with the current value; its result becomes the value of the enclosing chain. A missing branch passes the value through unchanged, so <if> with only <then> is a guard.
- <switch value="…"> (SwitchAction) for a multi-way decision. The optional value function computes the value the cases decide on; without it, the chain's current value is that value. Each <case> configures exactly one of two conditions: match="…" is a TL-Script expression without parameters whose result is compared to the switch value with the equality of TL-Script (==), so a classifier is written as ` `module:Enum#literal `, a text as `'text' and a number as the number; test="…" is a predicate called with the switch value, read in the fuzzy sense of TL-Script. The cases are compared in order, the first matching one runs, otherwise the <default>.
{{{#!xml <switch value="t -> $t.get(demo.tickets:Ticket#status)">
<case match="demo.tickets:TicketStatus#closed">
<with-transaction>
<execute-script function="t -> $t.set(demo.tickets:Ticket#status, demo.tickets:TicketStatus#open)"/>
</with-transaction>
</case>
<case test="s -> $s == null">
<confirm expr="x -> #('The ticket has no status.'@en)"/>
</case>
<default>
<with-transaction>
<execute-script function="t -> $t.set(demo.tickets:Ticket#status, demo.tickets:TicketStatus#closed)"/>
</with-transaction>
</default>
</switch> }}}
- "Exactly one of match and test" is declared as configuration constraints on the two properties (@Constraint with OnlySetIfUnset and the new general MandatoryIfUnset in com.top_logic.basic.config.constraint.impl), so the configuration form editor reports a violation at the field, and ViewLoader.parseConfig runs the ConstraintChecker over every loaded view, so a violated constraint fails the view load with the location of the offending element. ConstraintChecker logs the translated failure message (naming property and location) instead of a constant text.
- Nested chains run through ViewActionChain.nest with the Continuation protocol, so asynchronous actions - <confirm>, <open-dialog>, a dirty-form veto on <write-channel> - work inside a branch exactly as at the top level; an abort inside a branch aborts the whole command, and compensations registered inside a branch keep their place in the command's unwind. ViewActionChain.run hands the chain's final value to its completion callback.
- appliesFormState() of a branching action is the disjunction over its branches, so a form's state is applied before the chain when any branch needs it.
- ActionScript is the one place all scripted actions (<execute-script>, <confirm>, <if>, <switch>) compile their function and read their inputs channels; ViewActions instantiates action lists and answers their appliesFormState.
Also fixed on the way
The save and cancel chain of a <form> (FormElement) ran its actions through a hand-rolled loop calling the synchronous ViewAction.execute, which failed for every interruptible action; it now runs through ViewActionChain, so <confirm>, <if> and <switch> work in a form's action chains too.
Tests, documentation and demo
TestBranchActions (tl-layout-view): synchronous and asynchronous branches, pass-through, abort propagation and compensation order, case matching by TL-Script equality and by predicate, rejection of a view whose case has neither or both conditions, appliesFormState. TestConstraintChecker (tl-basic): MandatoryIfUnset alone and combined with OnlySetIfUnset. docs/faq/react-view-layer.md documents the branching actions beside the linear chain. com.top_logic.demo.react, view "Object list": a toolbar command "Toggle" switches on the ticket status and closes an open ticket or reopens a closed one.