Value Helps
A value help is a picker: a dialog that answers a question. Which partner? Which cost centre? The caller awaits a promise, the user picks, the promise resolves.
const selection = await LaravelUi5.openValueHelp({
namespace: 'com.laravelui5.partners.valuehelp.partners',
scope: 'colleagues',
mode: 'single',
});
// null = cancelled · [] = confirmed with nothing · [{key, text, data?}] = a pickIt is the global dialog's sibling — same artifact idea, same shell hosting, one addition: a return channel.
The division of labour
One picker, many scopes. That split is the whole design, and it is what keeps a picker safe to share:
| Who decides | What |
|---|---|
| the picker | the shape — which columns a row has, how it looks, how you search it |
| the server | which rows, for this actor, in this scope |
| the caller | which scope to open, and single or multiple |
A scope is a named facet of the picker: colleagues, userAttached. Every open names one, and the name resolves server-side to an OData entity set. The client never sends a filter, and there is no unscoped picker. Which is why a value help can be opened by an app that has no business seeing every row: the scope it may open decides what it gets.
Writing one
The artifact
namespace App\Sales\ValueHelps;
use LaravelUi5\OData\Edm\Contracts\Type\EntityTypeInterface;
use LaravelUi5\OData\Edm\EdmPrimitiveType;
use LaravelUi5\OData\Edm\Property\Property;
use LaravelUi5\OData\Edm\Type\EntityType;
use LaravelUi5\OData\Edm\Type\PrimitiveType;
use LaravelUi5\Sdk\Ui5\AbstractUi5ValueHelp;
class CustomerPickerValueHelp extends AbstractUi5ValueHelp
{
public const NAMESPACE = 'com.acme.sales.valuehelp.customers';
public const VERSION = '1.0.0';
public const TITLE = 'Customer Picker';
public const DESCRIPTION = 'Selects a customer.';
public const VIEW = 'com.acme.sales.view.valuehelp.Customers';
/** The shape every scope must project: the key, and the columns the view binds. */
public function declaredEntityType(): EntityTypeInterface
{
$int32 = new PrimitiveType(EdmPrimitiveType::Int32);
$string = new PrimitiveType(EdmPrimitiveType::String);
$key = new Property('id', $int32);
return new EntityType(
namespace: static::NAMESPACE,
name: 'Customer',
key: [$key],
declaredProperties: [$key, new Property('name', $string), new Property('city', $string)],
);
}
}The five constants are the same as a dialog's. declaredEntityType() declares the picker's shape: its key and the properties the shared view binds. The base throws if you leave it out — deliberately, since no default shape can exist. In practice every picker needs both: the open intent refuses a picker without a #[ContributesScope] ("Unknown scope"), and ui5:sync asks every scoped picker for its shape and stops if it cannot answer.
Register it on your module — this one has an empty default, so only modules that ship pickers implement it:
public function getValueHelps(): array
{
return [new ValueHelps\CustomerPickerValueHelp($this)];
}#[Access] is optional here, and boundary-scoped: omit it on a picker only your own app opens — the app's gate already covers it. Declare one on a picker other apps may open, where it is the only authorization at the open moment.
There is no generator for value helps; author the files by hand.
The view
Single sap.m.Dialog root, like every shell-hosted dialog. The table's items binding and row template are not in the view — the controller base sets them from the scope the server resolved.
<mvc:View controllerName="com.acme.sales.controller.valuehelp.Customers"
xmlns="sap.m" xmlns:mvc="sap.ui.core.mvc">
<Dialog title="{i18n>customerPicker.title}">
<subHeader>
<Bar><contentMiddle><SearchField search=".onSearch"/></contentMiddle></Bar>
</subHeader>
<Table id="vhTable" growing="true">
<columns><!-- … --></columns>
</Table>
<beginButton><Button text="{i18n>select}" type="Emphasized" press=".onSelect"/></beginButton>
<endButton><Button text="{i18n>cancel}" press=".onCancel"/></endButton>
</Dialog>
</mvc:View>The controller
Extend com.laravelui5.core.AbstractValueHelpController and bind the table in initDialog:
initDialog(oDialog) {
this.bindSelection(this.byId('vhTable'), new ColumnListItem({ cells: [ /* … */ ] }));
},
onSelect() {
const items = this.byId('vhTable').getSelectedItems()
.map(i => ({ key: i.getBindingContext().getProperty('id'),
text: i.getBindingContext().getProperty('name') }));
this.confirmSelection(items);
},
onCancel() {
this.cancel();
},bindSelection sets the selection mode from the caller's mode, binds the items to the entity set the server chose, and installs one more thing worth knowing about: if the read comes back 403, the table shows an "unable to load" illustration instead of an empty list. An empty table would read as no candidates, which is a lie. Override getReadDeniedTitle() for your own wording.
confirmSelection(items) resolves the caller's promise and closes; cancel() resolves it with null. The accessors getValueHelpEntitySet(), getValueHelpMode() and getValueHelpContext() give you what the server and caller sent.
Calling one
const picked = await LaravelUi5.openValueHelp({ namespace, scope, mode: 'single', context: {} });| Option | |
|---|---|
namespace | the picker artifact |
scope | which facet — resolved and authorized on the server |
mode | 'single' or 'multi'; a client concern, never sent to the server |
context | optional caller hints. Untrusted — a scope that is scoped to the actor ignores it |
Three answers, and they are distinct on purpose: null cancelled, [] confirmed with no selection, a non-empty array picked. Denials arrive as a rejected promise, as does opening a second picker while one is open — only one value help is open at a time. On Core without the shell the call rejects outright rather than resolving empty.
The pick-only input pattern, as the shipped apps use it:
<Input value="{form>/customerName}" showValueHelp="true" valueHelpOnly="true"
valueHelpRequest=".onBrowseCustomer"/>valueHelpOnly is deprecated as of UI5 1.119 and still the only single-control pick-only input in sap.m; the alternative is an input plus a companion button.
Contributing a scope
A scope on someone else's picker is declared on your entity set — the same class that carries its #[Read]:
use LaravelUi5\Sdk\Weave\ValueHelp\Attributes\ContributesScope;
use LaravelUi5\Sdk\Security\Attributes\Read;
#[Read(ability: 'readServiceCustomers', role: AcmeRole::Service, note: 'Read customers in service.')]
#[ContributesScope(valueHelp: 'com.acme.sales.valuehelp.customers', scope: 'inService')]
final class ServiceCustomersSet extends AbstractEntitySet { /* … */ }The picker app is not edited. This is the value-help arm of LUX Weave — the same move as contributing a tile to another module's dashboard.
Your set must project the picker's shape: every declared property and every key it names has to exist in your set. That is not a runtime surprise — ui5:sync aborts when it isn't true:
| The sync stops when | Message |
|---|---|
| the scope names a value help that doesn't exist | Scope contribution targets unknown value help […] |
| the picker hosts scopes but declares no shape | … hosts contributed scopes but declares no shape |
| your entity set doesn't exist in the app you named | … points at entity set […] that does not exist in app […] |
| your set is missing a declared property or key | the missing names, keys prefixed key: |
Two contributions cannot claim the same (picker, scope) pair, and one set contributes to one picker — the attribute is not repeatable.
Three gates, one picker
| Layer | Gate | Where | Character |
|---|---|---|---|
| Open | the picker's #[Access] | when the open intent is dispatched | fail-fast UX; optional |
| Read | the resolved scope set's #[Read] | at the OData boundary | the load-bearing one |
| Rows | the set's own scoping (#[ScopedTo…]) | inside the query | which rows |
The picker's #[Access] is a courtesy — it keeps a door from opening that would show nothing. The gate that matters is the #[Read] on the set behind the scope, and a set without one is readable by every signed-in partner (The Read Gate). Gate the sets, not just the picker.
What it doesn't do
- No nested value helps. A picker cannot open another picker.
- No create-from-picker. Not found — create it is not in the contract; compose it from a dialog and an action if you need it.
- No capability flags. A picker doesn't advertise whether it supports multi-select. Opening a single-only picker with
mode: 'multi'is a bug in the caller, not a negotiated failure. - One scope per set. A set that should serve two pickers has to wait for the attribute to become repeatable.
See also
- Global Dialogs: the same hosting, without a return value
- LUX Weave: the contribution idea this shares with dashboards
- The Read Gate: what really protects the rows
- The Settings App: a shipped consumer —
#[ForSetting]binds a picker and a scope to a setting