Поведінки інтерфейсу (UI behaviors)
Надавайте перевагу навичці create-ui-behavior (у .claude/skills/ вашого згенерованого репозиторію), коли
додаєте або змінюєте логіку форми.
Сторінки сутностей генеруються з атрибутів Dto, але справжня форма також сама щось робить,
поки відкрита: перераховує суму рядка, коли змінюється кількість, підставляє ціну, коли обрано товар, ховає поле
контрагента для деяких операцій, спорожнює таблицю, яка новій операції більше не потрібна. У Kandra все це живе в
одному класі на форму — поведінці інтерфейсу (UI behavior), відповіднику модуля форми 1C. Щоб побачити, що
робить форма Waybill, ви читаєте WaybillDto і WaybillUiBehavior в одному файлі — і більше нічого.
Поведінка інтерфейсу — це клієнтський код. Вона ніколи не торкається бази даних і не є хуком життєвого циклу навколо
збереженої сутності (це Behavior з OnSaveAsync/OnSubmitAsync). Усе, що знає лише сервер, —
це обробник, який вона викликає.
Форма: Dto називає одну поведінку
// 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 // рядковий Dto не має власного [UiBehavior]
{
[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);
}
Клас поведінки живе в тому самому файлі, що й Dto, у Acme.Forms (щоб його бачив браузер). Він успадковує базовий
клас виду Dto, де TDto — це Dto, що несе атрибут:
| Вид форми | Dto, що несе [UiBehavior] | Базовий клас |
|---|---|---|
| Документ | Dto документа | DocumentUiBehavior<TDto> |
| Довідник | Dto довідника (ієрархічного — абстрактний кореневий Dto; його папкова й листова форми отримують власні екземпляри) | DictionaryUiBehavior<TDto> |
| Звіт | Dto фільтрів | ReportUiBehavior<TDto> |
| Обробник даних | Dto вводу | DataProcessorUiBehavior<TDto> |
Базові класи (простір імен Kandra.Forms.UiBehaviors) дають успадкований Record — Dto, що редагується. Dto
ніколи не є параметром методу. Поведінка не повинна посилатися на MudBlazor чи компоненти ASP.NET Core: той самий клас
призначений обслуговувати кожен клієнтський UI-стек.
Який атрибут називає який метод
Атрибути на Dto називають публічні екземплярні методи поведінки через nameof. Статичних методів і типів
обробників, на які треба посилатися, немає.
| Атрибут | Розміщується на | Форма методу |
|---|---|---|
[OnChange(nameof(B.M))] | поле шапки | () |
[OnChange(nameof(B.M))] | поле рядкового Dto частини таблиці | (TLine line) — відредагований рядок |
[Visible(nameof(B.M))] | поле шапки або колекція частини таблиці (ховає всю сітку) | (), повертає bool |
[Visible(nameof(B.M))] | поле рядкового Dto (ховає цю клітинку в рядках, які метод відхиляє) | (TLine line), повертає bool |
[FormTab("K", Visible = nameof(B.M))], [FormSection("K", Visible = nameof(B.M))] | клас Dto | (), повертає bool |
[OnLineRemove(nameof(B.M))] | колекція частини таблиці | (TLine line) — вилучений рядок |
- Методи
[OnChange]і[OnLineRemove]можуть додати завершальнийCancellationToken(його скасовують, коли форма закривається) і повертатиvoid,TaskабоValueTask; згенерована форма очікує асинхронні. Методи[Visible]синхронні й повертаютьbool. - Рівно один
[OnChange]на властивість. Метод сам впорядковує кроки звичайним C#: обрано товар, знайти його, очистити серійний номер, отримати ціну, перерахувати рядок, перерахувати підсумок. Порядок атрибутів не має значення. [OnChange]спрацьовує лише на редагування користувача. Присвоєння властивості з коду — зокрема з іншого методу поведінки — не спрацьовує. Метод, що змінює залежне значення, сам викликає залежну логіку (RecalcLine(line)вище).- Рядковий Dto не має власної поведінки. Метод, названий на властивості рядкового Dto, шукається у поведінці документа, якому належить частина таблиці. Рядковий Dto, спільний для двох документів, потребує цього методу в обох поведінках.
[OnLineAdd]немає: рендерер частини таблиці не має зворотного виклику додавання рядка.
Хуки життєвого циклу
Хуки — це віртуальні методи, які ви перевизначаєте, а не атрибути:
| Хук | Види | Виконується |
|---|---|---|
OnOpenAsync(CancellationToken) | усі | один раз на запис, після того як задано Record — і для нового запису, і для наявного |
OnBeforeSaveAsync(CancellationToken) | довідник, документ | після валідації форми, безпосередньо перед надсиланням збереження/проведення. Він не скасовує збереження; відхиляти запис — справа валідатора |
OnBeforeRunAsync(CancellationToken) | звіт | перед кожним запуском звіту |
OnBeforeExecuteAsync(CancellationToken) | обробник даних | після валідації вводу, перед виконанням обробника |
OnOpenAsync — місце, де заздалегідь отримують те, що правила видимості форми читають, а запис не містить. Наприклад,
послуги в документі приховують серійний номер і термін придатності своїх рядків, а відкритий документ ще не шукав свої
номенклатурні позиції:
public override ValueTask OnOpenAsync(CancellationToken cancellationToken) =>
items.PrefetchAsync(Record.Lines?.Select(l => l.ItemId), cancellationToken);
Три правила роблять це безпечним. Надсилайте один запит на весь документ (помічник відкидає дублікати, порожні
ідентифікатори й позиції, які вже має, і нічого не надсилає, якщо не лишилося жодної). Лише читайте: хук ніколи не змінює
Record, тож прихована комірка зберігає значення, а відкритий документ не виглядає зміненим. І не блокуйте форму при
збої: форма очікує OnOpenAsync під час відкриття, тож виняток із нього ламає сторінку. Попереднє завантаження, яке
скасовано (форму закрили раніше) або яке завершилося помилкою, має залишати форму відкритою з видимими комірками, як і
раніше.
Стан, залежності та допоміжні класи
-
Стан — це приватні поля поведінки. На одну відкриту форму є один екземпляр: атрибути товару, які підтягнув пошук, перемикач UI — вони зникають разом із формою й ніколи не зберігаються й нікуди не надсилаються. Значення, що має значення після Save, належить Dto.
-
Залежності приходять із клієнтського DI через конструктор. Форма створює свою поведінку через
ActivatorUtilities; поведінку ви не реєструєте. Не розв'язуйте поведінку як scoped-сервіс — у Blazor WebAssembly «scoped» фактично діє на весь застосунок, тож дві відкриті форми ділили б один екземпляр. -
Спільна логіка — це звичайний допоміжний клас, який поведінка складає (
ItemInfoCache,LinePrices). Допоміжний клас, що тримає стан окремої форми, реєструється як Transient, щоб кожна форма мала власну копію:// Acme.ClientLib.Common/ClientAcmeServicesExtensions.csservices.AddTransient<ItemInfoCache>();services.AddTransient<LinePrices>();Дайте допоміжним класам невеликий інтерфейс над рядками (
IItemLine,IPricedItemLine, реалізовані рядковими Dto), щоб один помічник обслуговував Waybill, Invoice та GoodsReceipt однаково. -
Серверний пошук — це просто
await. ВпровадьтеIHandlerApiClient<TIn,TOut>— узагальнений інтерфейс, з яким зв'язано кожен клієнт обробника — у поведінку або допоміжний клас, заповніть запит зRecord, викличте його, застосуйте відповідь. Спершу перевірте випадки «нічого робити» (порожній ключ має очистити застаріле значення, не звертаючись до сервера). -
Поведінка, що реалізує
IDisposableабоIAsyncDisposable, звільняється разом із формою.
Показ і приховування
[Visible] повністю з прикладом розглянуто в Інтерфейсах UI. Тут
важливі два правила: приховане поле зберігає значення (очистьте його в методі [OnChange] поля, що визначає
видимість, якщо застаріле значення шкодить), а валідатор ніколи не посилається на поведінку — він сам формулює
умову для поля, яке форма може приховати.
public bool HasCounterparty() => Record.Operation is BankPaymentOperation.ToSupplier or BankPaymentOperation.CustomerRefund;
// валідатор: власна копія умови
RuleFor(x => x.CounterpartyId).NotEmpty()
.When(x => x.Operation is BankPaymentOperation.ToSupplier or BankPaymentOperation.CustomerRefund);
Видимість переоцінюється під час кожного рендеру, тож правило «прихований за замовчуванням» мусить вважати «ще не шукали» як показувати: відкриття наявного документа починається з порожнього кешу, і воно не повинно ховати дані, яких не перевіряло.
Правило потоків
Не використовуйте ConfigureAwait(false) у поведінці чи в допоміжних класах, які вона викликає, і змінюйте
Record та його рядки лише в контексті виклику. Blazor WebAssembly однопотоковий, тож сьогодні нічого не ламається;
UI-стек з окремим UI-потоком побачив би зв'язані дані, змінені з фонового потоку.
Що генерується
Razor ви не пишете. Для кожної форми генератор випускає член Behavior (і BehaviorToken для методів із
CancellationToken), а також: [OnChange] стає зворотним викликом onChanged: на редакторі поля, [OnLineRemove] —
зворотним викликом onLineRemoved: на сітці, [Visible] / Visible = — if (Behavior.Method(...)) навколо
редактора, сітки, клітинки, секції чи вкладки. Життєвий цикл — створення поведінки, передача їй Record, виконання
OnOpenAsync один раз на запис, володіння токеном скасування та звільнення всього — це не код Blazor: він живе в
UiBehaviorHost (Kandra.ClientLib.Common), а форми Blazor лише передають йому свої події компонента. Щоб
побачити, у що саме перетворився кожен атрибут, прочитайте згенерований {Dto}Form.g.cs у Generated.Net/
клієнтського проєкту. Dto без [UiBehavior] і без [Visible] генерується точнісінько як завжди.
Тестування поведінки
Поведінка — це звичайний клас; браузер чи тестовий хост компонентів не потрібні. Створіть її з фальшивими
залежностями, надайте Record, викличте методи й перевірте Dto:
var behavior = new BankPaymentUiBehavior(fakeIbanClient);
((IUiBehaviorRecordTarget<BankPaymentDto>)behavior).AttachRecord(
new BankPaymentDto { Operation = BankPaymentOperation.CustomerRefund });
behavior.OnOperationChanged();
Assert.Empty(behavior.Record.GoodsReceiptSettlements);
fakeIbanClient — невеликий клас, що реалізує IHandlerApiClient<TIn,TOut>. Дивіться Тестування.
Помилки збірки
| Id | Значення |
|---|---|
KANDRAUI026 | Метод названо, але Dto (або документ, якому належить рядковий Dto) не має [UiBehavior] |
KANDRAUI027 | У поведінці немає методу з такою назвою |
KANDRAUI028 | Неправильна сигнатура — повідомлення перелічує допустимі форми для цього місця |
KANDRAUI029 | Тип у [UiBehavior] не публічний, абстрактний або не успадковує базу виду з Dto як TDto (або [UiBehavior] стоїть на папці/листі ієрархічного довідника замість кореня) |
KANDRAUI030 | Попередження: [UiBehavior] на рядковому Dto — його ігноровано |
Дивіться також
- Інтерфейси UI — словник атрибутів згенерованих форм, вкладок, секцій і видимості.
- Обробники — серверні кінцеві точки, які викликає поведінка.
- Створення документа — повний Dto з його поведінкою.
- Тестування — що тестувати, коли конфігурація зростає.