Convalida della vista generale con un datagrid e mantenimento in MVVM

0

Sto facendo fatica a trovare una buona architettura per quello che deve essere un problema comune

Enable/Disable a button in a View that contains a datagrid, and has validation requirements on the cell, row and collection levels. And to perform this cleanly in a MVVM architecture.

Se non fosse per il datagrid, sarebbe facile, dovrei semplicemente controllare il risultato complessivo di un'implementazione INotifyDataErrorInfo o IDataErrorInfo su ViewModel e utilizzare l'esistenza di eventuali errori per disabilitare il pulsante tramite l'interfaccia ICommand al pulsante, che risiede nel ViewModel.

Tuttavia la griglia di dati complica le cose, e mi trovo a dover andare avanti e indietro tra la convalida dei dati nel ViewModel o nella vista stessa. EG:

  1. La convalida in ViewModel mantiene la convalida più vicina all'origine dati e posso implementare INotifyDataErrorInfo su ciascun elemento della raccolta. Ciò mi consente di convalidare a livello di cella e di riga, ma non sembra esserci alcun supporto per la convalida rispetto alla raccolta generale stessa - e devo ancora trovare una soluzione decente in grado di farlo (vedi link sotto). (E non voglio inquinare gli oggetti nella collezione aggiungendo qualche riferimento a un'entità esterna a loro).

  2. La convalida nella vista stessa (utilizzando le regole di convalida sul datagrid) mi consente di convalidare a livello di cella, riga e raccolta 1 , ma non riesco a vedere come comunicare presenza di errori sul ViewModel in quanto nulla viene passato al ViewModel a meno che sia valido.

La soluzione più vicina che ho visto online che risolve tutti i miei problemi è questa TechNet del 2014 - Validazione dei dati in MVVM

Ma è brutto quanto brutto può ottenere 2 . Per citare da quella pagina (sottolineatura mia):

The Most Important thing is to disable the Save button when the form is invalid. For this we had a static property in our view model named Errors. When the Error is occurred the Error count is increased by 1. When the error is corrected the error count is decreased by 1. So when the Count is 0 the save will be enabled or disabled.

La soluzione dell'autore è posizionata su un contatore statico sul ViewModel e imposta una chiamata nella vista stessa. Quando viene rilevato un errore nel ViewModel, l'interfaccia IDataErrorInfo passa quell'informazione alla Vista, quindi aumenta il contatore sul ViewModel e il valore del contatore viene utilizzato per disabilitare il pulsante.

Inoltre, per eseguire la convalida a livello di raccolta, implementano un'altra variabile statica sul ViewModel che è impostata sull'istanza della raccolta nel ViewModel corrente. Pertanto un elemento si convalida sull'intera collezione trovando la raccolta da questa variabile statica.

Questo codice mi fa rabbrividire.

Quindi cosa mi manca? Come posso facilmente, in modo pulito e rispettando rigorose linee guida MVVM, eseguire la convalida di un ViewModel complessivo che richiede la convalida di una griglia di dati a livello di cella, riga e raccolta? E comunicare i risultati di tale convalida al ViewModel stesso?

1 Per eseguire la convalida a livello di raccolta utilizzando ValidationRules ho creato una regola personalizzata come da questa domanda SO link che inietta la fonte di associazione della griglia di dati nella regola. Ciò consente in modo pulito di convalidare a livello di raccolta.

2 Secondo i commenti in fondo a quel link, questo articolo ha vinto dei premi!

Aggiorna

Vado con una versione modificata della risposta di Emerson. Terrò la convalida interna all'interno del modello di visualizzazione degli oggetti, ma inserirò un test di convalida per eseguire la convalida del livello di raccolta. Il seguente codice espone le mie idee. Questo codice sembra funzionare (e probabilmente potrebbe essere ottimizzato), ma non sono sicuro se andrò all'inferno per questo: D

// Base of all view models 
// Handles property notification and error notification
public abstract class ViewModelBase<T> INotifyPropertyChanged, INotifyDataErrorInfo :  where T : class
{
    // Validate view model against external sources
    // Return a list of errors
    protected Func<T, List<String>> ExternalValidate = null;

    // Rest of INotifyPropertyChanged and INotifyDataErrorInfo implementation etc.
    protected void AddError(string error, [CallerMemberName] string propertyName = null)
    {
      INotifyDataErrorInfo.AddError(error, propertyName);
    }

    protected void AddError(List<string> errors, [CallerMemberName] string propertyName = null)
    {
      foreach(var error in errors)
      {
        INotifyDataErrorInfo.AddError(error, propertyName);
      }
    }
}

// The Product data model
public class Product
{
  public int Number { get; set; }
}

// Adapts a Product Data/Domain model to a view model
// Does all the error checking for the view model both within and external to this one object
public class ProductViewModel : ViewModelBase<ProductViewModel>
{
  public int Number
  {
    get { return _Number; }
    set 
    {
      // Validate "value" against other properties in *this* view model 
      if (value<0) AddError("can't be negative"); 

      // Validate against **External** values
      if (ExternalValidate != null)
      {
        var errors = ExternalValidate(this);
        if (errors != null) AddError(errors);
      }
    }
  }
  protected int _Number;

