UI behaviors
Prefer create-ui-behavior (in your scaffolded repo's .claude/skills/) when you add or change the logic of a form.
Entity pages are generated from the Dto's attributes, but a real form also does things on its
own while it is open: it recalculates a row sum when a quantity changes, fills a price when an item is picked, hides
a counterparty field for some operations, empties a table the new operation no longer uses. In Kandra all of that
lives in one class per form — the UI behavior, the equivalent of a 1C form module. To see what the Waybill
form does, you read WaybillDto and WaybillUiBehavior, in one file, and nothing else.
A UI behavior is client code. It never touches the database and is not a lifecycle hook around a saved entity (that is
a Behavior with OnSaveAsync/OnSubmitAsync). Anything only the server knows is a
handler it calls.
The shape: a Dto names one behavior
// Acme.Forms/Documents/Waybill.cs
[UiBehavior(typeof(WaybillUiBehavior))]
public class WaybillDto : DocumentDto
{
[OnChange(nameof(WaybillUiBehavior.OnPriceTypeChanged))]
public Guid PriceTypeId { get; set; }
[OnLineRemove(nameof(WaybillUiBehavior.OnLineRemoved))]
public ICollection<WaybillLineDto> Lines { get; set; } = [];
}
public class WaybillLineDto : IPricedItemLine // a line Dto has no [UiBehavior] of its own
{
[OnChange(nameof(WaybillUiBehavior.OnItemChanged))]
public Guid ItemId { get; set; }
[OnChange(nameof(WaybillUiBehavior.RecalcLine))]
public decimal Quantity { get; set; }
[OnChange(nameof(WaybillUiBehavior.RecalcLine))]
public decimal Price { get; set; }
[Visible(nameof(WaybillUiBehavior.ShowSerialNumber))]
public string? SerialNumber { get; set; }
}
public sealed class WaybillUiBehavior(ItemInfoCache items, LinePrices prices) : DocumentUiBehavior<WaybillDto>
{
public async Task OnPriceTypeChanged(CancellationToken ct)
{
await prices.RefreshAllAsync(Record.PriceTypeId, Record.Lines, ct);
RecalcTotal();
}
public async Task OnItemChanged(WaybillLineDto line, CancellationToken ct)
{
await items.LookupAsync(line.ItemId, ct);
if (!items.CarriesBatchData(line.ItemId)) line.SerialNumber = null;
await prices.RefreshAsync(Record.PriceTypeId, Record.Lines, line, ct);
RecalcLine(line);
}
public void RecalcLine(WaybillLineDto line) { line.Sum = line.Quantity * line.Price; RecalcTotal(); }
public void OnLineRemoved(WaybillLineDto line) => RecalcTotal();
public bool ShowSerialNumber(WaybillLineDto line) => items.CarriesBatchData(line.ItemId);
public override ValueTask OnBeforeSaveAsync(CancellationToken ct) { RecalcTotal(); return ValueTask.CompletedTask; }
private void RecalcTotal() => Record.Total = Record.Lines.Sum(l => l.Sum);
}
The behavior class lives in the same file as the Dto, in Acme.Forms (so the browser sees it). It derives from the
base class of the Dto's kind, with TDto set to the Dto that carries the attribute:
| Form kind | Dto that carries [UiBehavior] | Base class |
|---|---|---|
| Document | the document Dto | DocumentUiBehavior<TDto> |
| Dictionary | the dictionary Dto (a hierarchical one: the abstract root Dto — its folder and leaf forms each get their own instance) | DictionaryUiBehavior<TDto> |
| Report | the filters Dto | ReportUiBehavior<TDto> |
| Data processor | the input Dto | DataProcessorUiBehavior<TDto> |
The base classes (namespace Kandra.Forms.UiBehaviors) give you the inherited Record — the Dto being edited. The
Dto is never a method parameter. A behavior must not reference MudBlazor or ASP.NET Core components: the same class is
meant to serve every client UI stack.
Which attribute names which method
Attributes on the Dto name public instance methods of the behavior with nameof. There are no static methods and
no handler types to reference.
| Attribute | Placed on | Method shape |
|---|---|---|
[OnChange(nameof(B.M))] | a header field | () |
[OnChange(nameof(B.M))] | a field of a table part's line Dto | (TLine line) — the edited row |
[Visible(nameof(B.M))] | a header field, or a table-part collection (hides the whole grid) | () returning bool |
[Visible(nameof(B.M))] | a field of a line Dto (hides that cell in the rows the method rejects) | (TLine line) returning bool |
[FormTab("K", Visible = nameof(B.M))], [FormSection("K", Visible = nameof(B.M))] | the Dto class | () returning bool |
[OnLineRemove(nameof(B.M))] | a table-part collection property | (TLine line) — the removed row |
[OnChange]and[OnLineRemove]methods may add a trailingCancellationToken(it is cancelled when the form closes) and returnvoid,TaskorValueTask; the generated form awaits the async ones.[Visible]methods are synchronous and returnbool.- Exactly one
[OnChange]per property. The method does the sequencing in plain C#: pick an item, look it up, clear the serial number, fetch the price, recompute the row, recompute the total. No attribute order to depend on. - Only a user edit fires
[OnChange]. Assigning a property from code — including from another behavior method — does not. A method that changes a dependent value calls the dependent logic itself (RecalcLine(line)above). - A line Dto has no behavior of its own. A method named on a line Dto's property is looked up on the behavior of the document that owns the table part. A line Dto shared by two documents needs that method on both behaviors.
- There is no
[OnLineAdd]: the table-part renderer has no add-row callback.
Lifecycle hooks
Hooks are virtual methods you override, not attributes:
| Hook | Kinds | Runs |
|---|---|---|
OnOpenAsync(CancellationToken) | all | once per record, after Record is set — for a new record and an existing one alike |
OnBeforeSaveAsync(CancellationToken) | dictionary, document | after the form validated, right before the save/submit is sent. It does not cancel the save; rejecting a record is the validator's job |
OnBeforeRunAsync(CancellationToken) | report | before each run of the report |
OnBeforeExecuteAsync(CancellationToken) | data processor | after the input validated, before the processor executes |
State, dependencies and helpers
-
State is private fields of the behavior. There is one instance per open form: an item's attributes a lookup fetched, a UI toggle — they die with the form and are never saved or sent anywhere. A value that matters after Save belongs on the Dto.
-
Dependencies come from client DI, through the constructor. The form creates its behavior with
ActivatorUtilities; you do not register the behavior. Do not resolve a behavior as a scoped service — in Blazor WebAssembly "scoped" is effectively application-wide, so two open forms would share one instance. -
Shared logic is a plain helper class the behavior composes (
ItemInfoCache,LinePrices). A helper that holds per-form state is registered Transient, so each form gets its own copy:// Acme.ClientLib.Common/ClientAcmeServicesExtensions.csservices.AddTransient<ItemInfoCache>();services.AddTransient<LinePrices>();Give helpers a small interface over the lines (
IItemLine,IPricedItemLine, implemented by the line Dtos) so one helper serves Waybill, Invoice and GoodsReceipt alike. -
A server lookup is just an
await. InjectIHandlerApiClient<TIn,TOut>— the generic interface every handler client is bridged to — into the behavior or a helper, fill the request fromRecord, call it, apply the response. Guard the no-op cases first (an empty key should clear the stale value without calling the server). -
A behavior that implements
IDisposableorIAsyncDisposableis disposed together with the form.
Showing and hiding
[Visible] is covered with a full example in UI interfaces. Two
rules matter here: a hidden field keeps its value (clear it in the [OnChange] method of the field that decides
visibility, when a stale value does harm), and the validator never references the behavior — it states its own
condition for a field the form can hide.
public bool HasCounterparty() => Record.Operation is BankPaymentOperation.ToSupplier or BankPaymentOperation.CustomerRefund;
// the validator: its own copy of the condition
RuleFor(x => x.CounterpartyId).NotEmpty()
.When(x => x.Operation is BankPaymentOperation.ToSupplier or BankPaymentOperation.CustomerRefund);
Visibility is re-evaluated on every render, so a hidden-by-default rule must treat "not looked up yet" as show: opening an existing document starts with an empty cache, and it must not hide data it has not checked.
Threading rule
Do not use ConfigureAwait(false) in a behavior, or in any helper it calls, and change Record and its lines only
on the calling context. Blazor WebAssembly is single-threaded, so nothing breaks there today; a UI stack with a dedicated
UI thread would see bound data modified from a background thread.
What is generated
You write no Razor. For each form the generator emits a Behavior member (and a BehaviorToken for methods that take a
CancellationToken) and: [OnChange] becomes an onChanged: callback on the field's editor, [OnLineRemove] an
onLineRemoved: callback on the grid, [Visible] / Visible = an if (Behavior.Method(...)) around the editor,
grid, cell, section or tab. The lifecycle — creating the behavior, giving it Record, running OnOpenAsync once per
record, owning the cancellation token and disposing everything — is not Blazor code: it lives in UiBehaviorHost
(Kandra.ClientLib.Common), and the Blazor form classes only forward their component events to it. Read the generated
{Dto}Form.g.cs under the client project's Generated.Net/ to see exactly what each attribute became. A Dto with no
[UiBehavior] and no [Visible] generates exactly what it always did.
Testing a behavior
A behavior is a plain class; no browser or component test host is needed. Construct it with fakes, hand it a Record,
call the methods and assert on the Dto:
var behavior = new BankPaymentUiBehavior(fakeIbanClient);
((IUiBehaviorRecordTarget<BankPaymentDto>)behavior).AttachRecord(
new BankPaymentDto { Operation = BankPaymentOperation.CustomerRefund });
behavior.OnOperationChanged();
Assert.Empty(behavior.Record.GoodsReceiptSettlements);
fakeIbanClient is a small class implementing IHandlerApiClient<TIn,TOut>. See Testing.
Build errors
| Id | Meaning |
|---|---|
KANDRAUI026 | A method is named but the Dto (or the owning document of a line Dto) has no [UiBehavior] |
KANDRAUI027 | The behavior has no method of that name |
KANDRAUI028 | Wrong signature — the message lists the accepted shapes for that position |
KANDRAUI029 | The [UiBehavior] type is not public, is abstract, or does not derive from the kind's base with the Dto as TDto (or [UiBehavior] sits on a hierarchical folder/leaf instead of the root) |
KANDRAUI030 | Warning: [UiBehavior] on a line Dto — it is ignored |
See also
- UI interfaces — the attribute vocabulary of generated forms, tabs, sections and visibility.
- Handlers — the server endpoints a behavior calls.
- Creating a document — a full Dto with its behavior.
- Testing — what to test as your configuration grows.