major
#29734
View form: <store-form-state/> does not persist new rows of a <composition-table>, so saving a persistent owner fails
Found on 2026-10-04 against tl-layout-view 8.0.0-alpha8 (the code is the same in alpha9), in the BOS/Consulting application (issue #384, the workflow engine's step dialog).
Observed
A dialog form over a **persistent** object with a <composition-table attribute="..."> and the usual save command
<generic-command name="save" placement="BUTTON_BAR"> <with-transaction><store-form-state/></with-transaction> <close-dialog/> </generic-command>
"Hinzufügen" adds a row, the row is filled, and the save fails:
Problem beim Setzen des Attributwertes 'tl.ai.flow:Step#branches' auf '[Ja]'. Caused by: java.lang.UnsupportedOperationException at ...LinkStorageUtil.createWrapperAssociation(LinkStorageUtil.java:99) at ...ListStorage.append(ListStorage.java:244) ... at com.top_logic.layout.view.form.TLObjectOverlay.apply(TLObjectOverlay.java:207) at com.top_logic.layout.view.form.FormControl.executeStoreState(FormControl.java:534) at com.top_logic.layout.view.command.StoreFormStateAction.execute(StoreFormStateAction.java:71)
The same table in a form with the form's own Edit/Save works.
Why
- AbstractCompositionControl.addRow creates a TransientObject and publishes the row list, transient row included, to the form overlay (AttributeRowSetBinding.updateMembership).
- The form's own save goes through FormControl.persistChanges, which calls FormParticipant.persist(tx) first: the composition control turns its transient rows into persistent objects (persistTransientObject) and writes the persisted list back (AttributeRowSetBinding.commit).
- StoreFormStateAction calls FormControl.executeStoreState() only, which runs applyState() of the participants and _overlay.apply() but never persist(tx). The overlay therefore assigns the transient row to the persistent reference, and ListStorage fails on its handle.
- For a **transient** owner (a create dialog) applyState() keeps the rows transient on purpose and it works; only a persistent owner is affected - i.e. every edit dialog with a composition table.
Expected
<store-form-state/> persists what the form's own save persists: run persist(tx) of the participants (in the surrounding transaction, which <with-transaction> provides) before applying the overlay, e.g. by letting executeStoreState call the participants' persist when the base object is persistent.
Two related gaps seen in the same dialog
- Inside a <tab-bar> of one form, the composition table's Add does nothing: the table is created after the form entered edit mode (initial-edit-mode), never gets onFormStateChanged, so _fieldModel stays null and addRow returns null (also listed in TopLogic/tl-ai#629).
- A row created by addRow has no container (TransientObjectFactory.createObject(type) without context), so an options-by-expression that navigates from the row to its owner - "the steps of this workflow" - offers nothing. Passing the form object as the context would fix it.
Workaround carried by the application
BOS/Consulting, WEB-INF/views/flow/: the step dialog has no composition table any more; ways and output fields are tables of their own on the page, each row is made in a create dialog on a transient object whose container is its owner (aiFlowNewPart), and stored by aiFlowSave (a CopyOperation into the owner's composition).
Lösung
- Ein Speicherpfad im Formular. FormControl.executeStoreState() führt den vollständigen Speichervorgang aus: Validierung, Prüfung der Schreibrechte, bei persistentem Formularobjekt persist(tx) aller Teilnehmer in einer (verschachtelten) KB-Transaktion, danach applyState() der Teilnehmer und Übernahme des Overlays. Innerhalb von <with-transaction> tritt die verschachtelte Transaktion der äußeren bei; ohne sie speichert <store-form-state/> selbständig. Das eigene Speichern des Formulars (persistChanges) läuft über denselben Pfad. Die Validierung findet damit vor dem Persistieren statt.
- Spät erzeugte Kompositionstabelle. Ist das Formular bei AbstractCompositionControl.init() bereits im Bearbeitungsmodus (z.B. Tabelle in einer <tab-bar> eines Formulars mit initial-edit-mode), tritt die Tabelle sofort in den Bearbeitungsmodus ein; "Hinzufügen" funktioniert.
- Container neuer Zeilen. addRow legt die transiente Zeile mit dem Basisobjekt des Formulars als Kontext an, so dass container() der Zeile ihren Besitzer liefert.
- Demo (tl-demo-react, Constraint-Test): Bearbeiten-Dialog mit Kompositionstabelle in einer <tab-bar> und <with-transaction><store-form-state/></with-transaction>.