  public ProductViewModel(Func<ProductViewModel,List<string> externalValidate, Product product)
  {
    // The external validation check
    ExternalValidate = externalValidate

    // Initialize PVM from product
    _Number  = product.Number;
  }
}

// Holds the collection of ProductViewModel objects
// Defines the collection level validation rules
// Injects those rules into the ProductViewModel objects
// The key thing is capturing the values in the Func.
public class MainVM()
{
  public int TheAnswer {get; set; } = 42;

  public List<ProductViewModel> PVMS = new List<ProductViewModel>();

  Func<ProductViewModel, List<string>> ExternalTest = (ProductViewModel pvm) =>
  {
    var result = new List<string>();
    foreach(var pvms in PVMS)
    {
      if (!Object.ReferenceEquals(ppm,pvms)
      {
        if (ppm.Number == pvms.Number)
        {
          result.Add("Can't have duplicates");
        }
      }
    }

    if (pvm.Number == TheAnswer) 
    {          
      result.Add("Can't equal the answer");
    }

    return result.Count>0 ? result : null;

  }

  List<Product> products = GetProductsFromSource();

  foreach(var product in products)
  {
    PVMS.Add( new ProductViewModel(ExternalTest, product) );
  }
}

Nel codice precedente, ogni volta che viene impostata la proprietà Number di ProductViewModel , la classe verrà convalidata all'interno di se stessa e quindi chiamerà la convalida esterna. Se il numero è duplicato o impostato sul valore corrente di TheAnswer , verrà generato un messaggio di errore.

Il salto attraverso i cerchi per arrivare a questo significa che il mio ProductViewModel non sa che è parte di una collezione e semplicemente si convalida contro alcune serie di condizioni definite esternamente. Queste condizioni esterne potrebbero essere qualsiasi cosa, ad esempio l'iterazione sulla raccolta e la ricerca di valori duplicati, o in questo caso il controllo che la proprietà Number non sia uguale ad un valore particolare.

E posizionando il controllo condizionale in una classe base generica questo codice è riutilizzabile.

    
posta Peter M 29.11.2017 - 15:40
fonte

2 risposte

0

Supponendo di avere una classe di prodotto, ogni prodotto non dovrebbe sapere altri prodotti nell'elenco.

Nella tua GUI, ti suggerisco di utilizzare ProductViewModel, responsabile della convalida di tutte le informazioni. Pertanto, non c'è alcun problema nel rendere ProductViewModel in possesso di altre istanze dello stesso tipo nella sua raccolta genitore. Una volta completata la convalida, è possibile generare una raccolta di Prodotti, se necessario.

Ecco alcuni codici che tentano di illustrare il mio suggerimento:

La tua classe di entità dei dati

public class Product {
    public int Id {get;set;}
    public string Name {get;set;}
}

Il tuo modello di visualizzazione (responsabile della convalida di tutto)

//Represents the view model for the Product.
//This view model is responsible for validating information of the product.
//As part of  this validation, it is necessary that this instance know
//details of the collection it belongs.
public class ProductViewModel : INotifyDataErrorInfo {

    //This could be injected via constructor or property.
    //Represents the collection of products, that requires validation
    //See its definition below
    private ProductViewModelCollection parentCollection;

    public int Id { 
        get { return _id; }
        set { if (ValidateId(value))) _id = value; }
    }
    public string Name {
        get { return _name; }
        set { if (ValidateName(value)) _name = value; }
    }

    //The methods below validate all info.
    //Alternatively, you could implement the validation somewhere else, 
    //and inject the necessary validators into this viewModel instance.
    private bool ValidateId(int id) { 
        //... validate the id...

        foreach (ProductViewModel otherProduct in parentCollection) {
            //...if necessary, perform additional validations comparing
            //with other products in the parent collection
        }
    }
    private bool ValidateName(string name) { 
        //... validate the name...

        foreach (ProductViewModel otherProduct in parentCollection) {
            //...if necessary, perform additional validations comparing
            //with other products in the parent collection
        }
    }

    //...implementation of INotifyDataErrorInfo...
}

La tua raccolta di prodotti, è la fonte per il datagrid

public class ProductViewModelCollection : ObservableCollection<ProductViewModel>, IINotifyDataErrorInfo {
    //...implementation of INotifyDataErrorInfo...
}

Dopo questo, non sarà necessario utilizzare la convalida incorporata dei componenti dell'interfaccia utente; puoi implementare tutto nel modello di visualizzazione, riuscendo a testare l'unità di conseguenza. La conoscenza attraverso la raccolta è implementata in ViewModel (perché la tua GUI lo richiede), non all'interno della tua entità di dati.

UPDATE: Secondo approccio (tramite iniezione):

