Двусторонняя привязка Blazor с типами данных только для чтения

Я пытаюсь воспользоваться новой функцией сложных свойств Entity Framework Core 8 для своего веб-приложения. Я превратил свою модель Address, которая раньше была полноценной сущностью со своей собственной таблицей, в readonly record struct, которая существует как комплексное свойство нескольких других моделей, например Customer.

У меня есть компонент Blazor, который предназначен для включения в формы создания/редактирования и называется AddressSubForm. Он содержит все поля формы, необходимые для адреса. Проблема в том, что раньше я мог просто @bind-Address = "Customer.Address", а теперь, когда Адрес равен readonly, он не сможет изменить подсвойства Адреса, чтобы отразить ввод пользователя. Само по себе это не проблема: создать новый Адрес, используя синтаксис with для создания новых записей путем изменения старых, несложно и несложно. Но у Blazor или Entity Framework (мне неясно, какой именно) сейчас возникают проблемы с отслеживанием изменений в адресе, и он сохраняет null для всех столбцов адреса в базе данных.

Моя последняя попытка выглядит так:

@using System.Linq.Expressions
<FormItem Field = "Address">
    <Template>
        <label for = "Address" class = "k-label k-form-label">Address</label>
        <div id = "Address" class = "k-form-field-wrap">
            <p>
                <label>Street Address</label>
                <TelerikTextBox @bind-Value = "Street1"
                                OnChange = "async value => { Address = Address with { Street1 = (string)value }; await AddressChanged.InvokeAsync(); }" />
            </p>
            <p>
                <label>Street Address (line 2)</label>
                <TelerikTextBox @bind-Value = "Street2"
                                OnChange = "async value => { Address = Address with { Street2 = (string)value }; await AddressChanged.InvokeAsync(); }" />
            </p>
            <!-- ... -->
        </div>
    </Template>
</FormItem>

@code {
    [Parameter] public Address Address { get; set; }
    [Parameter] public EventCallback<Address> AddressChanged { get; set; }
    [Parameter] public Expression<Func<Address>> AddressExpression { get; set; }

    private string Street1 { get; set; } = string.Empty;
    private string Street2 { get; set; } = string.Empty;
    // ...

    protected override void OnInitialized()
    {
        Street1 = Address.Street1;
        Street2 = Address.Street2;
        // ...
    }
}

Как я могу изменить этот компонент, чтобы он правильно отслеживал любые изменения, внесенные в комплексное свойство «Адрес», и позволял сохранять его в базе данных?

Здесь нет кода EF. Кроме того, read-only и two-way binding предполагают недоразумение. Вам нужна двусторонняя привязка, если вы собираетесь обновить как свою ViewModel, так и объекты базы данных. Это означает, что они должны быть редактируемыми. Вы пытаетесь применить некоторые «лучшие практики»?

Panagiotis Kanavos 25.06.2024 13:39

Обратные вызовы OnChange тоже неверны. Нет смысла так звонить AddressChanged. Привязка данных Blazor сама генерирует эти события. Тот факт, что вам пришлось написать так много сложного кода, является очень убедительным признаком того, что вы что-то делаете неправильно.

Panagiotis Kanavos 25.06.2024 13:43
(it's not clear to me which Вы создаете совершенно новый Address объект каждый раз, когда изменяется свойство, о котором ни EF, ни Blazor ничего не знают. Если используется автоматическая привязка данных, Blazor сам вызовет событие -Changed, когда Address = Address with{} присваивает новый объект свойству Address. Однако EF ничего об этом не знает. Вам придется снова прикрепить этот новый объект к DbContext.
Panagiotis Kanavos 25.06.2024 13:47

@PanagiotisKanavos Я знаю, что делаю что-то неправильно, если бы я не делал что-то неправильно, у меня не было бы этой проблемы. Это была просто последняя попытка многих заставить это работать, и когда мой код начал выглядеть так, я понял, что мне нужно спросить. Раньше я слышал, что иногда явный вызов обратного вызова события может решить проблемы с двусторонней привязкой. Очевидно, в данном случае это не сработало.

AAM111 25.06.2024 15:21
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
4
78
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Есть несколько подходов к этой довольно сложной теме.

Этот ответ основан на коде из моей недавней статьи. Он реализует концепцию контекста редактирования записи внутри подформы. Подформа использует дочернюю форму EditContext.

Он также включает в себя правильное отслеживание состояния редактирования и блокировку форм.

Сначала объекты данных. Person — это стандартный объект типа DTO. Адрес — это объект значения записи. TrackState является частью государственного отслеживания.

public class Person
{
    [TrackState] public string? Name { get; set; }
    // Tracking handled by the Sub-form
    public Address? Address { get; set; }
}

public record Address(string Town, string PostCode);

Далее подформа. Он берет объект значения адреса, предоставленный привязкой, и создает AddressEditContext для управления Address редактированием объекта. Есть обширные встроенные комментарии, объясняющие, что происходит.

@using System.Linq.Expressions

<EditForm EditContext = "_editContext">
    <label class = "form-label">Town</label>
    <InputText class = "form-control mb-2"
               @bind-Value=_recordEditContext.Town
               @bind-Value:after = "OnPossibleChange" />
    <label class = "form-label">PostCode</label>
    <InputText class = "form-control mb-2"
               @bind-Value=_recordEditContext.PostCode
               @bind-Value:after = "OnPossibleChange" />
</EditForm>

@code {
    [CascadingParameter] private EditContext ParentEditContext { get; set; } = default!;
    [Parameter] public Address? Value { get; set; }
    [Parameter] public EventCallback<Address> ValueChanged { get; set; }
    [Parameter] public Expression<Func<Address>> ValueExpression { get; set; } = default!;

    private Address _address = default!;
    private EditContext _editContext = default!;
    private AddressEditContext _recordEditContext = default!;
    private Address? _previousState;

    public override Task SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);

        // Quick and dirty Exception checking
        // Fix in production code
        ArgumentNullException.ThrowIfNull(Value);
        ArgumentNullException.ThrowIfNull(ParentEditContext);
        ArgumentNullException.ThrowIfNull(ValueExpression);

        // If _address is null, then this is the first render event
        // build out the state objects from the provided address
        if (_address is null)
        {
            _address = Value;
            _recordEditContext = new(_address);
            _previousState = _address;
            _editContext = new(_recordEditContext);
        }

        return base.SetParametersAsync(ParameterView.Empty);
    }

    private async Task OnPossibleChange()
    {
        // Get the current state from the RecordEditContext
        var state = _recordEditContext.AsRecord;

        // Get the field identifier for the Address field
        var fi = FieldIdentifier.Create(ValueExpression);

        // If the state has changed:
        //  Update the state, notify the parent edit context and raise the ValueChanged callback
        if (state != _previousState)
        {
            _previousState = state;
            ParentEditContext.NotifyFieldChanged(fi);
            await this.ValueChanged.InvokeAsync(state);
        }

        // Check if the address has reverted to the original state
        // if so clear the Address from the EditContext modified list
        if (!_recordEditContext.IsDirty)
            ParentEditContext.MarkAsUnmodified(fi);
    }

    private class AddressEditContext
    {
        private Address _baseRecord;

        public string Town { get; set; }
        public string PostCode { get; set; }

        public AddressEditContext(Address address)
        {
            _baseRecord = address;
            Town = address.Town ?? string.Empty;
            PostCode = address.PostCode ?? string.Empty;
        }

        public Address AsRecord
            => new(this.Town, this.PostCode);

        public bool IsDirty => _baseRecord != this.AsRecord;
    }
}

