Перейти до основного вмісту

Поведінки інтерфейсу (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.cs
    services.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 — його ігноровано

Дивіться також​