//Represents the view model for the Product.
//This view model is responsible for validating information of the product.
//As part of  this validation, it is necessary that this instance know
//details of the collection it belongs.
public class ProductViewModel : INotifyDataErrorInfo {

    //Injected via constructor or property.
    private IProductValidator validator;

    //String properties, IF YOUR GUI ALLOWS user to type
    //text in the fields
    public string Id { 
        get { return _id; }
        set { if (validator.ValidateId(value))) _id = value; }
    }
    public string Name {
        get { return _name; }
        set { if (validator.ValidateName(value)) _name = value; }
    }

    public Product GetProduct() {
        //...generate Product object with valid info...
    }
}

public class ProductValidator : IProductValidator {

    //This could be injected via constructor or property.
    //Represents the collection of products, that requires validation
    //See its definition below
    private ProductViewModelCollection parentCollection;

    //The methods below validate all info.
    //Alternatively, you could implement the validation somewhere else, 
    //and inject the necessary validators into this viewModel instance.
    private bool ValidateId(string id) { 
        //... validate the id...

        foreach (ProductViewModel otherProduct in parentCollection) {
            //...if necessary, perform additional validations comparing
            //with other products in the parent collection
        }
    }
    private bool ValidateName(string name) { 
        //... validate the name...

        foreach (ProductViewModel otherProduct in parentCollection) {
            //...if necessary, perform additional validations comparing
            //with other products in the parent collection
        }
    }

    //...implementation of INotifyDataErrorInfo...
}
    
risposta data 29.11.2017 - 19:03
fonte
0

Se davvero devi implementare la modifica all'interno di una griglia, per eseguire la convalida sia l'elemento dati che il modello viewmodel principale devono implementare INotifyPropertyChanged, con IDataErrorInfo gestito dall'elemento dati.

Questa è la classe del modello di base che uso per gestire la convalida

public abstract class ValidatingModelBase : INotifyPropertyChanged, IDataErrorInfo
{
    private readonly Dictionary<string, List<ValidationRule>> _validationRules = new Dictionary<string, List<ValidationRule>>();

    #region INotifyPropertyChanged
    public event PropertyChangedEventHandler PropertyChanged;

    public virtual void RaisePropertyChanged(string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
    #endregion

    #region IDataErrorInfo
    public string this[string columnName]
    {
        get
        {
            var result = string.Empty;

            if (_validationRules.ContainsKey(columnName))
                foreach (var errorCheck in _validationRules[columnName])
                {
                    if (errorCheck.IsValid.Invoke())
                        continue;

                    result = errorCheck.FailErrorMessage;
                    break;
                }

            var errorStateChanged = string.IsNullOrWhiteSpace(result) 
                ? _invalidProperties.Remove(columnName) 
                : _invalidProperties.Add(columnName);

            if (errorStateChanged && !nameof(IsValid).Equals(columnName))
                RaisePropertyChanged(nameof(IsValid));

            return result;
        }
    }

    public string Error => string.Empty;
    #endregion

    private readonly HashSet<string> _invalidProperties = new HashSet<string>();

    public bool IsValid => !_invalidProperties.Any();

    public bool HasError(string propertyName) => _invalidProperties.Contains(propertyName);

    public void AddValidationRule(string propertyName, Func<bool> isValid, string failErrorMessage)
    {
        if (!_validationRules.ContainsKey(propertyName))
            _validationRules[propertyName] = new List<ValidationRule>();

        _validationRules[propertyName].Add(new ValidationRule(isValid, failErrorMessage));
    }

    // =============================================================================== 

    private class ValidationRule
    {
        public ValidationRule(Func<bool> isValid, string failErrorMessage)
        {
            IsValid = isValid;
            FailErrorMessage = failErrorMessage;
        }

        public Func<bool> IsValid { get; }
        public string FailErrorMessage { get; }
    }
}

quindi, data una classe modello

public PersonModel: ValidatingModelBase
{
    public PersonModel()
    {
        AddValidationRule(nameof(FirstName), !string.IsNullorWhiteSpace(FirstName), "First Name can't be blank");
        ...
    }

    private string _firstName;
    public string FirstName
    {
        get { return _firstName; }
        set {
                _firstName = value;   
                RaisePropertyChanged(nameof(FirstName));
            }
    }

    ....
}

nel tuo ViewModel potresti scrivere qualcosa come

public MainViewModel: INotifyPropertyChanged
{
    public MainViewModel()
    {
        GridData = _dataService.GetPeople();

        foreach(var person in PersonGridData)
            person.PropertyChanged += (s,e) => if (nameof(Person.IsValid).equals(e.PropertyName))
                                                   RaisePropertyChanged(nameof(PersonGridIsValid));
    }

    public ICollection<PersonModel> PersonGridData {get;}

    public bool PersonGridIsValid => PersonGridData.All(p => p.IsValid);
}

La proprietà PersonGridIsValid può quindi essere utilizzata per determinare se l'intera vista è in uno stato valido.

    
risposta data 29.11.2017 - 18:23
fonte

Leggi altre domande sui tag