major
#29532
Object navigation in TL Views: display targets, a reveal protocol for containers, and a show-object action
Problem
In the classic UI a link to a business object is resolved on the server: GotoHandler receives the object, looks up the component that displays objects of its type (WithGotoConfiguration.getGotoTargets(), a map from TLType to ComponentName, with a global default), and LayoutComponent.makeVisible() walks the component tree so that every ancestor - tab, dialog, layout - shows the child on the way to that component. Object links inside structured text are tlObject anchors written by TLObjectLinkUtil; a click runs the OpenTLObjectLink control command, which resolves the object server-side and either navigates or opens it in a dialog. No client-side URL is involved anywhere.
The view layer (com.top_logic.layout.view) has no counterpart:
- No display targets. Nothing declares which view shows an object of a given type. A <form input="ticket">, a <table selection="…"> or a <tile-stack> frame bound with bind-input-to all display objects, but the association "objects of type T are shown here, on channel C" exists only implicitly in TL-Script expressions.
- No reveal protocol. There is no operation that makes a given element visible by asking each container on the way from the root to show the right child - the equivalent of makeVisible(). Each container has its own state channel (the sidebar's active item, the tab bar's active tab, the tile stack's path, the dialog stack), but none of them exposes "reveal this child".
- No show-object action. A <generic-command> chain can write channels, open dialogs and run scripts, but it cannot say "display this object wherever it is displayed". The only navigation actions are <navigate-push>/`<navigate-pop>`, which are commands (see #29530) and presuppose that the caller already sits inside the right stack.
- The client-side hooks dangle. ReactResourceCellControl sends a goto command when a linked cell is clicked and offers setGotoListener(GotoListener) with handleGoto(ReactContext, Object), but nothing in the engine calls setGotoListener, and the only creator (MetaResourceControlProvider) passes useLink = false. Structured text (I18NHtml) is displayed in the React UI without any handling of tlObject anchors.
Consequence for an application such as the issue tracker built on the view layer: a ticket reference in a description, an assignee cell in a table or a "blocked by" entry in a form cannot be followed. Any such link would have to be hand-wired per site with knowledge of the concrete sidebar item and stack, which does not generalize - an application need not have a sidebar at all.
A further requirement: showing an object may require several selections in order. A ticket of a milestone of a project, displayed three levels deep, is reached only by selecting the project, then the milestone, then the ticket - in a tile stack even across three frames with separate channel namespaces. A single "type T on channel C" declaration cannot express this, and a table that swaps its own model when asked for a selection it does not contain (the classic approach) produces fragile selection/model loops.
Solution
Three generic parts in com.top_logic.layout.view.navigation, none of which knows sidebars, tab bars or tile stacks in particular.
Display targets: the `DisplayTargetService`
Display targets are declared globally in the configuration of a service, the counterpart of the classic goto targets. The application decides which of the places that display a type is the place to show an object of it; a view file stays reusable and does not know that it is a navigation target; a library module contributes targets for its own types in its configuration fragment.
A target names a type and an ordered list of views to show, each with the channel values to set (from the React demo):
{{{#!xml <config service-class="com.top_logic.layout.view.navigation.DisplayTargetService">
<instance>
<targets>
<target type="tl.demo.projectManagement:Ticket">
<show view="projects/overview.view.xml">
<bind channel="project" expr="t -> $t.container()"/>
</show>
<show view="projects/milestones.view.xml"
label-expr="t -> $t.container().get(tl.demo.projectManagement:ProjectScope#name)">
<bind channel="project" expr="t -> $t.container()"/>
</show>
<show view="projects/tickets.view.xml"
label-expr="t -> $t.get(tl.demo.projectManagement:Ticket#milestone).get(tl.demo.projectManagement:Milestone#name)">
<bind channel="milestone" expr="t -> $t.get(tl.demo.projectManagement:Ticket#milestone)"/>
</show>
<show view="projects/ticket-detail.view.xml" label-expr="t -> $t.get(tl.demo.projectManagement:Ticket#title)">
<bind channel="ticket"/>
</show>
</target>
<target type="tl.demo.projectManagement:Contributor">
<show view="projects/contributor-dialog.view.xml" dialog="true">
<bind channel="contributor"/>
</show>
</target>
</targets>
</instance>
</config> }}}
A <show> entry is carried out in one of three ways, decided by how the view is reached:
- The view is mounted somewhere in the application (a sidebar item, a tab, a <view-ref>, an <adaptive-detail> pane, the initial view of a <tile-stack>): the mount is revealed (see below) and the bindings are written to the view's channels in the declared order.
- The view is not mounted: it is pushed as a frame onto the tile stack that hosts the previously shown view, with the bindings as the frame's bound values and label/`label-expr` as its breadcrumb label. A chain of such entries rebuilds a drill-down path; frames already on the stack with the same view, values and label are kept (pop to the longest matching prefix, push the rest), so a target reaching a frame the user drilled into by hand reuses it.
- dialog="true": the view is opened as a dialog with the bindings as initial channel values (the same seam <open-dialog> uses); it must be the last entry.
A <bind> expression is a TL-Script function of the object being shown; it defaults to the object itself. The most specific type wins (an exact class beats a generalization); among several targets for one type, the one whose first view is mounted nearest to the view that triggered the navigation is preferred, then the one flagged default="true", then the first declared. hasTarget(type) answers whether objects of a type can be shown at all - this decides whether a value is rendered as a link. At startup the service checks every <bind channel> against the channels its view declares and logs a configuration error for a mismatch.
Where a view is mounted is determined by a static scan of the view configuration reachable from the application's root view: UIElement.getChildGroups() reports an element's content without a session - keyed for content a container addresses by a key (sidebar item id, tab id, adaptive-detail pane, tile-stack initial view), unkeyed otherwise, and as an embedded view for a <view-ref> - and ViewMounts derives from it the mount paths of every view file, cached and invalidated together with the view files. Sidebar items and tabs create their content lazily, which is why this knowledge cannot come from the control tree.
A reveal protocol for containers
Every control that shows one of several children implements ChildRevealer.revealChild(key): the sidebar activates the item, the tab bar selects the tab, <adaptive-detail> switches to its pane, the tile stack pops to the frame, a dialog closes the dialogs above it; <switch> needs nothing (it follows its input). Containers record their position when they create their children's contexts (a RevealPath scope in the ViewContext, analogous to the slot path), and a RevealRegistry per browser window maps mounted views and containers to their runtime instances. Revealing a mounted view walks its mount path from the root, asking each container on the way to reveal the next child - which creates content that is built lazily - and then writes the bindings into the view instance found at the end. This is makeVisible() for view elements. Unsaved changes veto a reveal exactly as they veto a channel write (confirmation dialog, then retry; cancelling aborts the command chain). The routing participants of #29530 are these same containers, so the address bar reflects the revealed state without additional work.
A `show-object` action and automatic links
<show-object/> is a ViewAction, usable in a <generic-command> chain beside <execute-script>, <with-transaction> and <open-dialog>: it shows the object handed in by the chain and passes it on; <generic-command input="selection"><show-object/></generic-command> is the whole configuration of a "go to" button. Java code calls ObjectNavigation.show. Controls of com.top_logic.layout.react reach the same operation through ReactContext.getObjectNavigator() (canShow, show), answered by the view layer, so that object values displayed read-only are links automatically wherever a display target exists for their type:
- ReactResourceCellControl - tree nodes (via MetaResourceControlProvider) and any cell created with useLink; a GotoListener, if set, still takes precedence.
- The read-only values of ReactDropdownSelectControl, which is how reference attributes are displayed in <table> cells and in view-mode <form> fields - so an assignee in a table or a "blocked by" entry in a form can be followed.
- tlObject anchors in structured text displayed read-only by the React WYSIWYG control: a click is sent to the server (showObjectLink), the object is resolved with TLObjectLinkUtil as in the classic OpenTLObjectLink, and shown; a link to a deleted object or to a type without a target is reported to the user.
The TL-Script functions htmlObjectLink(object, label), htmlSource(content) and htmlText(source) (HtmlFunctions, com.top_logic.layout.wysiwyg) write such an anchor and read or write the HTML source of a structured-text attribute, so a command can append an object reference to a comment.
Commands in the WYSIWYG editor toolbar, and the control a field is edited with
Two general additions let an editor offer a "reference an object" button the way the classic CreateTLObjectLink editor plugin does, without the editor knowing anything about objects:
- A field chooses its input control in the view. <field attribute="content"><input-control class="…Provider" …/></field> (FieldElement.Config) names the ReactFieldControlProvider editing this field where it is displayed, overriding the model's <input-control> annotation, the select rule and the type mapping (FieldControlService.createFieldControl with an explicit provider). A control that belongs to one place in the user interface - an editor whose toolbar opens a dialog of this very view - is configured in the view, so that the model keeps saying what the attribute is and the view how it is presented.
- The WYSIWYG editor's toolbar carries configured view commands. WysiwygControlProvider takes <commands> - ordinary ViewCommand`s with label, icon, executability, `input=, <open-dialog>, <execute-script> and so on - and an optional insert-channel="<name>". The commands run in a child ViewContext of the field's view context: they see the channels of the surrounding view (the selected ticket, the project) and hand them on to the dialogs they open. Where an insertion channel is declared, it exists in that context beside the view's channels, and a String written to it is inserted at the cursor: the control pushes a one-shot insert request {seq, html}, the client inserts it at the current selection once per seq and reports the resulting text, which takes the request back (a remounted client never repeats it). The commands are built by ToolbarBuilder into a ReactToolbarControl (icon-only by default, like the formatting buttons) exposed as the toolbar child control, rendered by the client beside its own buttons. ViewCommands carries the model-building and lifecycle code shared with the elements that declare <commands>.
The React demo's comment composer configures it in tickets/comment-composer.view.xml:
{{{#!xml <field attribute="content" label-position="hide-label">
<input-control class="com.top_logic.layout.react.wysiwyg.WysiwygControlProvider" insert-channel="insert">
<commands>
<generic-command image="css:ri-links-line" input="ticket">
<label><en>Reference ticket...</en><de>Ticket referenzieren...</de></label>
<executability><null-input-disabled/></executability>
<open-dialog dialog-view="tickets/reference-ticket.view.xml">
<bind channel="context" to="ticket"/>
<bind channel="result" to="insert"/>
</open-dialog>
</generic-command>
</commands>
</input-control>
</field> }}}
The dialog receives the ticket the comment belongs to as context, and its Insert action is <execute-script function="t -> htmlObjectLink($t)"/><write-channel name="result"/><close-dialog/> - the whole contract between a selector and the editor. The former "Reference ticket" panel command of the composer appended the link at the end of the text by rewriting the draft, and was disabled while the mandatory content field showed an error, because its store-form-state action put the FormValid rule on it. The inserted link carries the target/`rel` attributes the TipTap link extension adds to every anchor; they do not affect following the link.
Also fixed on the way
- A <table> did not show a selection its channel already named when the table was created (a drilled-down frame receives its selection as a parameter): the row stayed unhighlighted. It now applies the initial selection.
- TLObjectLinkUtil.getLink/`getLinkDestination` accept any TLObject, not only a Wrapper.
- OpenDialogAction.openDialog returns the DialogHandle of the opened dialog; DialogManager can close the dialogs above a given one.
Documentation and demo
docs/faq/react-view-layer.md describes display targets, the mount scan, the reveal protocol, the entry points, the field-level control choice and the editor's command toolbar. com.top_logic.demo.react gets a Projects drill-down (project, milestone, ticket, detail) whose types have display targets, a contributor dialog target, tl.accounts:Person shown in the tiles demo (whose initial view is mounted three times, exercising the nearest-mount rule), and the object-list comment composer references a project ticket through a command in the editor's toolbar; the rendered comment, the assignee cell, the milestone field and the account values navigate.