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:
-
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). -
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.