diff --git a/Source/Csla.Blazor/ViewModel.cs b/Source/Csla.Blazor/ViewModel.cs index 45476d106a..7005d2621e 100644 --- a/Source/Csla.Blazor/ViewModel.cs +++ b/Source/Csla.Blazor/ViewModel.cs @@ -6,405 +6,38 @@ // Base type for creating your own view model //----------------------------------------------------------------------- -using System.Collections.Specialized; -using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using Csla.Core; using Csla.Reflection; -using Csla.Rules; namespace Csla.Blazor { /// /// Base type for creating your own view model. /// - public class ViewModel + public class ViewModel<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T> : Csla.Mvvm.ViewModel { - /// - /// Gets the current ApplicationContext instance. - /// - protected ApplicationContext ApplicationContext { get; } - /// /// Creates an instance of the type. /// /// /// is . public ViewModel(ApplicationContext applicationContext) - { - ApplicationContext = applicationContext ?? throw new ArgumentNullException(nameof(applicationContext)); - } - - #region Events - - /// - /// Event raised after Model has been saved. - /// - public event Action? Saved; - /// - /// Event raised when failed to save Model. - /// - public event EventHandler? Error; - /// - /// Event raised when Model is changing. - /// - public event Action? ModelChanging; - /// - /// Event raised when Model has changed. - /// - public event Action? ModelChanged; - /// - /// Event raised when the Model object - /// raises its PropertyChanged event. - /// - public event PropertyChangedEventHandler? ModelPropertyChanged; - /// - /// Event raised when the Model object - /// raises its ModelChildChanged event. - /// - public event Action? ModelChildChanged; - /// - /// Event raised when the Model object - /// raises its ModelCollectionChanged event. - /// - public event Action? ModelCollectionChanged; - - /// - /// Raises the ModelChanging event. - /// - /// Old Model value - /// New Model value - protected virtual void OnModelChanging(T? oldValue, T? newValue) - { - if (ReferenceEquals(oldValue, newValue)) - return; - - if (ManageObjectLifetime && newValue is ISupportUndo undo) - undo.BeginEdit(); - - _propertyInfoCache.Clear(); - - // unhook events from old value - if (oldValue != null) - { - UnhookChangedEvents(oldValue); - - if (oldValue is INotifyBusy nb) - nb.BusyChanged -= OnBusyChanged; - } - - // hook events on new value - if (newValue != null) - { - HookChangedEvents(newValue); - - if (newValue is INotifyBusy nb) - nb.BusyChanged += OnBusyChanged; - } - - ModelChanging?.Invoke(oldValue, newValue); - } - - /// - /// Unhooks changed event handlers from the model. - /// - /// - protected void UnhookChangedEvents(T? model) - { - if (model is INotifyPropertyChanged npc) - npc.PropertyChanged -= OnModelPropertyChanged; - - if (model is INotifyChildChanged ncc) - ncc.ChildChanged -= OnModelChildChanged; - - if (model is INotifyCollectionChanged cc) - cc.CollectionChanged -= OnModelCollectionChanged; - } - - /// - /// Hooks changed events on the model. - /// - /// - private void HookChangedEvents(T? model) - { - if (model is INotifyPropertyChanged npc) - npc.PropertyChanged += OnModelPropertyChanged; - - if (model is INotifyChildChanged ncc) - ncc.ChildChanged += OnModelChildChanged; - - if (model is INotifyCollectionChanged cc) - cc.CollectionChanged += OnModelCollectionChanged; - } - - private void OnBusyChanged(object sender, BusyChangedEventArgs e) - { - // only set busy state for entire object. Ignore busy state based - // on async rules being active - if (string.IsNullOrEmpty(e.PropertyName)) - IsBusy = e.Busy; - } - - /// - /// Raises the ModelChanged event. - /// - protected virtual void OnModelChanged() - { - ModelChanged?.Invoke(); - } - - /// - /// Raises the ModelPropertyChanged event. - /// - /// - /// - protected virtual void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - ModelPropertyChanged?.Invoke(this, e); - } - - /// - /// Raises the ModelChildChanged event. - /// - /// - /// - protected virtual void OnModelChildChanged(object? sender, ChildChangedEventArgs e) - { - ModelChildChanged?.Invoke(this, e); - } - - /// - /// Raises the ModelCollectionChanged event. - /// - /// - /// - protected virtual void OnModelCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - ModelCollectionChanged?.Invoke(this, e); - } - - #endregion - - #region Methods - - /// - /// Refresh the Model. - /// - /// Async data portal or factory method - /// is . - public async Task RefreshAsync(Func> factory) - { - ArgumentNullException.ThrowIfNull(factory); - - Exception = null; - ViewModelErrorText = string.Empty; - try - { - IsBusy = true; - await SetPropertiesAtObjectLevelAsync(); - Model = await factory(); - } - catch (DataPortalException ex) - { - Model = default; - Exception = ex; - ViewModelErrorText = ex.BusinessExceptionMessage; - } - catch (Exception ex) - { - Model = default; - Exception = ex; - ViewModelErrorText = ex.Message; - } - finally - { - IsBusy = false; - } - - return Model; - } - - /// - /// Gets or sets a value specifying the timeout - /// when calling SaveAsync and the Model is - /// currently busy with async rules. - /// - public TimeSpan BusyTimeout { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// Saves the Model asynchronously. - /// - public async Task SaveAsync() - { - try - { - using var cts = BusyTimeout.ToCancellationTokenSource(); - await SaveAsync(cts.Token); - } - catch (TaskCanceledException tcex) - { - Exception = new TimeoutException(nameof(SaveAsync), tcex); - ViewModelErrorText = Exception.Message; - } - } - - /// - /// Saves the Model. - /// - /// The cancellation token. - public async Task SaveAsync(CancellationToken ct) - { - Exception = null; - ViewModelErrorText = string.Empty; - try - { - if (Model is ITrackStatus obj && !obj.IsSavable) - { - if (obj.IsBusy) - { - while (obj.IsBusy) - { - ct.ThrowIfCancellationRequested(); - await Task.Delay(1, ct); - } - } - if (!obj.IsValid) - { - ViewModelErrorText = ModelErrorText; - return; - } - else if (!obj.IsSavable) - throw new InvalidOperationException( - $"{obj.GetType().Name} IsBusy: {obj.IsBusy}, IsValid: {obj.IsValid}, IsSavable: {obj.IsSavable}"); - } - - UnhookChangedEvents(Model); - - var savable = Model as ISavable; - if (ManageObjectLifetime) - { - //apply changes - must apply edit to Model not clone - if (Model is ISupportUndo undoable) - undoable.ApplyEdit(); - - // clone the object if possible - if (savable is ICloneable clonable) - savable = (ISavable)clonable.Clone(); - } - - IsBusy = true; - Model = await DoSaveAsync(savable); - Saved?.Invoke(); - } - catch (DataPortalException ex) - { - Exception = ex; - ViewModelErrorText = ex.BusinessExceptionMessage; - } - catch (TimeoutException ex) - { - Exception = ex; - ViewModelErrorText = ex.Message; - } - catch (Exception ex) - { - Exception = ex; - ViewModelErrorText = ex.Message; - } - finally - { - if (ManageObjectLifetime && Model is IUndoableObject udbl && udbl.EditLevel == 0 && Model is ISupportUndo undo) - undo.BeginEdit(); + : base(applicationContext) + { } - HookChangedEvents(Model); - IsBusy = false; - if (Exception != null) - { - Error?.Invoke(this, new Core.ErrorEventArgs(this, Exception)); - } - } - } - - /// - /// Override to provide custom Model save behavior. - /// - protected virtual async Task DoSaveAsync(ISavable? cloned) + /// + protected override void OnModelChanging(T? oldValue, T? newValue) { - if (cloned != null) - { - var saved = (T)await cloned.SaveAsync(); - if (Model is IEditableBusinessObject editable) - await new GraphMerger(ApplicationContext).MergeGraphAsync(editable, (IEditableBusinessObject)saved); - else - Model = saved; - } - return Model; + if (!ReferenceEquals(oldValue, newValue)) + _propertyInfoCache.Clear(); + base.OnModelChanging(oldValue, newValue); } - /// - /// Cancels changes made to the model - /// if ManagedObjectLifetime is true. - /// - protected virtual void DoCancel() - { - if (ManageObjectLifetime) - { - if (Model is ISupportUndo undo) - { - UnhookChangedEvents(Model); - try - { - undo.CancelEdit(); - undo.BeginEdit(); - } - finally - { - HookChangedEvents(Model); - } - } - } - } - - #endregion - - #region Properties - - private T? _model; - /// - /// Gets or sets the Model object. - /// - public T? Model - { - get => _model; - set - { - if (!ReferenceEquals(_model, value)) - { - OnModelChanging(_model, value); - _model = value; - OnModelChanged(); - } - } - } - - /// - /// Gets or sets a value indicating whether the - /// view model should manage the lifetime of - /// the business object, including using n-level - /// undo. - /// - public bool ManageObjectLifetime { get; set; } = false; - - /// - /// Gets a value indicating whether this object is - /// executing an asynchronous process. - /// - public bool IsBusy { get; protected set; } = false; + #region GetPropertyInfo private const string TextSeparator = " "; - #endregion - - #region GetPropertyInfo /// /// Get a PropertyInfo object for a property. @@ -464,14 +97,14 @@ public IPropertyInfo GetPropertyInfo

(Expression> property, string id) ///

