enhancement
major
minor
major
minor
major
#29557
React view commands: the ExecutableState reason of a disabled command never reaches the client, and there is no disabled-if rule beside visible-if
Affects 8.0.0-SNAPSHOT (2026-09-11), tl-layout-view / tl-layout-react.
1. A disabled command shows no reason
ViewCommandModel.getTooltip() (com.top_logic.layout.view/src/main/java/com/top_logic/layout/view/command/ViewCommandModel.java:159-165) returns only the configured tooltip. The reason of a disabled ExecutableState — the one every ViewExecutabilityRule produces, including the engine's own <null-input-disabled/> — is never sent to the client: the disabled toolbar button's DOM carries neither a title nor a tooltip attribute with it.
So the "disabled with a reason" pattern of the classic UI (hover the grey button, read why) does not exist in the React view layer. The application workaround is a static <tooltip> on the command naming the condition, which is one text for every reason the rules might give.
Expected: a disabled command's tooltip is the disabled reason (as in ButtonControl of the classic UI), the configured tooltip while executable.
2. No disabled-if rule
tl-layout-view ships <visible-if expr="…"> (hides a command) and <null-input-disabled/>, but no rule that keeps a command visible and *disables* it with a reason computed by a TL-Script expression — the counterpart of visible-if for "you may not run this yet". The Expenses application carries a small general one (DisabledIf: a ViewExecutabilityRule with an expr called with the command input, returning null while executable or the reason as ResKey / string; src/main/java/com/top_logic/apps/expenses/view/DisabledIf.java in bhu/expenses on Gitea) that could move into the engine as @TagName("disabled-if").
Together with item 1 it gives view authors the standard pair: hide what does not belong to the user, disable with a reason what the user may not do yet.
3. A reason change between two disabled states is swallowed
ViewCommandModel.updateExecutableState assigns the new state and fires only when visibility() differs from the previous state. A rule that first says "nothing selected" and then "the selected ticket is closed" therefore never propagates the second reason, and getExecutableState() hands out the stale one.
Lösung
Tooltip eines deaktivierten Kommandos. ViewCommandModel.getTooltip() liefert, solange das Kommando sichtbar aber nicht ausführbar ist, den aufgelösten Grund (ExecutableState.getI18NReasonKey()) des aktuellen Zustands; solange es ausführbar ist (oder der Zustand keinen Grund trägt), weiterhin den konfigurierten Tooltip. Der Button-Control (ReactButtonControl) schickt den Tooltip bei jeder Zustandsänderung des Modells ohnehin neu an den Client; der Client zeigt den Tooltip auch am deaktivierten Button an, so dass dort keine Änderung nötig war.
Zustandsvergleich. ExecutableState (final, unveränderlich) hat jetzt equals()/hashCode() über Sichtbarkeit und Grund (compareTo bleibt wie bisher nur über die Sichtbarkeit definiert). ViewCommandModel.updateExecutableState feuert, sobald der neue Zustand ungleich dem alten ist — also auch bei einem geänderten Grund zwischen zwei deaktivierten Zuständen.
Neue Regel <disabled-if expr="…"/> (com.top_logic.layout.view.command.DisabledIf, Gegenstück zu <visible-if>): Die TL-Script-Funktion wird mit dem Eingabewert des Kommandos aufgerufen. Liefert sie null oder false, bleibt das Kommando ausführbar. Ein ResKey oder ein String wird zum Grund des deaktivierten Zustands (und damit zum Tooltip). Ein bloßes true deaktiviert mit dem generischen Grund des Engines ("not executable"), so dass die Regel auch als reines Prädikat verwendbar ist. Eine Regel, die über das Eingabeobjekt hinaus andere Objekte liest, braucht deren Typen in den observed-types des Kommandos, damit deren Änderungen die Regel neu auswerten.
<executability>
<null-input-disabled/>
<disabled-if expr="t -> if($t.get(`demo.tickets:Ticket#status`) == `demo.tickets:TicketStatus#closed`, #('Closed tickets cannot be deleted.'@en, 'Geschlossene Tickets können nicht gelöscht werden.'@de), null)"/>
</executability>
Demo. Die Tickets-Ansicht der React-Demo (tl-demo-react) verwendet die Regel am Löschen-Kommando: ohne Auswahl zeigt der deaktivierte Button "Kein Bezugsobjekt gewählt", bei einem geschlossenen Ticket den obigen Grund.