Наконец, демо-страница для редактирования Person. Я добавил функцию правильного отслеживания состояния с помощью BlazrEditStateTracker и блокировки формы с помощью NavigationLock.

@page "/"
@inject NavigationManager NavManager
<PageTitle>Home</PageTitle>

<h1>Composite Object Editor Demo</h1>

<EditForm EditContext = "_editContext">
    <BlazrEditStateTracker/>

    <label class = "form-label">Name</label>
    <InputText class = "form-control mb-2"
               @bind-Value=_model.Name />

    <AddressEditor @bind-Value = "_model.Address" />

    <div class = "text-end mb-2">
        <button hidden = "@_isDirty" class = "btn btn-dark" @onclick = "this.Exit">Exit</button>
        <button hidden = "@_isClean" class = "btn btn-danger" @onclick = "this.ExitWithoutSaving">Exit without Saving</button>
        <button disabled = "@_isClean" class = "btn btn-success">Save</button>
    </div>
</EditForm>

<NavigationLock ConfirmExternalNavigation = "_isDirty" OnBeforeInternalNavigation = "this.CheckNavigationAllowed" />

<div class = "bg-dark text-white m-2 p-2">
    <pre>Name = @_model.Name</pre>
    <pre>Town = @_model.Address?.Town</pre>
    <pre>Postcode = @_model.Address?.PostCode</pre>
</div>

@code {
    private Person _model = new() { Name = "Fred", Address = new("Hacconby", "PE10 OUX") };
    private EditContext _editContext = default!;
    private bool _isDirty => _editContext.IsModified();
    private bool _isClean => !_isDirty;
    private bool _exit;

    protected override void OnInitialized()
    {
        _editContext = new(_model);
    }

    private void ExitWithoutSaving()
    {
        _exit = true;
        this.NavManager.NavigateTo("/Counter");
    }

    private void Exit()
    {
        this.NavManager.NavigateTo("/Counter");
    }

    private void CheckNavigationAllowed(LocationChangingContext context)
    {
        if (_isDirty && !_exit)
            context.PreventNavigation();

            _exit = false;
    }
}

Вот файл проекта, в котором показаны два пакета:

<Project Sdk = "Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include = "Blazr.EditStateTracker" Version = "1.3.0" />
  </ItemGroup>

</Project>

Репозиторий Blazr.EditStateTracker находится здесь.

Ответ принят как подходящий

Мне удалось решить эту проблему, передав Func этому компоненту, который устанавливает для свойства Address новое значение. Из-за этого мне больше не нужна двусторонняя привязка.

В AddressSubForm.razor:

<FormItem Field = "Address">
    <Template>
        <label for = "Address" class = "k-label k-form-label">Address</label>
        <div id = "Address" class = "k-form-field-wrap">
            <p>
                <label>Street Address</label>
                <TelerikTextBox @bind-Value = "Street1"
                                OnChange = "value => Address = SetAddress(Address with { Street1 = (string)value })" />
            </p>
            <p>
                <label>Street Address (line 2)</label>
                <TelerikTextBox @bind-Value = "Street2"
                                OnChange = "value => Address = SetAddress(Address with { Street2 = (string)value })" />
            </p>
            <!-- ... -->
        </div>
    </Template>
</FormItem>

@code {
    [Parameter] public Address Address { get; set; }
    
    [Parameter] public Func<Address, Address> SetAddress { get; set; }

    private string Street1 { get; set; } = string.Empty;
    private string Street2 { get; set; } = string.Empty;
    // ...

    protected override void OnInitialized()
    {
        Street1 = Address.Street1;
        Street2 = Address.Street2;
        ...
    }
}

В AddCustomer.razor:

<AddressSubForm Address = "Customer.Address"
                SetAddress = "address => Customer.Address = address"/> 

Другие вопросы по теме