/// Property name /// is , or only consists of white spaces. - /// is . + /// is . public IPropertyInfo GetPropertyInfo(string propertyName) { ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); _ = Model ?? throw new InvalidOperationException($"{nameof(Model)} == null"); var keyName = Model.GetType().FullName + "." + propertyName; - return GetPropertyInfo(keyName, Model, propertyName, " "); + return GetPropertyInfo(keyName, Model, propertyName, TextSeparator); } /// @@ -482,14 +115,14 @@ public IPropertyInfo GetPropertyInfo(string propertyName) /// Property name /// Unique identifier for property in list or array /// is , or only consists of white spaces. - /// is . + /// is . public IPropertyInfo GetPropertyInfo(string propertyName, string id) { ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); _ = Model ?? throw new InvalidOperationException($"{nameof(Model)} == null"); var keyName = Model.GetType().FullName + "." + propertyName + $"[{id}]"; - return GetPropertyInfo(keyName, Model, propertyName, " "); + return GetPropertyInfo(keyName, Model, propertyName, TextSeparator); } private readonly Dictionary _propertyInfoCache = []; @@ -507,165 +140,5 @@ private IPropertyInfo GetPropertyInfo(string keyName, object model, string prope } #endregion - - #region Errors and Exception - - /// - /// Gets any error text generated by refresh or save operations. - /// - public string ViewModelErrorText { get; protected set; } = string.Empty; - - /// - /// Gets the first validation error - /// message from the Model. - /// - protected virtual string ModelErrorText - { - get - { - if (Model is IDataErrorInfo obj) - { - return obj.Error; - } - return string.Empty; - } - } - - /// - /// Gets the last exception caught by - /// the view model during refresh or save - /// operations. - /// - public Exception? Exception { get; private set; } - - #endregion - - #region ObjectLevelPermissions - - private bool _canCreateObject; - - /// - /// Gets a value indicating whether the current user - /// is authorized to create an instance of the - /// business domain type. - /// - public bool CanCreateObject - { - get - { - SetPropertiesAtObjectLevel(); - return _canCreateObject; - } - protected set - { - if (_canCreateObject != value) - _canCreateObject = value; - } - } - - private bool _canGetObject; - - /// - /// Gets a value indicating whether the current user - /// is authorized to retrieve an instance of the - /// business domain type. - /// - public bool CanGetObject - { - get - { - SetPropertiesAtObjectLevel(); - return _canGetObject; - } - protected set - { - if (_canGetObject != value) - _canGetObject = value; - } - } - - private bool _canEditObject; - - /// - /// Gets a value indicating whether the current user - /// is authorized to edit/save an instance of the - /// business domain type. - /// - public bool CanEditObject - { - get - { - SetPropertiesAtObjectLevel(); - return _canEditObject; - } - protected set - { - if (_canEditObject != value) - _canEditObject = value; - } - } - - private bool _canDeleteObject; - - /// - /// Gets a value indicating whether the current user - /// is authorized to delete an instance of the - /// business domain type. - /// - public bool CanDeleteObject - { - get - { - SetPropertiesAtObjectLevel(); - return _canDeleteObject; - } - protected set - { - if (_canDeleteObject != value) - _canDeleteObject = value; - } - } - - private Task? _objectLevelPermissionsTask; - - /// - /// Sets the properties at object level. - /// - /// - /// When all authorization rules are synchronous the values are - /// set before this method returns. If a rule is asynchronous the - /// values are set when it completes; call RefreshAsync to ensure - /// they are available. - /// - private void SetPropertiesAtObjectLevel() - { - if (_objectLevelPermissionsTask != null) - return; - var task = SetPropertiesAtObjectLevelAsync(); - // surface exceptions from synchronous rules, as before - if (task.IsCompleted) - task.GetAwaiter().GetResult(); - } - - /// - /// Sets the properties at object level, evaluating - /// authorization rules only once. - /// - private Task SetPropertiesAtObjectLevelAsync() - { - return _objectLevelPermissionsTask ??= LoadObjectLevelPermissionsAsync(); - } - - private async Task LoadObjectLevelPermissionsAsync() - { - Type sourceType = typeof(T); - - CanCreateObject = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.CreateObject, sourceType, CancellationToken.None); - CanGetObject = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.GetObject, sourceType, CancellationToken.None); - CanEditObject = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.EditObject, sourceType, CancellationToken.None); - CanDeleteObject = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.DeleteObject, sourceType, CancellationToken.None); - } - - #endregion } } diff --git a/Source/Csla.Xaml.Shared/ViewModel.cs b/Source/Csla.Xaml.Shared/ViewModel.cs index 3c4bb70f57..9cf9d0f02b 100644 --- a/Source/Csla.Xaml.Shared/ViewModel.cs +++ b/Source/Csla.Xaml.Shared/ViewModel.cs @@ -6,13 +6,10 @@ // // Base class used to create ViewModel objects, //----------------------------------------------------------------------- -#if ANDROID -namespace Csla.Axml -#elif IOS -namespace Csla.Iosui -#else + +using System.Diagnostics.CodeAnalysis; + namespace Csla.Xaml -#endif { /// /// Base class used to create ViewModel objects, @@ -20,7 +17,7 @@ namespace Csla.Xaml /// InvokeMethod or Invoke. /// /// Type of the Model object. - public class ViewModel : ViewModelBase + public class ViewModel<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T> : ViewModelBase { /// /// Saves the Model, first committing changes if ManagedObjectLifetime is true. @@ -48,11 +45,7 @@ public virtual void Cancel(object sender, ExecuteEventArgs e) /// public virtual void AddNew(object sender, ExecuteEventArgs e) { -#if ANDROID || IOS - BeginAddNew(); -#else DoAddNew(); -#endif } /// diff --git a/Source/Csla.Xaml.Shared/ViewModelBase.cs b/Source/Csla.Xaml.Shared/ViewModelBase.cs index ebbc8592a2..4c34ab2f72 100644 --- a/Source/Csla.Xaml.Shared/ViewModelBase.cs +++ b/Source/Csla.Xaml.Shared/ViewModelBase.cs @@ -6,818 +6,415 @@ // Base class used to create ViewModel objects that //----------------------------------------------------------------------- -using System.Collections; using System.Collections.Specialized; using System.ComponentModel; using System.ComponentModel.DataAnnotations; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Globalization; -#if !ANDROID && !IOS && !XAMARIN && !MAUI && !AVALONIA +#if !MAUI && !AVALONIA using System.Windows; #endif using Csla.Core; -using Csla.Rules; -#if WINDOWS_UWP -using Windows.UI.Xaml; -#endif -#if ANDROID && !MAUI -namespace Csla.Axml -#elif IOS && !MAUI -namespace Csla.Iosui -#else namespace Csla.Xaml -#endif { +#if MAUI || AVALONIA /// /// Base class used to create ViewModel objects that /// implement their own commands/verbs/actions. /// /// Type of the Model object. -#if ANDROID || IOS || XAMARIN || MAUI || AVALONIA - public abstract class ViewModelBase : INotifyPropertyChanged, IViewModel + public abstract class ViewModelBase<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T> : Csla.Mvvm.ViewModel, IViewModel + { + /// + /// Creates an instance of the type that resolves + /// its ApplicationContext through + /// . + /// + protected ViewModelBase() + : base(manageObjectLifetime: true) + { } + + /// + /// Creates an instance of the type. + /// + /// The current ApplicationContext. + /// is . + protected ViewModelBase(ApplicationContext applicationContext) + : base(applicationContext, manageObjectLifetime: true) + { } + + /// + protected override ApplicationContext ResolveApplicationContext() => ApplicationContextManager.GetApplicationContext(); + + /// + /// Creates or retrieves a new instance of the + /// Model by invoking an action. + /// + /// Factory method to invoke + [Obsolete("Use RefreshAsync(Func>)")] + public virtual Task RefreshAsync(Func> factory) => RefreshAsync(factory); + +#if MAUI + /// + /// Adds a new item to the Model (if it + /// is a collection). + /// + [Obsolete("Use DoAddNew")] + protected virtual void BeginAddNew() => DoAddNew(); +#endif + + object? IViewModel.Model + { + get => Model; + set => Model = (T?)value; + } + } #else - public abstract class ViewModelBase : DependencyObject, + /// + /// Base class used to create ViewModel objects that + /// implement their own commands/verbs/actions. + /// + /// Type of the Model object. + /// + /// This type is a , so it + /// delegates its behavior to a + /// instance rather than inheriting from it. + /// + public abstract class ViewModelBase<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T> : DependencyObject, INotifyPropertyChanged, IViewModel -#endif { - private ApplicationContext ApplicationContext => ApplicationContextManager.GetApplicationContext(); + private readonly InnerViewModel _inner; -#if ANDROID || IOS || XAMARIN || WINDOWS_UWP || MAUI || AVALONIA - private T? _model; /// - /// Gets or sets the Model object. + /// Creates an instance of the type that resolves + /// its ApplicationContext through + /// . /// - public T? Model + protected ViewModelBase() { - get => _model; - set - { - if (!ReferenceEquals(value, _model)) - { - var oldValue = _model; - _model = value; - this.OnModelChanged((T?)oldValue, _model); - OnPropertyChanged(nameof(Model)); - } - } + _inner = new InnerViewModel(this, ManageObjectLifetime); + Initialize(); } -#else + + /// + /// Creates an instance of the type. + /// + /// The current ApplicationContext. + /// is . + protected ViewModelBase(ApplicationContext applicationContext) + { + _inner = new InnerViewModel(this, applicationContext, ManageObjectLifetime); + Initialize(); + } + + private void Initialize() + { + _inner.Error += (_, e) => Error?.Invoke(this, new Core.ErrorEventArgs(this, e.Error)); + } + + /// + /// Gets the current ApplicationContext instance. + /// + protected ApplicationContext ApplicationContext => _inner.Context; + + #region Model and ManageObjectLifetime + /// /// Gets or sets the Model object. /// public static readonly DependencyProperty ModelProperty = DependencyProperty.Register(nameof(Model), typeof(T), typeof(ViewModelBase), - new PropertyMetadata((o, e) => - { - var viewmodel = (ViewModelBase)o; - viewmodel.OnModelChanged((T)e.OldValue, (T)e.NewValue); - })); + new PropertyMetadata((o, e) => ((ViewModelBase)o)._inner.Model = (T?)e.NewValue)); /// /// Gets or sets the Model object. /// public T? Model { - get { return (T?)GetValue(ModelProperty); } - set { SetValue(ModelProperty, value); } + get => (T?)GetValue(ModelProperty); + set => SetValue(ModelProperty, value); } -#endif /// /// Gets or sets a value indicating whether the /// ViewModel should automatically managed the /// lifetime of the Model. /// -#if ANDROID || IOS || XAMARIN || MAUI || AVALONIA - public bool ManageObjectLifetimeProperty; -#else public static readonly DependencyProperty ManageObjectLifetimeProperty = DependencyProperty.Register(nameof(ManageObjectLifetime), typeof(bool), - typeof(ViewModelBase), new PropertyMetadata(true)); -#endif + typeof(ViewModelBase), new PropertyMetadata(true, + (o, e) => ((ViewModelBase)o)._inner.ManageObjectLifetime = (bool)e.NewValue)); + /// /// Gets or sets a value indicating whether the - /// ViewManageObjectLifetime should automatically managed the - /// lifetime of the ManageObjectLifetime. + /// ViewModel should automatically manage the + /// lifetime of the Model. /// [Display(AutoGenerateField = false)] [ScaffoldColumn(false)] public bool ManageObjectLifetime { -#if ANDROID || IOS || XAMARIN || MAUI || AVALONIA - get { return (bool)ManageObjectLifetimeProperty; } - set { ManageObjectLifetimeProperty = value; } -#else - get { return (bool)GetValue(ManageObjectLifetimeProperty); } - set { SetValue(ManageObjectLifetimeProperty, value); } -#endif + get => (bool)GetValue(ManageObjectLifetimeProperty); + set => SetValue(ManageObjectLifetimeProperty, value); } - private bool _isBusy; + object? IViewModel.Model + { + get => Model; + set => Model = (T?)value; + } - /// - /// Gets a value indicating whether this object is - /// executing an asynchronous process. - /// - public bool IsBusy + #endregion + + #region Status properties + + /// + public TimeSpan BusyTimeout { - get => _isBusy; - protected set - { - if (value != _isBusy) - { - _isBusy = value; - OnPropertyChanged(nameof(IsBusy)); - OnSetProperties(); - } - } + get => _inner.BusyTimeout; + set => _inner.BusyTimeout = value; } - private bool _isDirty; + /// + public bool IsBusy + { + get => _inner.IsBusy; + protected set => _inner.SetIsBusy(value); + } - /// - /// Gets a value indicating whether the Model - /// has been changed. - /// + /// public virtual bool IsDirty { - get => _isDirty; - protected set - { - if (_isDirty != value) - { - _isDirty = value; - OnPropertyChanged(nameof(IsDirty)); - } - } + get => _inner.BaseIsDirty; + protected set => _inner.BaseIsDirty = value; } - private bool _isValid; - - /// - /// Gets a value indicating whether the Model - /// is currently valid (has no broken rules). - /// + /// public virtual bool IsValid { - get => _isValid; - protected set - { - if (_isValid != value) - { - _isValid = value; - OnPropertyChanged(nameof(IsValid)); - } - } + get => _inner.BaseIsValid; + protected set => _inner.BaseIsValid = value; } - private bool _canSave = false; - - /// - /// Gets a value indicating whether the Model - /// can currently be saved. - /// + /// public virtual bool CanSave { - get => _canSave; - protected set - { - if (_canSave != value) - { - _canSave = value; - OnPropertyChanged(nameof(CanSave)); - } - } + get => _inner.BaseCanSave; + protected set => _inner.BaseCanSave = value; } - private bool _canCancel = false; - - /// - /// Gets a value indicating whether the Model - /// can currently be canceled. - /// + /// public virtual bool CanCancel { - get => _canCancel; - protected set - { - if (_canCancel != value) - { - _canCancel = value; - OnPropertyChanged(nameof(CanCancel)); - } - } + get => _inner.BaseCanCancel; + protected set => _inner.BaseCanCancel = value; } - private bool _canCreate = false; - - /// - /// Gets a value indicating whether an instance - /// of the Model - /// can currently be created. - /// + /// public virtual bool CanCreate { - get => _canCreate; - protected set - { - if (_canCreate != value) - { - _canCreate = value; - OnPropertyChanged(nameof(CanCreate)); - } - } + get => _inner.BaseCanCreate; + protected set => _inner.BaseCanCreate = value; } - private bool _canDelete = false; - - /// - /// Gets a value indicating whether the Model - /// can currently be deleted. - /// + /// public virtual bool CanDelete { - get => _canDelete; - protected set - { - if (_canDelete != value) - { - _canDelete = value; - OnPropertyChanged(nameof(CanDelete)); - } - } + get => _inner.BaseCanDelete; + protected set => _inner.BaseCanDelete = value; } - private bool _canFetch = false; - - /// - /// Gets a value indicating whether an instance - /// of the Model - /// can currently be retrieved. - /// + /// public virtual bool CanFetch { - get => _canFetch; - protected set - { - if (_canFetch != value) - { - _canFetch = value; - OnPropertyChanged(nameof(CanFetch)); - } - } + get => _inner.BaseCanFetch; + protected set => _inner.BaseCanFetch = value; } - private bool _canRemove = false; - - /// - /// Gets a value indicating whether the Model - /// can currently be removed. - /// + /// public virtual bool CanRemove { - get => _canRemove; - protected set - { - if (_canRemove != value) - { - _canRemove = value; - OnPropertyChanged(nameof(CanRemove)); - } - } + get => _inner.BaseCanRemove; + protected set => _inner.BaseCanRemove = value; } - private bool _canAddNew = false; - - /// - /// Gets a value indicating whether the Model - /// can currently be added. - /// + /// public virtual bool CanAddNew { - get => _canAddNew; - protected set - { - if (_canAddNew != value) - { - _canAddNew = value; - OnPropertyChanged(nameof(CanAddNew)); - } - } + get => _inner.BaseCanAddNew; + protected set => _inner.BaseCanAddNew = value; } - private int _setPropertiesVersion; - - private async void SetProperties() + /// + public virtual bool CanCreateObject { - // async void so exceptions surface on the SynchronizationContext, - // just as a synchronous exception would - await SetPropertiesAsync(); + get => _inner.BaseCanCreateObject; + protected set => _inner.BaseCanCreateObject = value; } - private async Task SetPropertiesAsync() + /// + public virtual bool CanGetObject { - var version = ++_setPropertiesVersion; - var model = Model; - - // evaluate authorization rules first; when all rules are synchronous - // these tasks are already complete and no thread switch occurs - var canDeleteInstance = false; - var canRemoveItem = false; - var canAddNewItem = false; - Type? itemType = null; - if (model is ITrackStatus targetObject) - canDeleteInstance = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.DeleteObject, targetObject, CancellationToken.None); - if (model is ICollection) - { - itemType = Utilities.GetChildItemType(model.GetType()); - if (itemType != null) - { - canRemoveItem = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.DeleteObject, itemType, CancellationToken.None); - canAddNewItem = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.CreateObject, itemType, CancellationToken.None); - } - } - - // a newer call has started (or the model changed) while awaiting - if (version != _setPropertiesVersion || !ReferenceEquals(model, Model)) - return; - - bool isObjectBusy = false; - if (Model is INotifyBusy busyObject && busyObject.IsBusy) - isObjectBusy = true; - - // Does Model instance implement ITrackStatus - if (Model is ITrackStatus trackStatus) - { - IsDirty = trackStatus.IsDirty; - IsValid = trackStatus.IsValid; - CanSave = CanEditObject && trackStatus.IsSavable && !isObjectBusy; - CanCancel = CanEditObject && trackStatus.IsDirty && !isObjectBusy; - CanCreate = CanCreateObject && !trackStatus.IsDirty && !isObjectBusy; - CanDelete = CanDeleteObject && !isObjectBusy && canDeleteInstance; - CanFetch = CanGetObject && !trackStatus.IsDirty && !isObjectBusy; - - // Set properties for List - if (Model is ICollection list) - { - if (itemType == null) - { - CanAddNew = false; - CanRemove = false; - } - else - { - CanRemove = canRemoveItem && list.Count > 0 && !isObjectBusy; - CanAddNew = canAddNewItem && !isObjectBusy; - } - } - else - { - CanRemove = false; - CanAddNew = false; - } - } - - // Else if Model instance implement ICollection - else if (Model is ICollection list) - { - if (itemType == null) - { - CanAddNew = false; - CanRemove = false; - } - else - { - CanRemove = canRemoveItem && list.Count > 0 && !isObjectBusy; - CanAddNew = canAddNewItem && !isObjectBusy; - } - } - else - { - IsDirty = false; - IsValid = false; - CanCancel = false; - CanCreate = CanCreateObject; - CanDelete = false; - CanFetch = CanGetObject && !IsBusy; - CanSave = false; - CanRemove = false; - CanAddNew = false; - } + get => _inner.BaseCanGetObject; + protected set => _inner.BaseCanGetObject = value; } - private bool _canCreateObject; - - /// - /// Gets a value indicating whether the current - /// user is authorized to create a Model. - /// - public virtual bool CanCreateObject + /// + public virtual bool CanEditObject { - get - { - SetPropertiesAtObjectLevel(); - return _canCreateObject; - } - protected set - { - if (_canCreateObject != value) - { - _canCreateObject = value; - OnPropertyChanged(nameof(CanCreateObject)); - } - } + get => _inner.BaseCanEditObject; + protected set => _inner.BaseCanEditObject = value; } - private bool _canGetObject; - - /// - /// Gets a value indicating whether the current - /// user is authorized to retrieve a Model. - /// - public virtual bool CanGetObject + /// + public virtual bool CanDeleteObject { - get - { - SetPropertiesAtObjectLevel(); - return _canGetObject; - } - protected set - { - if (_canGetObject != value) - { - _canGetObject = value; - OnPropertyChanged(nameof(CanGetObject)); - } - } + get => _inner.BaseCanDeleteObject; + protected set => _inner.BaseCanDeleteObject = value; } - private bool _canEditObject; - - /// - /// Gets a value indicating whether the current - /// user is authorized to save (insert or update - /// a Model. - /// - public virtual bool CanEditObject - { - get - { - SetPropertiesAtObjectLevel(); - return _canEditObject; - } - protected set - { - if (_canEditObject != value) - { - _canEditObject = value; - OnPropertyChanged(nameof(CanEditObject)); - } - } - } + #endregion - private bool _canDeleteObject; + #region Errors - /// - /// Gets a value indicating whether the current - /// user is authorized to delete - /// a Model. - /// - public virtual bool CanDeleteObject + /// + public string ViewModelErrorText { - get - { - SetPropertiesAtObjectLevel(); - return _canDeleteObject; - } - protected set - { - if (_canDeleteObject != value) - { - _canDeleteObject = value; - OnPropertyChanged(nameof(CanDeleteObject)); - } - } + get => _inner.ViewModelErrorText; + protected set => _inner.SetViewModelErrorText(value); } - private bool ObjectPropertiesSet; + /// + public Exception? Exception => _inner.Exception; /// - /// Sets the properties at object level. + /// Gets the first validation error + /// message from the Model. /// - private async void SetPropertiesAtObjectLevel() - { - if (ObjectPropertiesSet) - return; - ObjectPropertiesSet = true; - - // async void so exceptions surface on the SynchronizationContext, - // just as a synchronous exception would - await SetPropertiesAtObjectLevelAsync(); - } + protected virtual string ModelErrorText => _inner.BaseModelErrorText; - private async Task SetPropertiesAtObjectLevelAsync() - { - Type sourceType = typeof(T); + #endregion - // when all rules are synchronous these tasks are already complete, - // so the values are set before the property getter returns - CanCreateObject = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.CreateObject, sourceType, CancellationToken.None); - CanGetObject = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.GetObject, sourceType, CancellationToken.None); - CanEditObject = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.EditObject, sourceType, CancellationToken.None); - CanDeleteObject = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.DeleteObject, sourceType, CancellationToken.None); + #region Operations - // call SetProperties to set "instance" values - OnSetProperties(); - } + /// + public virtual Task RefreshAsync(Func> factory) => _inner.RefreshAsync(factory); /// - /// Creates or retrieves a new instance of the + /// Creates or retrieves a new instance of the /// Model by invoking an action. /// /// Factory method to invoke - public virtual async Task RefreshAsync(Func> factory) - { - T? result; - try - { - IsBusy = true; - result = await factory.Invoke(); - Model = result; - } - finally - { - IsBusy = false; - } - return result; - } + [Obsolete("Use RefreshAsync(Func>)")] + public virtual Task RefreshAsync(Func> factory) => RefreshAsync(factory); - /// - /// Saves the Model, first committing changes - /// if ManagedObjectLifetime is true. - /// - public virtual async Task SaveAsync() - { - try - { - UnhookChangedEvents(Model); - var savable = Model as ISavable; - if (ManageObjectLifetime) - { - // clone the object if possible - if (Model is ICloneable clonable) - savable = (ISavable)clonable.Clone(); - - //apply changes - if (savable is ISupportUndo undoable) - undoable.ApplyEdit(); - } - - IsBusy = true; - Model = await DoSaveAsync(savable); - } - finally - { - HookChangedEvents(Model); - IsBusy = false; - } - return Model; - } + /// + public virtual Task SaveAsync() => _inner.SaveAsync(); + + /// + public virtual Task SaveAsync(CancellationToken ct) => _inner.BaseSaveAsync(ct); /// /// Override to provide custom Model save behavior. /// - protected virtual async Task DoSaveAsync(ISavable? cloned) - { - if (cloned != null) - { - var saved = (T)await cloned.SaveAsync(); - if (Model is IEditableBusinessObject editable) - await new GraphMerger(ApplicationContext).MergeGraphAsync(editable, (IEditableBusinessObject)saved); - else - Model = saved; - } - return Model; - } + /// The object to save (a clone of Model if ManageObjectLifetime is true). + protected virtual Task DoSaveAsync(ISavable? cloned) => _inner.BaseDoSaveAsync(cloned); /// - /// Cancels changes made to the model + /// Cancels changes made to the model /// if ManagedObjectLifetime is true. /// - protected virtual void DoCancel() - { - if (ManageObjectLifetime) - { - if (Model is ISupportUndo undo) - { - UnhookChangedEvents(Model); - try - { - undo.CancelEdit(); - undo.BeginEdit(); - } - finally - { - HookChangedEvents(Model); - OnSetProperties(); - } - } - } - } - -#if (ANDROID || IOS) || XAMARIN || MAUI - /// - /// Adds a new item to the Model (if it - /// is a collection). - /// - protected virtual void BeginAddNew() - { -#if ANDROID || IOS - var ibl = (Model as System.ComponentModel.IBindingList); -#else - var ibl = (Model as IBindingList); -#endif - if (ibl != null) - { - ibl.AddNew(); - } - else if (Model is IObservableBindingList iobl) - { - iobl.AddNew(); - } - else - { - throw CreateInvalidOperationExceptionForUnsupportedListType(); - } + protected virtual void DoCancel() => _inner.BaseDoCancel(); - OnSetProperties(); - } -#else /// /// Adds a new item to the Model (if it /// is a collection). /// - protected virtual object DoAddNew() - { - object result; - // typically use ObserableCollection - if (Model is IObservableBindingList iobl) - { - result = iobl.AddNew(); - } - else if (Model is IBindingList ibl) - { - result = ibl.AddNew()!; - } - else - { - throw CreateInvalidOperationExceptionForUnsupportedListType(); - } - - OnSetProperties(); - return result; - } -#endif + protected virtual object DoAddNew() => _inner.BaseDoAddNew(); /// /// Removes an item from the Model (if it /// is a collection). /// - protected virtual void DoRemove(object? item) - { - ThrowIoeWhenModelIsNull(); - ((IList)Model).Remove(item); - OnSetProperties(); - } + protected virtual void DoRemove(object? item) => _inner.BaseDoRemove(item); /// /// Marks the Model for deletion (if it is an /// editable root object). /// - protected virtual void DoDelete() - { - ThrowIoeWhenModelIsNull(); - ((IEditableBusinessObject)Model).Delete(); - } + protected virtual void DoDelete() => _inner.BaseDoDelete(); + + #endregion + + #region Events and hooks /// - /// Invoked when the Model changes, allowing - /// event handlers to be unhooked from the old - /// object and hooked on the new object. + /// Invoked before the Model changes. The default implementation + /// moves event handlers from the old object to the new object. /// /// Previous Model reference. /// New Model reference. - protected virtual void OnModelChanged(T? oldValue, T? newValue) - { - if (ReferenceEquals(oldValue, newValue)) - return; - - if (ManageObjectLifetime && newValue is ISupportUndo undo) - undo.BeginEdit(); - - // unhook events from old value - if (oldValue != null) - { - UnhookChangedEvents(oldValue); + protected virtual void OnModelChanging(T? oldValue, T? newValue) => _inner.BaseOnModelChanging(oldValue, newValue); - if (oldValue is INotifyBusy nb) - nb.BusyChanged -= Model_BusyChanged; - } - - // hook events on new value - if (newValue != null) - { - HookChangedEvents(newValue); - - if (newValue is INotifyBusy nb) - nb.BusyChanged += Model_BusyChanged; - } - - OnSetProperties(); - } + /// + /// Invoked after the Model has changed. + /// + /// Previous Model reference. + /// New Model reference. + protected virtual void OnModelChanged(T? oldValue, T? newValue) => _inner.BaseOnModelChanged(oldValue, newValue); /// /// Unhooks changed event handlers from the model. /// - /// - protected void UnhookChangedEvents(T? model) - { - if (model is INotifyPropertyChanged npc) - npc.PropertyChanged -= Model_PropertyChanged; - - if (model is INotifyChildChanged ncc) - ncc.ChildChanged -= Model_ChildChanged; - - if (model is INotifyCollectionChanged cc) - cc.CollectionChanged -= Model_CollectionChanged; - } + /// Model object. + protected void UnhookChangedEvents(T? model) => _inner.Unhook(model); /// - /// Hooks changed events on the model. + /// Hooks changed event handlers on the model. /// - /// - private void HookChangedEvents(T? model) - { - if (model is INotifyPropertyChanged npc) - npc.PropertyChanged += Model_PropertyChanged; - - if (model is INotifyChildChanged ncc) - ncc.ChildChanged += Model_ChildChanged; - - if (model is INotifyCollectionChanged cc) - cc.CollectionChanged += Model_CollectionChanged; - } + /// Model object. + protected void HookChangedEvents(T? model) => _inner.Hook(model); /// - /// Override this method to hook into to logic of setting - /// properties when model is changed or edited. + /// Override this method to hook into to logic of setting + /// properties when model is changed or edited. /// /// - /// Authorization rules are evaluated with - /// . - /// When a rule is asynchronous, the permission-based properties + /// Authorization rules are evaluated asynchronously. When a rule + /// is asynchronous, the permission-based properties /// (such as CanDelete or CanAddNew) may be updated after this /// method returns; PropertyChanged is raised when they change. /// - protected virtual void OnSetProperties() - { - SetProperties(); - } - - private void Model_BusyChanged(object sender, BusyChangedEventArgs e) - { - // only set busy state for entire object. Ignore busy state based - // on asynch rules being active - if (string.IsNullOrEmpty(e.PropertyName)) - IsBusy = e.Busy; - else - OnSetProperties(); - } + protected virtual void OnSetProperties() => _inner.BaseOnSetProperties(); - private void Model_PropertyChanged(object? sender, PropertyChangedEventArgs e) + /// + /// Event raised after Model has been saved. + /// + public event Action? Saved { - OnSetProperties(); - ModelPropertyChanged?.Invoke(this, e); + add => _inner.Saved += value; + remove => _inner.Saved -= value; } - private void Model_ChildChanged(object? sender, ChildChangedEventArgs e) - { - OnSetProperties(); - ModelChildChanged?.Invoke(this, e); - } + /// + public event EventHandler? Error; - private void Model_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + /// + /// Event raised when Model is changing. + /// + public event Action? ModelChanging { - OnSetProperties(); - ModelCollectionChanged?.Invoke(this, e); + add => _inner.ModelChanging += value; + remove => _inner.ModelChanging -= value; } - object? IViewModel.Model + /// + /// Event raised when Model has changed. + /// + public event Action? ModelChanged { - get => Model; - set => Model = (T?)value; + add => _inner.ModelChanged += value; + remove => _inner.ModelChanged -= value; } /// @@ -849,16 +446,126 @@ protected virtual void OnPropertyChanged(string propertyName) PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - [StackTraceHidden] - private InvalidOperationException CreateInvalidOperationExceptionForUnsupportedListType() - { - return new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, Csla.Properties.Resources.UnsupportedXamlListTypeForModel, nameof(IObservableBindingList), "IBindingList")); - } + #endregion - [StackTraceHidden, MemberNotNull(nameof(Model))] - private void ThrowIoeWhenModelIsNull() + /// + /// The that implements + /// the behavior, routing its virtual members back to the + /// owning viewmodel so overrides there take effect. + /// + private sealed class InnerViewModel : Csla.Mvvm.ViewModel { - _ = Model ?? throw new InvalidOperationException($"{nameof(Model)} == null"); + private readonly ViewModelBase _owner; + + // the initial ManageObjectLifetime is passed to the base constructor, + // so no PropertyChanged reaches the owner before its constructor runs + public InnerViewModel(ViewModelBase owner, bool manageObjectLifetime) + : base(manageObjectLifetime) + { + _owner = owner; + } + + public InnerViewModel(ViewModelBase owner, ApplicationContext applicationContext, bool manageObjectLifetime) + : base(applicationContext, manageObjectLifetime) + { + _owner = owner; + } + + public ApplicationContext Context => ApplicationContext; + + protected override ApplicationContext ResolveApplicationContext() => ApplicationContextManager.GetApplicationContext(); + + // status properties: the owner reads and writes through these + + public bool BaseIsDirty { get => base.IsDirty; set => base.IsDirty = value; } + public bool BaseIsValid { get => base.IsValid; set => base.IsValid = value; } + public bool BaseCanSave { get => base.CanSave; set => base.CanSave = value; } + public bool BaseCanCancel { get => base.CanCancel; set => base.CanCancel = value; } + public bool BaseCanCreate { get => base.CanCreate; set => base.CanCreate = value; } + public bool BaseCanDelete { get => base.CanDelete; set => base.CanDelete = value; } + public bool BaseCanFetch { get => base.CanFetch; set => base.CanFetch = value; } + public bool BaseCanRemove { get => base.CanRemove; set => base.CanRemove = value; } + public bool BaseCanAddNew { get => base.CanAddNew; set => base.CanAddNew = value; } + public bool BaseCanCreateObject { get => base.CanCreateObject; set => base.CanCreateObject = value; } + public bool BaseCanGetObject { get => base.CanGetObject; set => base.CanGetObject = value; } + public bool BaseCanEditObject { get => base.CanEditObject; set => base.CanEditObject = value; } + public bool BaseCanDeleteObject { get => base.CanDeleteObject; set => base.CanDeleteObject = value; } + + // status and permission reads and writes go through the owner, + // so overrides of its getters and protected setters take effect + public override bool IsDirty { get => _owner.IsDirty; protected set => _owner.IsDirty = value; } + public override bool IsValid { get => _owner.IsValid; protected set => _owner.IsValid = value; } + public override bool CanSave { get => _owner.CanSave; protected set => _owner.CanSave = value; } + public override bool CanCancel { get => _owner.CanCancel; protected set => _owner.CanCancel = value; } + public override bool CanCreate { get => _owner.CanCreate; protected set => _owner.CanCreate = value; } + public override bool CanDelete { get => _owner.CanDelete; protected set => _owner.CanDelete = value; } + public override bool CanFetch { get => _owner.CanFetch; protected set => _owner.CanFetch = value; } + public override bool CanRemove { get => _owner.CanRemove; protected set => _owner.CanRemove = value; } + public override bool CanAddNew { get => _owner.CanAddNew; protected set => _owner.CanAddNew = value; } + public override bool CanCreateObject { get => _owner.CanCreateObject; protected set => _owner.CanCreateObject = value; } + public override bool CanGetObject { get => _owner.CanGetObject; protected set => _owner.CanGetObject = value; } + public override bool CanEditObject { get => _owner.CanEditObject; protected set => _owner.CanEditObject = value; } + public override bool CanDeleteObject { get => _owner.CanDeleteObject; protected set => _owner.CanDeleteObject = value; } + + public void SetIsBusy(bool value) => IsBusy = value; + public void SetViewModelErrorText(string value) => ViewModelErrorText = value; + public string BaseModelErrorText => base.ModelErrorText; + protected override string ModelErrorText => _owner.ModelErrorText; + + public void Hook(T? model) => HookChangedEvents(model); + public void Unhook(T? model) => UnhookChangedEvents(model); + + protected override void OnPropertyChanged(string propertyName) + { + base.OnPropertyChanged(propertyName); + _owner.OnPropertyChanged(propertyName); + } + + protected override void OnModelChanging(T? oldValue, T? newValue) => _owner.OnModelChanging(oldValue, newValue); + public void BaseOnModelChanging(T? oldValue, T? newValue) => base.OnModelChanging(oldValue, newValue); + + protected override void OnModelChanged(T? oldValue, T? newValue) + { + // keep the dependency property in sync when the model + // is replaced by a refresh or save operation + if (!ReferenceEquals(_owner.GetValue(ModelProperty), newValue)) + _owner.SetValue(ModelProperty, newValue); + _owner.OnModelChanged(oldValue, newValue); + } + public void BaseOnModelChanged(T? oldValue, T? newValue) => base.OnModelChanged(oldValue, newValue); + + protected override void OnSetProperties() => _owner.OnSetProperties(); + public void BaseOnSetProperties() => base.OnSetProperties(); + + protected override void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + OnSetProperties(); + _owner.ModelPropertyChanged?.Invoke(_owner, e); + } + + protected override void OnModelChildChanged(object? sender, ChildChangedEventArgs e) + { + OnSetProperties(); + _owner.ModelChildChanged?.Invoke(_owner, e); + } + + protected override void OnModelCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + OnSetProperties(); + _owner.ModelCollectionChanged?.Invoke(_owner, e); + } + + public override Task SaveAsync(CancellationToken ct) => _owner.SaveAsync(ct); + public Task BaseSaveAsync(CancellationToken ct) => base.SaveAsync(ct); + + protected override Task DoSaveAsync(ISavable? cloned) => _owner.DoSaveAsync(cloned); + public Task BaseDoSaveAsync(ISavable? cloned) => base.DoSaveAsync(cloned); + + public void BaseDoCancel() => base.DoCancel(); + public object BaseDoAddNew() => base.DoAddNew(); + public void BaseDoRemove(object? item) => base.DoRemove(item); + public void BaseDoDelete() => base.DoDelete(); } } -} \ No newline at end of file +#endif +} diff --git a/Source/Csla/Mvvm/ViewModel.cs b/Source/Csla/Mvvm/ViewModel.cs new file mode 100644 index 0000000000..ece6376e80 --- /dev/null +++ b/Source/Csla/Mvvm/ViewModel.cs @@ -0,0 +1,1075 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Base type for creating a viewmodel that exposes a CSLA Model +//----------------------------------------------------------------------- + +using System.Collections; +using System.Collections.Specialized; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Csla.Core; +using Csla.Rules; + +namespace Csla.Mvvm +{ + /// + /// Base type for creating a viewmodel that exposes a CSLA + /// business object graph through its property. + /// Provides behaviors common to all UI frameworks: model lifetime + /// management, save/refresh/cancel, error capture, and + /// authorization-based status properties. + /// + /// Type of the Model object. + public class ViewModel<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T> : INotifyPropertyChanged + { + private ApplicationContext? _applicationContext; + + /// + /// Creates an instance of the type. + /// + /// The current ApplicationContext. + /// is . + public ViewModel(ApplicationContext applicationContext) + : this(applicationContext, false) + { } + + /// + /// Creates an instance of the type with an initial + /// value. No + /// PropertyChanged event is raised for that value. + /// + /// The current ApplicationContext. + /// Initial value of . + /// is . + protected ViewModel(ApplicationContext applicationContext, bool manageObjectLifetime) + { + _applicationContext = applicationContext ?? throw new ArgumentNullException(nameof(applicationContext)); + _manageObjectLifetime = manageObjectLifetime; + } + + /// + /// Creates an instance of the type that resolves its + /// ApplicationContext on first use by calling + /// . + /// + protected ViewModel() + { } + + /// + /// Creates an instance of the type with an initial + /// value, resolving its + /// ApplicationContext on first use by calling + /// . No + /// PropertyChanged event is raised for that value. + /// + /// Initial value of . + protected ViewModel(bool manageObjectLifetime) + { + _manageObjectLifetime = manageObjectLifetime; + } + + /// + /// Gets the current ApplicationContext instance. + /// + protected ApplicationContext ApplicationContext => _applicationContext ??= ResolveApplicationContext(); + + /// + /// Override to provide the ApplicationContext when the + /// viewmodel was created with the parameterless constructor. + /// + /// Always thrown by the default implementation. + protected virtual ApplicationContext ResolveApplicationContext() + { + throw new InvalidOperationException( + $"{GetType().Name} was created without an {nameof(Csla.ApplicationContext)}; use the constructor that accepts one, or override {nameof(ResolveApplicationContext)}."); + } + + #region Events + + /// + /// Event raised when a property changes. + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Event raised after Model has been saved. + /// + public event Action? Saved; + + /// + /// Event raised when a refresh or save operation fails, + /// or when an authorization rule used to calculate the + /// status properties throws an exception. + /// + public event EventHandler? Error; + + /// + /// Event raised when Model is changing. + /// + public event Action? ModelChanging; + + /// + /// Event raised when Model has changed. + /// + public event Action? ModelChanged; + + /// + /// Event raised when the Model object + /// raises its PropertyChanged event. + /// + public event PropertyChangedEventHandler? ModelPropertyChanged; + + /// + /// Event raised when the Model object + /// raises its ChildChanged event. + /// + public event Action? ModelChildChanged; + + /// + /// Event raised when the Model object + /// raises its CollectionChanged event. + /// + public event Action? ModelCollectionChanged; + + /// + /// Raises the PropertyChanged event. + /// + /// Name of the changed property. + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + /// + /// Invoked before the Model changes. Unhooks event handlers + /// from the old object, hooks them on the new object, and + /// raises the ModelChanging event. + /// + /// Previous Model reference. + /// New Model reference. + protected virtual void OnModelChanging(T? oldValue, T? newValue) + { + if (ReferenceEquals(oldValue, newValue)) + return; + + if (ManageObjectLifetime && newValue is ISupportUndo undo) + undo.BeginEdit(); + + // unhook events from old value + if (oldValue != null) + { + UnhookChangedEvents(oldValue); + + if (oldValue is INotifyBusy nb) + nb.BusyChanged -= OnModelBusyChanged; + } + + // hook events on new value + if (newValue != null) + { + HookChangedEvents(newValue); + + if (newValue is INotifyBusy nb) + nb.BusyChanged += OnModelBusyChanged; + } + + ModelChanging?.Invoke(oldValue, newValue); + } + + /// + /// Invoked after the Model has changed. Raises the + /// ModelChanged event. + /// + /// Previous Model reference. + /// New Model reference. + protected virtual void OnModelChanged(T? oldValue, T? newValue) + { + ModelChanged?.Invoke(); + } + + /// + /// Unhooks changed event handlers from the model. + /// + /// Model object. + protected void UnhookChangedEvents(T? model) + { + if (model is INotifyPropertyChanged npc) + npc.PropertyChanged -= OnModelPropertyChanged; + + if (model is INotifyChildChanged ncc) + ncc.ChildChanged -= OnModelChildChanged; + + if (model is INotifyCollectionChanged cc) + cc.CollectionChanged -= OnModelCollectionChanged; + } + + /// + /// Hooks changed event handlers on the model. + /// + /// Model object. + protected void HookChangedEvents(T? model) + { + if (model is INotifyPropertyChanged npc) + npc.PropertyChanged += OnModelPropertyChanged; + + if (model is INotifyChildChanged ncc) + ncc.ChildChanged += OnModelChildChanged; + + if (model is INotifyCollectionChanged cc) + cc.CollectionChanged += OnModelCollectionChanged; + } + + private void OnModelBusyChanged(object sender, BusyChangedEventArgs e) + { + // only set busy state for entire object. Ignore busy state based + // on async rules being active + if (string.IsNullOrEmpty(e.PropertyName)) + SetBusy(ref _isModelBusy, e.Busy); + else + OnSetProperties(); + } + + /// + /// Invoked when the Model raises PropertyChanged; + /// raises the ModelPropertyChanged event. + /// + /// Event sender. + /// Event arguments. + protected virtual void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + OnSetProperties(); + ModelPropertyChanged?.Invoke(this, e); + } + + /// + /// Invoked when the Model raises ChildChanged; + /// raises the ModelChildChanged event. + /// + /// Event sender. + /// Event arguments. + protected virtual void OnModelChildChanged(object? sender, ChildChangedEventArgs e) + { + OnSetProperties(); + ModelChildChanged?.Invoke(this, e); + } + + /// + /// Invoked when the Model raises CollectionChanged; + /// raises the ModelCollectionChanged event. + /// + /// Event sender. + /// Event arguments. + protected virtual void OnModelCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + OnSetProperties(); + ModelCollectionChanged?.Invoke(this, e); + } + + #endregion + + #region Model and status properties + + private T? _model; + /// + /// Gets or sets the Model object. + /// + public T? Model + { + get => _model; + set + { + if (!ReferenceEquals(_model, value)) + { + var oldValue = _model; + OnModelChanging(oldValue, value); + _model = value; + OnModelChanged(oldValue, value); + OnPropertyChanged(nameof(Model)); + // the busy state reported by the old model no longer applies + if (!SetBusy(ref _isModelBusy, false)) + OnSetProperties(); + } + } + } + + private bool _manageObjectLifetime; + /// + /// Gets or sets a value indicating whether the + /// viewmodel should manage the lifetime of + /// the business object, including using n-level + /// undo. + /// + [Display(AutoGenerateField = false)] + [ScaffoldColumn(false)] + public bool ManageObjectLifetime + { + get => _manageObjectLifetime; + set + { + if (_manageObjectLifetime != value) + { + _manageObjectLifetime = value; + OnPropertyChanged(nameof(ManageObjectLifetime)); + } + } + } + + /// + /// Gets or sets a value specifying the timeout + /// when calling SaveAsync and the Model is + /// currently busy with async rules. + /// + public TimeSpan BusyTimeout { get; set; } = TimeSpan.FromSeconds(30); + + private bool _isBusy; + private bool _isModelBusy; + /// + /// Gets a value indicating whether this object is + /// executing an asynchronous process, or the Model + /// reports that it is busy. + /// + public bool IsBusy + { + get => _isBusy || _isModelBusy; + protected set => SetBusy(ref _isBusy, value); + } + + // the viewmodel's own operations and the Model's BusyChanged event + // have separate flags, so the Model becoming idle part way through + // a save does not clear the viewmodel's busy state + private bool SetBusy(ref bool field, bool value) + { + if (field == value) + return false; + var wasBusy = IsBusy; + field = value; + if (wasBusy == IsBusy) + return false; + OnPropertyChanged(nameof(IsBusy)); + OnSetProperties(); + return true; + } + + private bool _isDirty; + /// + /// Gets a value indicating whether the Model + /// has been changed. + /// + public virtual bool IsDirty + { + get => _isDirty; + protected set => SetStatus(ref _isDirty, value, nameof(IsDirty)); + } + + private bool _isValid; + /// + /// Gets a value indicating whether the Model + /// is currently valid (has no broken rules). + /// + public virtual bool IsValid + { + get => _isValid; + protected set => SetStatus(ref _isValid, value, nameof(IsValid)); + } + + private bool _canSave; + /// + /// Gets a value indicating whether the Model + /// can currently be saved. + /// + public virtual bool CanSave + { + get => _canSave; + protected set => SetStatus(ref _canSave, value, nameof(CanSave)); + } + + private bool _canCancel; + /// + /// Gets a value indicating whether the Model + /// can currently be canceled. + /// + public virtual bool CanCancel + { + get => _canCancel; + protected set => SetStatus(ref _canCancel, value, nameof(CanCancel)); + } + + private bool _canCreate; + /// + /// Gets a value indicating whether an instance + /// of the Model can currently be created. + /// + public virtual bool CanCreate + { + get => _canCreate; + protected set => SetStatus(ref _canCreate, value, nameof(CanCreate)); + } + + private bool _canDelete; + /// + /// Gets a value indicating whether the Model + /// can currently be deleted. + /// + public virtual bool CanDelete + { + get => _canDelete; + protected set => SetStatus(ref _canDelete, value, nameof(CanDelete)); + } + + private bool _canFetch; + /// + /// Gets a value indicating whether an instance + /// of the Model can currently be retrieved. + /// + public virtual bool CanFetch + { + get => _canFetch; + protected set => SetStatus(ref _canFetch, value, nameof(CanFetch)); + } + + private bool _canRemove; + /// + /// Gets a value indicating whether an item + /// can currently be removed from the Model. + /// + public virtual bool CanRemove + { + get => _canRemove; + protected set => SetStatus(ref _canRemove, value, nameof(CanRemove)); + } + + private bool _canAddNew; + /// + /// Gets a value indicating whether an item + /// can currently be added to the Model. + /// + public virtual bool CanAddNew + { + get => _canAddNew; + protected set => SetStatus(ref _canAddNew, value, nameof(CanAddNew)); + } + + private void SetStatus(ref bool field, bool value, string propertyName) + { + if (field != value) + { + field = value; + OnPropertyChanged(propertyName); + } + } + + #endregion + + #region Status calculation + + private int _setPropertiesVersion; + + /// + /// Override this method to hook into to logic of setting + /// properties when model is changed or edited. + /// + /// + /// Authorization rules are evaluated with + /// . + /// When a rule is asynchronous, the permission-based properties + /// (such as CanDelete or CanAddNew) may be updated after this + /// method returns; PropertyChanged is raised when they change. + /// If a rule throws, the exception is reported through + /// , and + /// , and the status properties keep their + /// previous values. + /// + protected virtual void OnSetProperties() + { + SetProperties(); + } + + private void SetProperties() + { + // resolve the context first, so a missing ApplicationContext + // throws to the caller instead of being reported as a rule failure + _ = ApplicationContext; + ObserveTask(SetPropertiesAsync()); + } + + private bool _statusCalculationFailed; + + private async Task SetPropertiesAsync() + { + try + { + if (await CalculateStatusAsync()) + _statusCalculationFailed = false; + } + catch (Exception ex) + { + // the calculation runs on every model change, so a rule that keeps + // failing is reported when it starts failing, not on every change + if (!_statusCalculationFailed) + { + _statusCalculationFailed = true; + ReportError(ex); + } + } + } + + // returns false if a newer calculation superseded this one + private async Task CalculateStatusAsync() + { + var version = ++_setPropertiesVersion; + var model = Model; + + // evaluate authorization rules first; when all rules are synchronous + // these tasks are already complete and no thread switch occurs + var canDeleteInstance = false; + var canRemoveItem = false; + var canAddNewItem = false; + Type? itemType = null; + if (model is ITrackStatus targetObject) + canDeleteInstance = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.DeleteObject, targetObject, CancellationToken.None); + if (model is ICollection) + { + itemType = GetChildItemType(model.GetType()); + if (itemType != null) + { + canRemoveItem = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.DeleteObject, itemType, CancellationToken.None); + canAddNewItem = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.CreateObject, itemType, CancellationToken.None); + } + } + + // a newer call has started (or the model changed) while awaiting + if (version != _setPropertiesVersion || !ReferenceEquals(model, Model)) + return false; + + // the viewmodel is busy while it saves a clone of the model, + // even though the model itself is idle + var isObjectBusy = IsBusy || (Model is INotifyBusy busyObject && busyObject.IsBusy); + + if (Model is ITrackStatus trackStatus) + { + IsDirty = trackStatus.IsDirty; + IsValid = trackStatus.IsValid; + CanSave = CanEditObject && trackStatus.IsSavable && !isObjectBusy; + CanCancel = CanEditObject && trackStatus.IsDirty && !isObjectBusy; + CanCreate = CanCreateObject && !trackStatus.IsDirty && !isObjectBusy; + CanDelete = CanDeleteObject && !isObjectBusy && canDeleteInstance; + CanFetch = CanGetObject && !trackStatus.IsDirty && !isObjectBusy; + SetListProperties(itemType, canRemoveItem, canAddNewItem, isObjectBusy); + } + else if (Model is ICollection) + { + SetListProperties(itemType, canRemoveItem, canAddNewItem, isObjectBusy); + } + else + { + IsDirty = false; + IsValid = false; + CanCancel = false; + CanCreate = CanCreateObject; + CanDelete = false; + CanFetch = CanGetObject && !IsBusy; + CanSave = false; + CanRemove = false; + CanAddNew = false; + } + return true; + } + + private Type? _childItemTypeSource; + private Type? _childItemType; + + // the status calculation runs on every model change, so cache + // the reflection-based lookup for the current Model type + private Type? GetChildItemType(Type modelType) + { + if (modelType != _childItemTypeSource) + { + _childItemType = Utilities.GetChildItemType(modelType); + _childItemTypeSource = modelType; + } + return _childItemType; + } + + private void SetListProperties(Type? itemType, bool canRemoveItem, bool canAddNewItem, bool isObjectBusy) + { + if (Model is ICollection list && itemType != null) + { + CanRemove = canRemoveItem && list.Count > 0 && !isObjectBusy; + CanAddNew = canAddNewItem && !isObjectBusy; + } + else + { + CanRemove = false; + CanAddNew = false; + } + } + + #endregion + + #region Object-level permissions + + private bool _canCreateObject; + /// + /// Gets a value indicating whether the current user + /// is authorized to create an instance of the + /// business domain type. + /// + public virtual bool CanCreateObject + { + get + { + SetPropertiesAtObjectLevel(); + return _canCreateObject; + } + protected set => SetStatus(ref _canCreateObject, value, nameof(CanCreateObject)); + } + + private bool _canGetObject; + /// + /// Gets a value indicating whether the current user + /// is authorized to retrieve an instance of the + /// business domain type. + /// + public virtual bool CanGetObject + { + get + { + SetPropertiesAtObjectLevel(); + return _canGetObject; + } + protected set => SetStatus(ref _canGetObject, value, nameof(CanGetObject)); + } + + private bool _canEditObject; + /// + /// Gets a value indicating whether the current user + /// is authorized to edit/save an instance of the + /// business domain type. + /// + public virtual bool CanEditObject + { + get + { + SetPropertiesAtObjectLevel(); + return _canEditObject; + } + protected set => SetStatus(ref _canEditObject, value, nameof(CanEditObject)); + } + + private bool _canDeleteObject; + /// + /// Gets a value indicating whether the current user + /// is authorized to delete an instance of the + /// business domain type. + /// + public virtual bool CanDeleteObject + { + get + { + SetPropertiesAtObjectLevel(); + return _canDeleteObject; + } + protected set => SetStatus(ref _canDeleteObject, value, nameof(CanDeleteObject)); + } + + private bool _objectLevelPermissionsRequested; + private Task? _objectLevelPermissionsTask; + + /// + /// Sets the properties at object level, evaluating + /// authorization rules only once. + /// + /// + /// When all authorization rules are synchronous the values are + /// set before this method returns. If a rule is asynchronous the + /// values are set when it completes, and PropertyChanged is raised. + /// + private void SetPropertiesAtObjectLevel() + { + if (_objectLevelPermissionsRequested) + return; + ObserveTask(EnsureObjectLevelPermissionsAsync(retryFailed: false)); + } + + private async void ObserveTask(Task task) + { + // a failure is reported through Exception and Error rather than + // posted to the SynchronizationContext, so it behaves the same on + // every host. When the task has already completed (all rules are + // synchronous) this runs before the caller continues. + try + { + await task; + } + catch (Exception ex) + { + ReportError(ex); + } + } + + // retryFailed starts a new evaluation if the previous one failed. Only + // explicit operations retry: property reads do not, so a rule that keeps + // failing is not re-evaluated (and reported) on every read. + private Task EnsureObjectLevelPermissionsAsync(bool retryFailed) + { + if (!_objectLevelPermissionsRequested || (retryFailed && _objectLevelPermissionsTask is { IsFaulted: true })) + { + // resolve the context first, so a missing ApplicationContext + // throws to the caller instead of being reported as a rule failure + var applicationContext = ApplicationContext; + // set the flag first: the property setters raise PropertyChanged, + // and handlers may read the permission properties re-entrantly + _objectLevelPermissionsRequested = true; + _objectLevelPermissionsTask = LoadObjectLevelPermissionsAsync(applicationContext); + } + return _objectLevelPermissionsTask ?? Task.CompletedTask; + } + + private async Task LoadObjectLevelPermissionsAsync(ApplicationContext applicationContext) + { + var sourceType = typeof(T); + + CanCreateObject = await BusinessRules.HasPermissionAsync(applicationContext, AuthorizationActions.CreateObject, sourceType, CancellationToken.None); + CanGetObject = await BusinessRules.HasPermissionAsync(applicationContext, AuthorizationActions.GetObject, sourceType, CancellationToken.None); + CanEditObject = await BusinessRules.HasPermissionAsync(applicationContext, AuthorizationActions.EditObject, sourceType, CancellationToken.None); + CanDeleteObject = await BusinessRules.HasPermissionAsync(applicationContext, AuthorizationActions.DeleteObject, sourceType, CancellationToken.None); + + // set "instance" values now that object-level values are known + OnSetProperties(); + } + + #endregion + + #region Errors + + /// + /// Gets any error text generated by refresh or save operations, + /// or by the authorization rules used to calculate the status + /// properties. + /// + public string ViewModelErrorText { get; protected set; } = string.Empty; + + /// + /// Gets the first validation error + /// message from the Model. + /// + protected virtual string ModelErrorText + { + get + { + if (Model is IDataErrorInfo obj) + return obj.Error; + return string.Empty; + } + } + + /// + /// Gets the last exception caught by + /// the viewmodel during refresh or save + /// operations, or while evaluating the + /// authorization rules used to calculate + /// the status properties. + /// + public Exception? Exception { get; private set; } + + private void SetError(Exception? exception, string errorText) + { + Exception = exception; + ViewModelErrorText = errorText; + OnPropertyChanged(nameof(Exception)); + OnPropertyChanged(nameof(ViewModelErrorText)); + } + + private void ReportError(Exception exception) + { + // a failed permission load can be observed by both a property + // read and RefreshAsync; report it once + if (ReferenceEquals(Exception, exception)) + return; + + var errorText = exception is DataPortalException dpe ? dpe.BusinessExceptionMessage : exception.Message; + SetError(exception, errorText); + Error?.Invoke(this, new Core.ErrorEventArgs(this, exception)); + } + + #endregion + + #region Operations + + /// + /// Creates or retrieves an instance of the Model by + /// invoking a factory. Exceptions are captured in + /// and , + /// and the event is raised. + /// + /// Async data portal or factory method. + /// is . + public virtual async Task RefreshAsync(Func> factory) + { + if (factory is null) + throw new ArgumentNullException(nameof(factory)); + + SetError(null, string.Empty); + try + { + // start (or retry) the permission load before IsBusy triggers + // the status calculation, which reads the permissions + var permissions = EnsureObjectLevelPermissionsAsync(retryFailed: true); + IsBusy = true; + await permissions; + Model = await factory(); + } + catch (Exception ex) + { + Model = default; + ReportError(ex); + } + finally + { + IsBusy = false; + } + + return Model; + } + + private CancellationToken _busyTimeoutToken; + + /// + /// Saves the Model, waiting up to + /// for the Model to become idle. Exceptions are captured in + /// and , + /// and the event is raised. If the Model + /// is still busy after , the exception + /// is a . + /// + public virtual async Task SaveAsync() + { + using var cts = BusyTimeout.ToCancellationTokenSource(); + // lets SaveAsync(CancellationToken) tell the timeout apart from + // a caller's cancellation, without bypassing an override + _busyTimeoutToken = cts.Token; + try + { + return await SaveAsync(cts.Token); + } + finally + { + _busyTimeoutToken = default; + } + } + + /// + /// Saves the Model. If ManageObjectLifetime is true, a clone + /// of the Model with its changes committed is saved, and the + /// Model's changes are committed only if the save succeeds, so + /// can still restore the original values + /// after a failed save. Exceptions are captured in + /// and , + /// and the event is raised. + /// + /// The cancellation token. + public virtual async Task SaveAsync(CancellationToken ct) + { + SetError(null, string.Empty); + var model = Model; + if (model is null) + return model; + + Exception? error = null; + try + { + if (model is ITrackStatus obj && !obj.IsSavable) + { + await WaitForIdleAsync(obj, ct); + if (!obj.IsValid) + { + SetError(null, ModelErrorText); + return Model; + } + else if (!obj.IsSavable) + throw new InvalidOperationException( + $"{obj.GetType().Name} IsBusy: {obj.IsBusy}, IsValid: {obj.IsValid}, IsSavable: {obj.IsSavable}"); + } + + UnhookChangedEvents(model); + + var savable = model as ISavable; + if (ManageObjectLifetime) + { + if (savable is ICloneable clonable) + { + // save a clone with its changes committed; the Model keeps + // its snapshot until the save succeeds + savable = (ISavable)clonable.Clone(); + if (savable is ISupportUndo clonedUndo) + { + clonedUndo.ApplyEdit(); + _pendingApplyEdit = model as ISupportUndo; + } + } + else if (model is ISupportUndo undoable) + { + undoable.ApplyEdit(); + } + } + + IsBusy = true; + Model = await DoSaveAsync(savable); + // an override may have kept the Model without committing it + if (ReferenceEquals(Model, model)) + CommitPendingEdit(); + Saved?.Invoke(); + } + catch (Exception ex) + { + error = ex; + } + finally + { + // after a failed save the Model keeps its snapshot, so Cancel + // restores the values from before the edit + _pendingApplyEdit = null; + if (ManageObjectLifetime && Model is IUndoableObject udbl && udbl.EditLevel == 0 && Model is ISupportUndo undo) + undo.BeginEdit(); + + // handlers may already be attached: the save may have returned + // before unhooking, or replaced Model (which hooks the new model) + UnhookChangedEvents(Model); + HookChangedEvents(Model); + IsBusy = false; + OnSetProperties(); + if (error != null) + ReportError(error); + } + return Model; + } + + private async Task WaitForIdleAsync(ITrackStatus obj, CancellationToken ct) + { + try + { + if (obj is INotifyBusy notifyBusy) + { + await BusyHelper.WaitForIdle(notifyBusy, ct); + } + else + { + while (obj.IsBusy) + await Task.Delay(1, ct); + } + } + catch (OperationCanceledException ex) when (ct.IsCancellationRequested && ct == _busyTimeoutToken) + { + throw new TimeoutException($"{obj.GetType().FullName}.{nameof(SaveAsync)} - {BusyTimeout}.", ex); + } + } + + private ISupportUndo? _pendingApplyEdit; + + // commits the Model's changes once the save of its clone has succeeded + private void CommitPendingEdit() + { + var pending = _pendingApplyEdit; + _pendingApplyEdit = null; + pending?.ApplyEdit(); + } + + /// + /// Override to provide custom Model save behavior. + /// + /// The object to save (a clone of Model if ManageObjectLifetime is true). + /// is because the Model does not implement . + protected virtual async Task DoSaveAsync(ISavable? cloned) + { + if (cloned is null) + throw new InvalidOperationException($"{typeof(T).Name} does not implement {nameof(ISavable)}."); + + var saved = (T)await cloned.SaveAsync(); + // the save succeeded: commit the Model's changes before merging + // so it doesn't keep the pre-save snapshot + CommitPendingEdit(); + if (Model is IEditableBusinessObject editable) + await new GraphMerger(ApplicationContext).MergeGraphAsync(editable, (IEditableBusinessObject)saved); + else + Model = saved; + return Model; + } + + /// + /// Cancels changes made to the model + /// if ManageObjectLifetime is true. + /// + protected virtual void DoCancel() + { + if (ManageObjectLifetime && Model is ISupportUndo undo) + { + UnhookChangedEvents(Model); + try + { + undo.CancelEdit(); + undo.BeginEdit(); + } + finally + { + HookChangedEvents(Model); + OnSetProperties(); + } + } + } + + /// + /// Adds a new item to the Model (if it + /// is a collection). + /// + /// The new item. + protected virtual object DoAddNew() + { + object result; + if (Model is IObservableBindingList iobl) + result = iobl.AddNew(); + else if (Model is IBindingList ibl) + result = ibl.AddNew()!; + else + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, Properties.Resources.UnsupportedXamlListTypeForModel, nameof(IObservableBindingList), nameof(IBindingList))); + + OnSetProperties(); + return result; + } + + /// + /// Removes an item from the Model (if it + /// is a collection). + /// + /// Item to remove. + protected virtual void DoRemove(object? item) + { + ThrowIfModelIsNull(); + ((IList)Model).Remove(item); + OnSetProperties(); + } + + /// + /// Marks the Model for deletion (if it is an + /// editable root object). + /// + protected virtual void DoDelete() + { + ThrowIfModelIsNull(); + ((IEditableBusinessObject)Model).Delete(); + } + + [StackTraceHidden, MemberNotNull(nameof(Model))] + private void ThrowIfModelIsNull() + { + _ = Model ?? throw new InvalidOperationException($"{nameof(Model)} == null"); + } + + #endregion + } +} diff --git a/Source/tests/Csla.Blazor.Test/ViewModelSaveAsyncErrorTests.cs b/Source/tests/Csla.Blazor.Test/ViewModelSaveAsyncErrorTests.cs index 9dbad6f7eb..50034a1e1c 100644 --- a/Source/tests/Csla.Blazor.Test/ViewModelSaveAsyncErrorTests.cs +++ b/Source/tests/Csla.Blazor.Test/ViewModelSaveAsyncErrorTests.cs @@ -218,7 +218,7 @@ public async Task SavingFailure_ErrorTimeOut() await vm.SaveAsync(); Assert.IsNotNull(vm.Exception); - Assert.AreEqual(vm.Exception.Message, "The operation was canceled."); + Assert.IsInstanceOfType(vm.Exception); } #region Helper Methods diff --git a/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs b/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs new file mode 100644 index 0000000000..cfe25c1658 --- /dev/null +++ b/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs @@ -0,0 +1,212 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Tests for the WPF ViewModelBase wrapper over Csla.Mvvm.ViewModel +//----------------------------------------------------------------------- + +using Csla.Configuration; +using Csla.Core; +using Csla.Testing; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Csla.Xaml.Wpf.Tests +{ + [TestClass] + public class ViewModelBaseTests + { + private static CslaTestHost _testHost; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _testHost = CslaTestHost.Create(o => o.ConfigureCsla(c => c.AddXaml())); + } + + [ClassCleanup] + public static void ClassCleanup() + { + _testHost?.Dispose(); + } + + private class ReplacingViewModel : ViewModel + { + public SavableRoot Replacement { get; set; } + + protected override Task DoSaveAsync(ISavable cloned) => Task.FromResult(Replacement); + } + + private class TrackingSetterViewModel : ViewModel + { + public List CanSaveValues { get; } = []; + + public override bool CanSave + { + get => base.CanSave; + protected set + { + CanSaveValues.Add(value); + base.CanSave = value; + } + } + } + + private class DenyEditViewModel : ViewModel + { + public override bool CanEditObject => false; + } + + // a common MVVM shape: state created in the constructor, used from OnPropertyChanged + private class CommandRefreshingViewModel : ViewModel + { + private readonly List _refreshed; + + public CommandRefreshingViewModel() + { + _refreshed = []; + } + + protected override void OnPropertyChanged(string propertyName) + { + base.OnPropertyChanged(propertyName); + _refreshed.Add(propertyName); // null during base construction + } + } + + [TestMethod] + public void DefaultsToManagingObjectLifetime() + { + DispatcherContext.Run(async () => + { + var model = await _testHost.GetDataPortal().CreateAsync(); + var vm = new ViewModel(); + + vm.Model = model; + + Assert.IsTrue(vm.ManageObjectLifetime); + Assert.AreEqual(1, ((IUndoableObject)model).EditLevel); + }); + } + + [TestMethod] + public void SettingModelDependencyProperty_HooksModel() + { + DispatcherContext.Run(async () => + { + var model = await _testHost.GetDataPortal().CreateAsync(); + var vm = new ViewModel(); + object sender = null; + vm.ModelPropertyChanged += (s, _) => sender = s; + + vm.SetValue(ViewModelBase.ModelProperty, model); + model.Name = "Changed"; + + Assert.AreSame(vm, sender); + Assert.IsTrue(vm.IsDirty); + }); + } + + [TestMethod] + public void SaveAsync_ReplacedModel_UpdatesDependencyProperty() + { + DispatcherContext.Run(async () => + { + var model = await _testHost.GetDataPortal().CreateAsync(); + var replacement = await _testHost.GetDataPortal().CreateAsync(); + var vm = new ReplacingViewModel { Model = model, Replacement = replacement }; + model.Name = "Valid"; + + await vm.SaveAsync(); + + Assert.IsNull(vm.Exception); + Assert.AreSame(replacement, vm.GetValue(ViewModelBase.ModelProperty)); + Assert.AreSame(replacement, vm.Model); + }); + } + + [TestMethod] + public void SaveAsync_Failure_CapturesExceptionWithViewModelAsSender() + { + DispatcherContext.Run(async () => + { + var model = await _testHost.GetDataPortal().CreateAsync(); + var vm = new ViewModel { Model = model }; + object errorSender = null; + vm.Error += (s, _) => errorSender = s; + model.Name = SavableRoot.FailOnSave; + + await vm.SaveAsync(); + + Assert.IsNotNull(vm.Exception); + Assert.AreNotEqual(string.Empty, vm.ViewModelErrorText); + Assert.AreSame(vm, errorSender); + }); + } + + [TestMethod] + public void OverriddenStatusSetter_IsCalledByStatusCalculation() + { + DispatcherContext.Run(async () => + { + var model = await _testHost.GetDataPortal().CreateAsync(); + var vm = new TrackingSetterViewModel { Model = model }; + + model.Name = "Valid"; + + Assert.IsTrue(vm.CanSave); + CollectionAssert.Contains(vm.CanSaveValues, true); + }); + } + + [TestMethod] + public void OverriddenObjectPermission_IsUsedForStatus() + { + DispatcherContext.Run(async () => + { + var model = await _testHost.GetDataPortal().CreateAsync(); + var vm = new DenyEditViewModel { Model = model }; + + model.Name = "Valid"; + + Assert.IsTrue(model.IsSavable); + Assert.IsFalse(vm.CanSave); + }); + } + + [TestMethod] + public void Construction_DoesNotCallOnPropertyChangedEarly() + { + DispatcherContext.Run(async () => + { + await Task.Yield(); + _ = new CommandRefreshingViewModel(); // throws NullReferenceException today + }); + } + } + + [Serializable] + public class SavableRoot : BusinessBase + { + public const string FailOnSave = "fail"; + + public static readonly PropertyInfo NameProperty = RegisterProperty(nameof(Name)); + public string Name + { + get => GetProperty(NameProperty); + set => SetProperty(NameProperty, value); + } + + [Create] + private void Create() + { } + + [Insert] + [Update] + private void InsertOrUpdate() + { + if (Name == FailOnSave) + throw new InvalidOperationException("Save failed"); + } + } +} diff --git a/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs b/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs new file mode 100644 index 0000000000..5e1ab6be32 --- /dev/null +++ b/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs @@ -0,0 +1,246 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Business types used by the Csla.Mvvm tests +//----------------------------------------------------------------------- + +using System.ComponentModel; +using Csla.Core; +using Csla.Rules; +using Csla.Rules.CommonRules; + +namespace Csla.Test.Mvvm +{ + public class TestViewModel : Csla.Mvvm.ViewModel + { + public TestViewModel(ApplicationContext applicationContext) + : base(applicationContext) + { } + + public void Cancel() => DoCancel(); + public object AddNew() => DoAddNew(); + public void Remove(object item) => DoRemove(item); + public void Delete() => DoDelete(); + + public Func> SaveOverride { get; set; } + + protected override Task DoSaveAsync(ISavable cloned) + => SaveOverride is null ? base.DoSaveAsync(cloned) : SaveOverride(cloned); + } + + public class NoContextViewModel : Csla.Mvvm.ViewModel + { + } + + [Serializable] + public class MvvmRoot : BusinessBase + { + public const string FailOnSave = "fail"; + + public static readonly PropertyInfo NameProperty = RegisterProperty(nameof(Name)); + public string Name + { + get => GetProperty(NameProperty); + set => SetProperty(NameProperty, value); + } + + protected override void AddBusinessRules() + { + base.AddBusinessRules(); + BusinessRules.AddRule(new Required(NameProperty)); + } + + [Create] + private void Create() + { + BusinessRules.CheckRules(); + } + + [Fetch] + private void Fetch(int id) + { + if (id < 0) + throw new ArgumentOutOfRangeException(nameof(id)); + using (BypassPropertyChecks) + Name = $"Fetched {id}"; + BusinessRules.CheckRules(); + } + + [Insert] + [Update] + private void InsertOrUpdate() + { + if (Name == FailOnSave) + throw new InvalidOperationException("Save failed"); + } + + [DeleteSelf] + private void DeleteSelf() + { } + } + + [Serializable] + public class BusyRoot : BusinessBase + { + public void StayBusy() => MarkBusy(); + + [Create] + private void Create() + { } + } + + [Serializable] + public class MvvmChild : BusinessBase + { + public static readonly PropertyInfo NameProperty = RegisterProperty(nameof(Name)); + public string Name + { + get => GetProperty(NameProperty); + set => SetProperty(NameProperty, value); + } + + [CreateChild] + private void Create() + { } + } + + [Serializable] + public class MvvmList : BusinessListBase + { + [Create] + private void Create() + { } + } + + public class SyncAuthRule : AuthorizationRule + { + private readonly bool _hasPermission; + + public SyncAuthRule(AuthorizationActions action, bool hasPermission) + : base(action) + { + _hasPermission = hasPermission; + } + + protected override void Execute(IAuthorizationContext context) + { + context.HasPermission = _hasPermission; + } + } + + /// + /// Throws on its first evaluation only, to simulate a transient failure. + /// + public class FlakyAuthRule : AuthorizationRule + { + public static int Calls; + + public FlakyAuthRule(AuthorizationActions action) + : base(action) + { } + + protected override void Execute(IAuthorizationContext context) + { + if (Interlocked.Increment(ref Calls) == 1) + throw new InvalidOperationException("transient failure in an authorization rule"); + context.HasPermission = true; + } + } + + [Serializable] + public class FlakyAuthRoot : BusinessBase + { + [EditorBrowsable(EditorBrowsableState.Never)] + public static void AddObjectAuthorizationRules() + { + BusinessRules.AddRule(typeof(FlakyAuthRoot), new FlakyAuthRule(AuthorizationActions.CreateObject)); + } + + [Fetch] + private void Fetch(int id) + { } + } + + public class ThrowingAuthRule : AuthorizationRule + { + public ThrowingAuthRule(AuthorizationActions action) + : base(action) + { } + + protected override void Execute(IAuthorizationContext context) + { + throw new InvalidOperationException("failure in an authorization rule"); + } + } + + [Serializable] + public class ThrowingAuthRoot : BusinessBase + { + [EditorBrowsable(EditorBrowsableState.Never)] + public static void AddObjectAuthorizationRules() + { + BusinessRules.AddRule(typeof(ThrowingAuthRoot), new ThrowingAuthRule(AuthorizationActions.EditObject)); + } + + [Create] + private void Create() + { } + } + + /// + /// Throws for instance checks only, so the object-level + /// permission load succeeds. + /// + public class InstanceOnlyFailRule : AuthorizationRule + { + public static bool Failing = true; + + public InstanceOnlyFailRule(AuthorizationActions action) + : base(action) + { } + + protected override void Execute(IAuthorizationContext context) + { + if (Failing && context.Target != null) + throw new InvalidOperationException("instance rule keeps failing"); + context.HasPermission = true; + } + } + + [Serializable] + public class InstanceFailRoot : BusinessBase + { + public static readonly PropertyInfo NameProperty = RegisterProperty(nameof(Name)); + public string Name + { + get => GetProperty(NameProperty); + set => SetProperty(NameProperty, value); + } + + [EditorBrowsable(EditorBrowsableState.Never)] + public static void AddObjectAuthorizationRules() + { + BusinessRules.AddRule(typeof(InstanceFailRoot), new InstanceOnlyFailRule(AuthorizationActions.DeleteObject)); + } + + [Create] + private void Create() + { } + } + + [Serializable] + public class MvvmDenyEditRoot : BusinessBase + { + [EditorBrowsable(EditorBrowsableState.Never)] + public static void AddObjectAuthorizationRules() + { + BusinessRules.AddRule(typeof(MvvmDenyEditRoot), new SyncAuthRule(AuthorizationActions.EditObject, false)); + } + + [Create] + private void Create() + { } + } +} diff --git a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs new file mode 100644 index 0000000000..d0c46fd9fd --- /dev/null +++ b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs @@ -0,0 +1,507 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Tests for Csla.Mvvm.ViewModel +//----------------------------------------------------------------------- + +using System.ComponentModel; +using Csla.Core; +using Csla.Testing; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Csla.Test.Mvvm +{ + [TestClass] + public class ViewModelTests + { + private static CslaTestHost _testHost; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _testHost = CslaTestHost.Create(); + } + + [ClassCleanup] + public static void ClassCleanup() + { + _testHost?.Dispose(); + } + + private static TestViewModel CreateViewModel() => new(_testHost.ApplicationContext); + + private static Task CreateRootAsync() => _testHost.GetDataPortal().CreateAsync(); + + [TestMethod] + public void ParameterlessConstructor_WithoutContext_Throws() + { + var vm = new NoContextViewModel(); + + var act = () => vm.CanEditObject; + + act.Should().Throw(); + } + + [TestMethod] + public async Task ParameterlessConstructor_WithoutContext_SettingModelThrows() + { + var root = await CreateRootAsync(); + var vm = new NoContextViewModel(); + + var act = () => vm.Model = root; + + act.Should().Throw(); + } + + [TestMethod] + public async Task SettingModel_SetsStatusAndRaisesPropertyChanged() + { + var root = await CreateRootAsync(); + var vm = CreateViewModel(); + var changed = new List(); + vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + vm.Model = root; + + changed.Should().Contain(nameof(vm.Model)); + vm.IsValid.Should().BeFalse(); + vm.CanSave.Should().BeFalse(); + + root.Name = "Valid"; + + vm.IsValid.Should().BeTrue(); + vm.CanSave.Should().BeTrue(); + changed.Should().Contain(nameof(vm.CanSave)); + } + + [TestMethod] + public async Task ModelPropertyChanged_IsRaisedWithViewModelAsSender() + { + var root = await CreateRootAsync(); + var vm = CreateViewModel(); + vm.Model = root; + object sender = null; + vm.ModelPropertyChanged += (s, _) => sender = s; + + root.Name = "Changed"; + + sender.Should().BeSameAs(vm); + } + + [TestMethod] + public async Task ReplacingModel_UnhooksOldModel() + { + var oldRoot = await CreateRootAsync(); + var vm = CreateViewModel(); + vm.Model = oldRoot; + vm.Model = await CreateRootAsync(); + var raised = false; + vm.ModelPropertyChanged += (_, _) => raised = true; + + oldRoot.Name = "Changed"; + + raised.Should().BeFalse(); + } + + [TestMethod] + public async Task SaveAsync_Success_RaisesSavedAndKeepsInstance() + { + var root = await CreateRootAsync(); + root.Name = "Valid"; + var vm = CreateViewModel(); + vm.Model = root; + var saved = false; + vm.Saved += () => saved = true; + + var result = await vm.SaveAsync(); + + saved.Should().BeTrue(); + vm.Exception.Should().BeNull(); + vm.ViewModelErrorText.Should().BeEmpty(); + result.Should().BeSameAs(root); + root.IsNew.Should().BeFalse(); + } + + [TestMethod] + public async Task SaveAsync_Failure_CapturesExceptionAndRaisesError() + { + var root = await CreateRootAsync(); + root.Name = MvvmRoot.FailOnSave; + var vm = CreateViewModel(); + vm.Model = root; + object errorSender = null; + vm.Error += (s, _) => errorSender = s; + + await vm.SaveAsync(); + + vm.Exception.Should().NotBeNull(); + vm.ViewModelErrorText.Should().NotBeEmpty(); + errorSender.Should().BeSameAs(vm); + vm.IsBusy.Should().BeFalse(); + } + + [TestMethod] + public async Task SaveAsync_InvalidModel_SetsErrorTextWithoutSaving() + { + var root = await CreateRootAsync(); + var vm = CreateViewModel(); + vm.Model = root; + var saved = false; + vm.Saved += () => saved = true; + + await vm.SaveAsync(); + + saved.Should().BeFalse(); + vm.Exception.Should().BeNull(); + vm.ViewModelErrorText.Should().NotBeEmpty(); + root.IsNew.Should().BeTrue(); + } + + private static int CountNameChanges(TestViewModel vm, Action change) + { + var count = 0; + PropertyChangedEventHandler handler = (_, e) => + { + if (e.PropertyName == nameof(MvvmRoot.Name)) + count++; + }; + vm.ModelPropertyChanged += handler; + change(); + vm.ModelPropertyChanged -= handler; + return count; + } + + [TestMethod] + public async Task SaveAsync_InvalidModel_DoesNotDuplicateHandlers() + { + var root = await CreateRootAsync(); + var vm = CreateViewModel(); + vm.Model = root; + + await vm.SaveAsync(); + + CountNameChanges(vm, () => root.Name = "Changed").Should().Be(1); + } + + [TestMethod] + public async Task SaveAsync_ReplacedModel_HooksNewModelOnce() + { + var root = await CreateRootAsync(); + root.Name = "Valid"; + var replacement = await CreateRootAsync(); + var vm = CreateViewModel(); + vm.SaveOverride = _ => Task.FromResult(replacement); + vm.Model = root; + + await vm.SaveAsync(); + + vm.Model.Should().BeSameAs(replacement); + CountNameChanges(vm, () => replacement.Name = "Changed").Should().Be(1); + + vm.Model = await CreateRootAsync(); + + CountNameChanges(vm, () => replacement.Name = "Changed again").Should().Be(0); + } + + [TestMethod] + public async Task SaveAsync_WhileSaving_CanSaveIsFalse() + { + var root = await CreateRootAsync(); + root.Name = "Valid"; + var vm = CreateViewModel(); + var pending = new TaskCompletionSource(); + vm.SaveOverride = _ => pending.Task; + vm.Model = root; + vm.CanSave.Should().BeTrue(); + + var save = vm.SaveAsync(); + + vm.IsBusy.Should().BeTrue(); + vm.CanSave.Should().BeFalse(); + vm.CanCancel.Should().BeFalse(); + + pending.SetResult(root); + await save; + + vm.IsBusy.Should().BeFalse(); + vm.CanSave.Should().BeTrue(); + } + + [TestMethod] + public async Task SaveAsync_WithoutManagedLifetime_StaysBusyUntilSaved() + { + var root = await CreateRootAsync(); + root.Name = "Valid"; + var vm = CreateViewModel(); + vm.Model = root; + bool? busyWhenSaved = null; + vm.Saved += () => busyWhenSaved = vm.IsBusy; + + await vm.SaveAsync(); + + vm.Exception.Should().BeNull(); + busyWhenSaved.Should().BeTrue(); + vm.IsBusy.Should().BeFalse(); + } + + [TestMethod] + public async Task SaveAsync_NullModel_DoesNotRaiseSaved() + { + var vm = CreateViewModel(); + var saved = false; + vm.Saved += () => saved = true; + + await vm.SaveAsync(); + + saved.Should().BeFalse(); + vm.Exception.Should().BeNull(); + } + + [TestMethod] + public async Task SaveAsync_BusyPastTimeout_ReportsTimeoutException() + { + var root = await _testHost.GetDataPortal().CreateAsync(); + root.StayBusy(); + var vm = CreateViewModel(); + vm.Model = root; + vm.BusyTimeout = TimeSpan.FromMilliseconds(200); + + await vm.SaveAsync(); + + vm.Exception.Should().BeOfType(); + } + + [TestMethod] + public async Task ManageObjectLifetime_SaveKeepsEditLevel() + { + var root = await CreateRootAsync(); + var vm = CreateViewModel(); + vm.ManageObjectLifetime = true; + vm.Model = root; + ((IUndoableObject)vm.Model).EditLevel.Should().Be(1); + vm.Model.Name = "Valid"; + + await vm.SaveAsync(); + + vm.Exception.Should().BeNull(); + ((IUndoableObject)vm.Model).EditLevel.Should().Be(1); + vm.Model.IsNew.Should().BeFalse(); + } + + [TestMethod] + public async Task ManageObjectLifetime_CancelRevertsChanges() + { + var root = await CreateRootAsync(); + root.Name = "Original"; + var vm = CreateViewModel(); + vm.ManageObjectLifetime = true; + vm.Model = root; + root.Name = "Changed"; + + vm.Cancel(); + + root.Name.Should().Be("Original"); + ((IUndoableObject)root).EditLevel.Should().Be(1); + } + + [TestMethod] + public async Task ManageObjectLifetime_SaveSucceeds_CancelKeepsSavedValues() + { + var root = await CreateRootAsync(); + root.Name = "Original"; + var vm = CreateViewModel(); + vm.ManageObjectLifetime = true; + vm.Model = root; + root.Name = "Saved"; + + await vm.SaveAsync(); + vm.Exception.Should().BeNull(); + + vm.Cancel(); + + vm.Model.Name.Should().Be("Saved"); + ((IUndoableObject)vm.Model).EditLevel.Should().Be(1); + } + + [TestMethod] + public async Task ManageObjectLifetime_SaveFails_CancelRestoresOriginal() + { + var root = await CreateRootAsync(); + root.Name = "Original"; + var vm = CreateViewModel(); + vm.ManageObjectLifetime = true; + vm.Model = root; // BeginEdit: snapshot has "Original" + root.Name = MvvmRoot.FailOnSave; // the edit the server rejects + + await vm.SaveAsync(); + vm.Exception.Should().NotBeNull(); + + vm.Cancel(); + + vm.Model.Name.Should().Be("Original"); + } + + [TestMethod] + public async Task RefreshAsync_Success_SetsModel() + { + var vm = CreateViewModel(); + + var result = await vm.RefreshAsync(() => _testHost.GetDataPortal().FetchAsync(1)); + + result.Should().BeSameAs(vm.Model); + vm.Model.Name.Should().Be("Fetched 1"); + vm.Exception.Should().BeNull(); + vm.IsBusy.Should().BeFalse(); + } + + [TestMethod] + public async Task RefreshAsync_Failure_CapturesException() + { + var vm = CreateViewModel(); + vm.Model = await CreateRootAsync(); + + await vm.RefreshAsync(() => _testHost.GetDataPortal().FetchAsync(-1)); + + vm.Model.Should().BeNull(); + vm.Exception.Should().NotBeNull(); + vm.ViewModelErrorText.Should().NotBeEmpty(); + vm.IsBusy.Should().BeFalse(); + } + + [TestMethod] + public async Task RefreshFailure_RaisesError() + { + var vm = CreateViewModel(); + var raised = false; + vm.Error += (_, _) => raised = true; + + await vm.RefreshAsync(() => _testHost.GetDataPortal().FetchAsync(-1)); + + vm.Exception.Should().NotBeNull(); + raised.Should().BeTrue(); + } + + [TestMethod] + public async Task ListModel_AddNewAndRemove() + { + var list = await _testHost.GetDataPortal().CreateAsync(); + var vm = CreateViewModel(); + vm.Model = list; + vm.CanAddNew.Should().BeTrue(); + vm.CanRemove.Should().BeFalse(); + + var item = vm.AddNew(); + + list.Should().HaveCount(1); + vm.CanRemove.Should().BeTrue(); + + vm.Remove(item); + + list.Should().BeEmpty(); + vm.CanRemove.Should().BeFalse(); + } + + [TestMethod] + public async Task SyncRules_PermissionsAreSetImmediately() + { + var root = await _testHost.GetDataPortal().CreateAsync(); + var vm = CreateViewModel(); + + vm.Model = root; + + vm.CanCreateObject.Should().BeTrue(); + vm.CanGetObject.Should().BeTrue(); + vm.CanEditObject.Should().BeFalse(); + vm.CanDeleteObject.Should().BeTrue(); + vm.CanSave.Should().BeFalse(); + vm.CanDelete.Should().BeTrue(); + } + + [TestMethod] + public async Task PermissionRuleFailure_IsReportedThroughError() + { + var root = await _testHost.GetDataPortal().CreateAsync(); + var vm = CreateViewModel(); + Exception reported = null; + vm.Error += (_, e) => reported = e.Error; + + vm.Model = root; + + vm.Exception.Should().NotBeNull(); + vm.ViewModelErrorText.Should().NotBeEmpty(); + reported.Should().BeSameAs(vm.Exception); + vm.CanSave.Should().BeFalse(); + } + + [TestMethod] + public async Task PersistentStatusRuleFailure_IsReportedOnce() + { + InstanceOnlyFailRule.Failing = true; + var root = await _testHost.GetDataPortal().CreateAsync(); + var vm = CreateViewModel(); + var errors = 0; + vm.Error += (_, _) => errors++; + + vm.Model = root; + root.Name = "a"; + root.Name = "ab"; + root.Name = "abc"; + + errors.Should().Be(1); + } + + [TestMethod] + public async Task StatusRuleFailure_IsReportedAgainAfterRecovery() + { + InstanceOnlyFailRule.Failing = true; + var root = await _testHost.GetDataPortal().CreateAsync(); + var vm = CreateViewModel(); + var errors = 0; + vm.Error += (_, _) => errors++; + + try + { + vm.Model = root; + InstanceOnlyFailRule.Failing = false; + root.Name = "a"; + vm.CanDelete.Should().BeTrue(); + InstanceOnlyFailRule.Failing = true; + root.Name = "ab"; + } + finally + { + InstanceOnlyFailRule.Failing = true; + } + + errors.Should().Be(2); + } + + [TestMethod] + public async Task TransientPermissionFailure_LaterRefreshSucceeds() + { + FlakyAuthRule.Calls = 0; + var portal = _testHost.GetDataPortal(); + var vm = CreateViewModel(); + + await vm.RefreshAsync(() => portal.FetchAsync(1)); // fails: transient + await vm.RefreshAsync(() => portal.FetchAsync(1)); // should succeed + + vm.Exception.Should().BeNull(); + } + + [TestMethod] + public async Task Delete_MarksModelDeleted() + { + var root = await CreateRootAsync(); + var vm = CreateViewModel(); + vm.Model = root; + + vm.Delete(); + + root.IsDeleted.Should().BeTrue(); + } + } +} diff --git a/docs/Upgrading to CSLA 11.md b/docs/Upgrading to CSLA 11.md index 4b49b1f022..bcf93ad4ac 100644 --- a/docs/Upgrading to CSLA 11.md +++ b/docs/Upgrading to CSLA 11.md @@ -223,3 +223,64 @@ The factory receives a `TransactionScopeFactoryContext` describing the operation These let a factory adjust the transaction to the data involved. For example, it can use a longer timeout for a large batch. `BusinessObject` is the server-side instance before the operation runs. Treat it as read-only. The `TransactionalDataPortal` constructor now takes `(DataPortalBroker, ITransactionScopeFactory, TransactionScopeFactoryContext)` instead of `(DataPortalBroker, TransactionalAttribute)`. + +## Common `Csla.Mvvm.ViewModel` base type + +The view model types for each UI framework now share one implementation, `Csla.Mvvm.ViewModel`, in the core `Csla` package ([#1766](https://github.com/MarimerLLC/csla/issues/1766)). The existing types keep their names: + +* `Csla.Blazor.ViewModel` inherits from `Csla.Mvvm.ViewModel` and adds the Blazor-specific `GetPropertyInfo` methods. +* On MAUI and Avalonia, `Csla.Xaml.ViewModelBase` inherits from `Csla.Mvvm.ViewModel`. +* On WPF, `Csla.Xaml.ViewModelBase` is still a `DependencyObject` with the `ModelProperty` and `ManageObjectLifetimeProperty` dependency properties. It delegates to a `Csla.Mvvm.ViewModel` internally, so it has the same behavior. + +You can use `Csla.Mvvm.ViewModel` directly in other UI frameworks, such as Windows Forms. Pass an `ApplicationContext` to its constructor, or use the protected parameterless constructor and override `ResolveApplicationContext`. + +Because every view model now has the same behavior, some of them behave differently than before. + +### All view models + +* `SaveAsync` returns `Task` (the saved `Model`) instead of `Task`. Code that awaits `SaveAsync()` is unaffected. +* When `ManageObjectLifetime` is `true`, `SaveAsync` saves a clone with its changes committed, and calls `ApplyEdit` on the `Model` only after the save succeeds. If the save fails, the `Model` keeps its undo snapshot, so `DoCancel` still restores the values from before the edit. +* The `Error` event is raised when `SaveAsync` or `RefreshAsync` fails. It's also raised when an authorization rule throws while the status properties (`CanSave`, `CanEditObject` and so on) are calculated. That exception is captured in `Exception` and `ViewModelErrorText`, and the status properties keep their previous values. A rule that keeps failing is reported when it starts failing, not on every model change. Before, the XAML view model posted it to the `SynchronizationContext`, which crashes a desktop app that has no global exception handler. +* If loading the object-level permissions (`CanCreateObject`, `CanGetObject`, `CanEditObject`, `CanDeleteObject`) fails, the next `RefreshAsync` evaluates the rules again. +* If the `Model` is still busy after `BusyTimeout`, `SaveAsync` captures a `TimeoutException`. +* `SaveAsync` does nothing if `Model` is `null`, and doesn't raise `Saved`. If the `Model` doesn't implement `ISavable`, the default `DoSaveAsync` throws an `InvalidOperationException`, which `SaveAsync` captures. +* `OnModelChanging(oldValue, newValue)` moves event handlers from the old model to the new model. `OnModelChanged(oldValue, newValue)` runs after the model changes and raises `ModelChanged`. + +### XAML (WPF, MAUI and Avalonia) + +* `SaveAsync` and `RefreshAsync` no longer throw. Exceptions are captured in the `Exception` and `ViewModelErrorText` properties, and both methods raise the new `Error` event. Code that wrapped these calls in `try`/`catch` should check `Exception` or handle `Error` instead. If `RefreshAsync` fails, `Model` is set to `null`. +* If the `Model` is invalid, `SaveAsync` doesn't save. It sets `ViewModelErrorText` to the model's error text, without setting `Exception` or raising `Error`. + + Code that navigates away after a save must now check for errors itself. Check both properties, because an invalid model sets only `ViewModelErrorText`: + + ```csharp + // CSLA 10 + try + { + await vm.SaveAsync(); + await NavigateBack(); + } + catch (Exception ex) + { + ShowError(ex); + } + + // CSLA 11 + await vm.SaveAsync(); + if (vm.Exception is null && string.IsNullOrEmpty(vm.ViewModelErrorText)) + await NavigateBack(); + else + ShowError(vm.ViewModelErrorText); + ``` +* `SaveAsync` waits up to `BusyTimeout` (30 seconds by default) for a busy `Model` to become idle. Use `SaveAsync(CancellationToken)` to control the wait yourself. +* There are new `Saved`, `ModelChanging` and `ModelChanged` events. +* `RefreshAsync(factory)` is obsolete because the `F` type parameter was never used. Call `RefreshAsync(factory)`. +* On MAUI and Avalonia, `ManageObjectLifetime` now defaults to `true`, the same as WPF. Set it to `false` to keep the previous behavior. +* On MAUI and Avalonia, the public `ManageObjectLifetimeProperty` field is removed. Use the `ManageObjectLifetime` property. +* On MAUI, `BeginAddNew` is obsolete. Use `DoAddNew`. +* On WPF, override `OnModelChanging` instead of `OnModelChanged` if you need to run code before the event handlers are moved to the new model. + +### Blazor + +* `ViewModel` implements `INotifyPropertyChanged`. It has the `IsDirty`, `IsValid`, `CanSave`, `CanCancel`, `CanCreate`, `CanDelete`, `CanFetch`, `CanAddNew` and `CanRemove` properties from the XAML view model, and the `DoCancel`, `DoAddNew`, `DoRemove` and `DoDelete` methods. These properties are updated whenever the model changes, which means instance-level authorization rules are evaluated on each change. +* The parameterless `OnModelChanged()` method is replaced by `OnModelChanged(T? oldValue, T? newValue)`. Update any override. diff --git a/releasenotes.md b/releasenotes.md index 04860e070d..5757a0e0c5 100644 --- a/releasenotes.md +++ b/releasenotes.md @@ -66,6 +66,10 @@ The data portal extension generator and the CSLA0025 analyzer are adapted from [ The data portal no longer finds operation methods by name (`DataPortal_Fetch`, `Child_Update`, and so on). Only methods marked with an operation attribute are invoked. Matching by name could call an unrelated method that happened to have an operation name ([#4595](https://github.com/MarimerLLC/csla/issues/4595)). See [Upgrading to CSLA 11](docs/Upgrading%20to%20CSLA%2011.md) for migration guidance. +**Common view model base type** ([#1766](https://github.com/MarimerLLC/csla/issues/1766)) + +The new `Csla.Mvvm.ViewModel` in the `Csla` package has the view model behavior that was implemented separately for Blazor, WPF, MAUI and Avalonia. The existing `Csla.Blazor.ViewModel` and `Csla.Xaml.ViewModelBase` types build on it, so all view models handle errors, object lifetime and authorization the same way. See [Upgrading to CSLA 11](docs/Upgrading%20to%20CSLA%2011.md#common-cslamvvmviewmodelt-base-type). + ### Changes **Data Portal** @@ -79,6 +83,10 @@ The data portal no longer finds operation methods by name (`DataPortal_Fetch`, ` * [#4707](https://github.com/MarimerLLC/csla/issues/4707) XAML and Blazor view models support async authorization rules, and `HasPermission` reports a still-running async rule with an `InvalidOperationException` that points to `HasPermissionAsync` ([#4948](https://github.com/MarimerLLC/csla/pull/4948)) * [#4947](https://github.com/MarimerLLC/csla/issues/4947) `IsSavable` supports async per-type authorization rules: the object is busy while the rule runs, and the result is cached per action and user ([#4948](https://github.com/MarimerLLC/csla/pull/4948)) +**View Models** + +* [#1766](https://github.com/MarimerLLC/csla/issues/1766) Add `Csla.Mvvm.ViewModel` as the common base for the Blazor and XAML view models + **Bug Fixes** * [#4891](https://github.com/MarimerLLC/csla/issues/4891) Remove the unusable parameterless constructor of `LazySingleton` ([#4944](https://github.com/MarimerLLC/csla/pull/4944)) @@ -105,6 +113,7 @@ The data portal no longer finds operation methods by name (`DataPortal_Fetch`, ` * `BusinessListBase`, `BusinessBindingListBase`, `DynamicListBase` and `DynamicBindingListBase` throw `NotSupportedException` when items are added while `AllowNew` is `false`, or removed while `AllowRemove` is `false`. `AllowRemove` now defaults to `true` for `BusinessListBase` and `DynamicListBase`. See [Upgrading to CSLA 11](docs/Upgrading%20to%20CSLA%2011.md#allownew-and-allowremove-are-enforced). * With async authorization rules, view model permission properties (such as `CanCreateObject`) start out `false` and update when the rules complete. Reading `IsSavable` while an async `CreateObject`, `EditObject` or `DeleteObject` rule is running marks the object busy and returns `false`, and calling `Save` before the rule completes throws. See [Upgrading to CSLA 11](docs/Upgrading%20to%20CSLA%2011.md#async-authorization-rules-in-view-models-and-issavable) ([#4707](https://github.com/MarimerLLC/csla/issues/4707), [#4947](https://github.com/MarimerLLC/csla/issues/4947)). * `TransactionalAttribute` no longer copies defaults from `DataOptions`: `TimeoutInSeconds` is now `int?` and `AsyncFlowOption` is now `TransactionScopeAsyncFlowOption?`, and the `TransactionalDataPortal` constructor takes an `ITransactionScopeFactory` and `TransactionScopeFactoryContext`. The `DataOptions` transaction defaults are now per-instance instead of static. See [Upgrading to CSLA 11](docs/Upgrading%20to%20CSLA%2011.md#transaction-settings-resolved-at-runtime-and-itransactionscopefactory) ([#4780](https://github.com/MarimerLLC/csla/issues/4780)). +* The XAML view model's `SaveAsync` and `RefreshAsync` capture exceptions in `Exception` and `ViewModelErrorText`, and raise `Error`, instead of throwing. An exception from an authorization rule during the status calculation is reported the same way on every view model. `SaveAsync` returns `Task` on all view models. On MAUI and Avalonia, `ManageObjectLifetime` defaults to `true` and the public `ManageObjectLifetimeProperty` field is removed. The Blazor `OnModelChanged()` override is replaced by `OnModelChanged(T? oldValue, T? newValue)`. See [Upgrading to CSLA 11](docs/Upgrading%20to%20CSLA%2011.md#common-cslamvvmviewmodelt-base-type) ([#1766](https://github.com/MarimerLLC/csla/issues/1766)). ### Supported Platforms