From 38f64c6798b7ecb28226b00af1b9492b08075374 Mon Sep 17 00:00:00 2001 From: Rockford lhotka Date: Thu, 15 Oct 2026 23:37:58 -0500 Subject: [PATCH 1/9] #1766 Add Csla.Mvvm.ViewModel as common view model base Blazor, MAUI and Avalonia view models inherit from it; the WPF ViewModelBase keeps DependencyObject and delegates to it. All view models now capture save/refresh errors, apply edits before cloning, and expose the same status properties. Co-Authored-By: Claude Opus 5.5 --- Source/Csla.Blazor/ViewModel.cs | 554 +--------- Source/Csla.Xaml.Shared/ViewModelBase.cs | 993 ++++++------------ Source/Csla/Mvvm/ViewModel.cs | 915 ++++++++++++++++ .../Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs | 155 +++ .../csla.netcore.test/Mvvm/MvvmTestObjects.cs | 131 +++ .../csla.netcore.test/Mvvm/ViewModelTests.cs | 259 +++++ docs/Upgrading to CSLA 11.md | 34 + releasenotes.md | 9 + 8 files changed, 1862 insertions(+), 1188 deletions(-) create mode 100644 Source/Csla/Mvvm/ViewModel.cs create mode 100644 Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs create mode 100644 Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs create mode 100644 Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs diff --git a/Source/Csla.Blazor/ViewModel.cs b/Source/Csla.Blazor/ViewModel.cs index 45476d106a..7c12180665 100644 --- a/Source/Csla.Blazor/ViewModel.cs +++ b/Source/Csla.Blazor/ViewModel.cs @@ -6,405 +6,37 @@ // Base type for creating your own view model //----------------------------------------------------------------------- -using System.Collections.Specialized; -using System.ComponentModel; 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 : 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 +96,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 +114,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 +139,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/ViewModelBase.cs b/Source/Csla.Xaml.Shared/ViewModelBase.cs index ebbc8592a2..b78906b6a5 100644 --- a/Source/Csla.Xaml.Shared/ViewModelBase.cs +++ b/Source/Csla.Xaml.Shared/ViewModelBase.cs @@ -6,818 +6,420 @@ // 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 : Csla.Mvvm.ViewModel, IViewModel + { + /// + /// Creates an instance of the type that resolves + /// its ApplicationContext through + /// . + /// + protected ViewModelBase() + { + 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 + /// + /// 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 : 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); + Initialize(); } -#else + + /// + /// Creates an instance of the type. + /// + /// The current ApplicationContext. + /// is . + protected ViewModelBase(ApplicationContext applicationContext) + { + _inner = new InnerViewModel(this, applicationContext); + Initialize(); + } + + private void Initialize() + { + _inner.ManageObjectLifetime = ManageObjectLifetime; + _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; + protected virtual string ModelErrorText => _inner.BaseModelErrorText; - // async void so exceptions surface on the SynchronizationContext, - // just as a synchronous exception would - await SetPropertiesAtObjectLevelAsync(); - } + #endregion - private async Task SetPropertiesAtObjectLevelAsync() - { - Type sourceType = typeof(T); + #region Operations - // 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); - - // 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(); - } - } - } - } + protected virtual void DoCancel() => _inner.BaseDoCancel(); -#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(); - } - - 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); - } + /// + /// Event raised when a save operation fails. + /// + 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 +451,113 @@ 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; + + public InnerViewModel(ViewModelBase owner) + { + _owner = owner; + } + + public InnerViewModel(ViewModelBase owner, ApplicationContext applicationContext) + : base(applicationContext) + { + _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; } + + // inputs to the status calculation honor owner overrides + public override bool CanCreateObject => _owner.CanCreateObject; + public override bool CanGetObject => _owner.CanGetObject; + public override bool CanEditObject => _owner.CanEditObject; + public override bool CanDeleteObject => _owner.CanDeleteObject; + + 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..2d1d845ac6 --- /dev/null +++ b/Source/Csla/Mvvm/ViewModel.cs @@ -0,0 +1,915 @@ +//----------------------------------------------------------------------- +// +// 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) + { + _applicationContext = applicationContext ?? throw new ArgumentNullException(nameof(applicationContext)); + } + + /// + /// Creates an instance of the type that resolves its + /// ApplicationContext on first use by calling + /// . + /// + protected ViewModel() + { } + + /// + /// 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 save operation fails. + /// + 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)) + IsBusy = 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)); + 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; + /// + /// Gets a value indicating whether this object is + /// executing an asynchronous process. + /// + public bool IsBusy + { + get => _isBusy; + protected set + { + if (value != _isBusy) + { + _isBusy = value; + OnPropertyChanged(nameof(IsBusy)); + OnSetProperties(); + } + } + } + + 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. + /// + protected virtual void OnSetProperties() + { + SetProperties(); + } + + private void SetProperties() + { + ObserveTask(SetPropertiesAsync()); + } + + private async Task SetPropertiesAsync() + { + 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; + + var isObjectBusy = 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; + } + } + + 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; + var task = EnsureObjectLevelPermissionsAsync(); + if (task.IsCompleted) + task.GetAwaiter().GetResult(); // surface exceptions from synchronous rules + else + ObserveTask(task); + } + + private static async void ObserveTask(Task task) + { + // async void so exceptions surface on the SynchronizationContext + // (such as a UI thread), just as a synchronous exception would. + // Without a context an async void exception would crash the + // process, so it is not rethrown; the status properties are + // simply left unchanged. + var context = SynchronizationContext.Current; + try + { + await task; + } + catch (Exception) when (context is null) + { } + } + + private Task EnsureObjectLevelPermissionsAsync() + { + if (!_objectLevelPermissionsRequested) + { + // set the flag first: the property setters raise PropertyChanged, + // and handlers may read the permission properties re-entrantly + _objectLevelPermissionsRequested = true; + _objectLevelPermissionsTask = LoadObjectLevelPermissionsAsync(); + } + return _objectLevelPermissionsTask ?? Task.CompletedTask; + } + + private async Task LoadObjectLevelPermissionsAsync() + { + 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. + /// + 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. + /// + public Exception? Exception { get; private set; } + + private void SetError(Exception? exception, string errorText) + { + Exception = exception; + ViewModelErrorText = errorText; + OnPropertyChanged(nameof(Exception)); + OnPropertyChanged(nameof(ViewModelErrorText)); + } + + #endregion + + #region Operations + + /// + /// Creates or retrieves an instance of the Model by + /// invoking a factory. Exceptions are captured in + /// and . + /// + /// 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 + { + IsBusy = true; + await EnsureObjectLevelPermissionsAsync(); + Model = await factory(); + } + catch (DataPortalException ex) + { + Model = default; + SetError(ex, ex.BusinessExceptionMessage); + } + catch (Exception ex) + { + Model = default; + SetError(ex, ex.Message); + } + finally + { + IsBusy = false; + } + + return Model; + } + + /// + /// Saves the Model, waiting up to + /// for the Model to become idle. Exceptions are captured in + /// and , + /// and the event is raised. + /// + public virtual async Task SaveAsync() + { + try + { + using var cts = BusyTimeout.ToCancellationTokenSource(); + await SaveAsync(cts.Token); + } + catch (TaskCanceledException tcex) + { + var timeout = new TimeoutException(nameof(SaveAsync), tcex); + SetError(timeout, timeout.Message); + } + return Model; + } + + /// + /// Saves the Model, first committing changes + /// if ManageObjectLifetime is true. Exceptions are captured in + /// and , + /// and the event is raised. + /// + /// The cancellation token. + public virtual async Task SaveAsync(CancellationToken ct) + { + SetError(null, string.Empty); + try + { + if (Model is ITrackStatus obj && !obj.IsSavable) + { + while (obj.IsBusy) + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(1, 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) + { + // 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) + { + SetError(ex, ex.BusinessExceptionMessage); + } + catch (Exception ex) + { + SetError(ex, ex.Message); + } + finally + { + if (ManageObjectLifetime && Model is IUndoableObject udbl && udbl.EditLevel == 0 && Model is ISupportUndo undo) + undo.BeginEdit(); + + HookChangedEvents(Model); + IsBusy = false; + OnSetProperties(); + if (Exception != null) + Error?.Invoke(this, new Core.ErrorEventArgs(this, Exception)); + } + return Model; + } + + /// + /// Override to provide custom Model save behavior. + /// + /// The object to save (a clone of Model if ManageObjectLifetime is true). + 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; + } + + /// + /// 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.Xaml.Wpf.Tests/ViewModelBaseTests.cs b/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs new file mode 100644 index 0000000000..9c12bf6e31 --- /dev/null +++ b/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs @@ -0,0 +1,155 @@ +//----------------------------------------------------------------------- +// +// 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 DenyEditViewModel : ViewModel + { + public override bool CanEditObject => false; + } + + [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 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); + }); + } + } + + [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 Save() + { + 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..52b6e69e07 --- /dev/null +++ b/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs @@ -0,0 +1,131 @@ +//----------------------------------------------------------------------- +// +// 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 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 Save() + { + if (Name == FailOnSave) + throw new InvalidOperationException("Save failed"); + } + + [DeleteSelf] + private void DeleteSelf() + { } + } + + [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; + } + } + + [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..68fa72910c --- /dev/null +++ b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs @@ -0,0 +1,259 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Tests for Csla.Mvvm.ViewModel +//----------------------------------------------------------------------- + +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 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(); + } + + [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 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 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 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..d8c2291d0c 100644 --- a/docs/Upgrading to CSLA 11.md +++ b/docs/Upgrading to CSLA 11.md @@ -223,3 +223,37 @@ 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. +* `SaveAsync` calls `ApplyEdit` on the `Model` before cloning it, when `ManageObjectLifetime` is `true`. +* `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 `SaveAsync` also raises 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. +* `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, `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 d031bd8498..91efd85acf 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)) @@ -104,6 +112,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 `SaveAsync` raises `Error`) instead of throwing. `SaveAsync` returns `Task` on all view models. On MAUI and Avalonia, `ManageObjectLifetime` defaults to `true`. 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 From 56f0b3e9a0cae11d31753a0e1ec4806e442e6920 Mon Sep 17 00:00:00 2001 From: Rockford lhotka Date: Fri, 16 Oct 2026 00:05:20 -0500 Subject: [PATCH 2/9] #1766 Address review: WPF setter forwarding, busy gating, duplicate hooks - WPF inner view model forwards both accessors of every status and permission property to the owner, so overridden protected setters run - Status calculation treats the view model's own IsBusy as busy, so CanSave/CanCancel are false while a cloned model is being saved - SaveAsync unhooks before re-hooking model events in its finally block, avoiding duplicate handlers after an invalid-model return or a save that replaces Model Co-Authored-By: Claude Opus 5.5 --- Source/Csla.Xaml.Shared/ViewModelBase.cs | 20 ++++-- Source/Csla/Mvvm/ViewModel.cs | 7 +- .../Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs | 30 ++++++++ .../csla.netcore.test/Mvvm/MvvmTestObjects.cs | 5 ++ .../csla.netcore.test/Mvvm/ViewModelTests.cs | 71 +++++++++++++++++++ 5 files changed, 127 insertions(+), 6 deletions(-) diff --git a/Source/Csla.Xaml.Shared/ViewModelBase.cs b/Source/Csla.Xaml.Shared/ViewModelBase.cs index b78906b6a5..c62f10e1f6 100644 --- a/Source/Csla.Xaml.Shared/ViewModelBase.cs +++ b/Source/Csla.Xaml.Shared/ViewModelBase.cs @@ -493,11 +493,21 @@ public InnerViewModel(ViewModelBase owner, ApplicationContext applicationCont public bool BaseCanEditObject { get => base.CanEditObject; set => base.CanEditObject = value; } public bool BaseCanDeleteObject { get => base.CanDeleteObject; set => base.CanDeleteObject = value; } - // inputs to the status calculation honor owner overrides - public override bool CanCreateObject => _owner.CanCreateObject; - public override bool CanGetObject => _owner.CanGetObject; - public override bool CanEditObject => _owner.CanEditObject; - public override bool CanDeleteObject => _owner.CanDeleteObject; + // 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; diff --git a/Source/Csla/Mvvm/ViewModel.cs b/Source/Csla/Mvvm/ViewModel.cs index 2d1d845ac6..5838324c5b 100644 --- a/Source/Csla/Mvvm/ViewModel.cs +++ b/Source/Csla/Mvvm/ViewModel.cs @@ -478,7 +478,9 @@ private async Task SetPropertiesAsync() if (version != _setPropertiesVersion || !ReferenceEquals(model, Model)) return; - var isObjectBusy = Model is INotifyBusy busyObject && busyObject.IsBusy; + // 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) { @@ -815,6 +817,9 @@ private void SetError(Exception? exception, string errorText) 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(); diff --git a/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs b/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs index 9c12bf6e31..b624665061 100644 --- a/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs +++ b/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs @@ -37,6 +37,21 @@ private class ReplacingViewModel : ViewModel 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; @@ -112,6 +127,21 @@ public void SaveAsync_Failure_CapturesExceptionWithViewModelAsSender() }); } + [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() { diff --git a/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs b/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs index 52b6e69e07..f8247b97eb 100644 --- a/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs +++ b/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs @@ -23,6 +23,11 @@ public TestViewModel(ApplicationContext applicationContext) 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 diff --git a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs index 68fa72910c..0b375bcdc4 100644 --- a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs +++ b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs @@ -6,6 +6,7 @@ // Tests for Csla.Mvvm.ViewModel //----------------------------------------------------------------------- +using System.ComponentModel; using Csla.Core; using Csla.Testing; using FluentAssertions; @@ -148,6 +149,76 @@ public async Task SaveAsync_InvalidModel_SetsErrorTextWithoutSaving() 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 ManageObjectLifetime_SaveKeepsEditLevel() { From 1603d06fbc454b03d8d2fe7e585323f947eb464d Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 19 Oct 2026 12:17:45 -0700 Subject: [PATCH 3/9] #1766 Repro test: Cancel after a failed save restores the original values With ManageObjectLifetime on, SaveAsync applies the edit to the Model before saving the clone, so after a failed save Cancel can no longer get back to the values the Model had when editing began. Fails on the PR as written (Name is "fail", expected "Original"); see the review comment on SaveAsync for an ordering that keeps both behaviors. Co-Authored-By: Claude Opus 5.5 --- .../csla.netcore.test/Mvvm/ViewModelTests.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs index 0b375bcdc4..faa0e3bcbe 100644 --- a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs +++ b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs @@ -252,6 +252,24 @@ public async Task ManageObjectLifetime_CancelRevertsChanges() ((IUndoableObject)root).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() { From 6bc0f0d191f6158408bfc3c9fc3101b0d745c9c0 Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 19 Oct 2026 12:17:54 -0700 Subject: [PATCH 4/9] #1766 Repro test: BusyTimeout reports a TimeoutException SaveAsync(ct) catches every exception itself, so the outer catch (TaskCanceledException) in SaveAsync() that converts an expired BusyTimeout into a TimeoutException is never reached. Fails on the PR as written: Exception is TaskCanceledException and ViewModelErrorText is "A task was canceled.". BusyRoot is a model that stays busy on demand. Co-Authored-By: Claude Opus 5.5 --- .../csla.netcore.test/Mvvm/MvvmTestObjects.cs | 10 ++++++++++ .../tests/csla.netcore.test/Mvvm/ViewModelTests.cs | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs b/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs index f8247b97eb..e2a75940a3 100644 --- a/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs +++ b/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs @@ -81,6 +81,16 @@ private void DeleteSelf() { } } + [Serializable] + public class BusyRoot : BusinessBase + { + public void StayBusy() => MarkBusy(); + + [Create] + private void Create() + { } + } + [Serializable] public class MvvmChild : BusinessBase { diff --git a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs index faa0e3bcbe..251e0d6f19 100644 --- a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs +++ b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs @@ -219,6 +219,20 @@ public async Task SaveAsync_WhileSaving_CanSaveIsFalse() vm.CanSave.Should().BeTrue(); } + [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() { From 12633698a1c6aaac05bdb1aaa655445031bce83a Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 19 Oct 2026 12:18:04 -0700 Subject: [PATCH 5/9] #1766 Repro test: constructing a view model doesn't call OnPropertyChanged early The base constructors now set ManageObjectLifetime (MAUI/Avalonia directly, WPF via the inner view model), which raises PropertyChanged through the virtual OnPropertyChanged before the subclass constructor body runs. A subclass that uses constructor-initialized state there throws. Fails on the PR as written with NullReferenceException (WPF; MAUI/Avalonia take the same path but weren't run). Co-Authored-By: Claude Opus 5.5 --- .../Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs b/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs index b624665061..3b125d0da0 100644 --- a/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs +++ b/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs @@ -57,6 +57,23 @@ 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() { @@ -156,6 +173,16 @@ public void OverriddenObjectPermission_IsUsedForStatus() Assert.IsFalse(vm.CanSave); }); } + + [TestMethod] + public void Construction_DoesNotCallOnPropertyChangedEarly() + { + DispatcherContext.Run(async () => + { + await Task.Yield(); + _ = new CommandRefreshingViewModel(); // throws NullReferenceException today + }); + } } [Serializable] From d2688b36f4aa59a4e39f4222a4f7a5722544f791 Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 19 Oct 2026 12:18:14 -0700 Subject: [PATCH 6/9] #1766 Repro test: a transient permission failure doesn't stick If loading the object-level permissions faults, the faulted task stays cached and RefreshAsync awaits it, so every later refresh on that view model fails with the same exception even after the rule would succeed. Fails on the PR as written. (A control run with a fresh view model after the same failure succeeds, so the stale state is the cached task, not the CSLA permission cache.) Co-Authored-By: Claude Opus 5.5 --- .../csla.netcore.test/Mvvm/MvvmTestObjects.cs | 33 +++++++++++++++++++ .../csla.netcore.test/Mvvm/ViewModelTests.cs | 13 ++++++++ 2 files changed, 46 insertions(+) diff --git a/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs b/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs index e2a75940a3..de108a1ddc 100644 --- a/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs +++ b/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs @@ -130,6 +130,39 @@ protected override void Execute(IAuthorizationContext context) } } + /// + /// 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) + { } + } + [Serializable] public class MvvmDenyEditRoot : BusinessBase { diff --git a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs index 251e0d6f19..d6695415bf 100644 --- a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs +++ b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs @@ -347,6 +347,19 @@ public async Task SyncRules_PermissionsAreSetImmediately() vm.CanDelete.Should().BeTrue(); } + [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() { From 80cff6f149d301206cca8bd2a1ebbe91b3b80775 Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 19 Oct 2026 12:18:23 -0700 Subject: [PATCH 7/9] #1766 Repro test: a failed RefreshAsync raises Error The upgrade doc tells XAML users who wrapped SaveAsync/RefreshAsync in try/catch to check Exception or handle Error instead, but RefreshAsync never raises Error. Fails on the PR as written. If Error is meant to be save-only, drop this test and make the doc say so instead. Co-Authored-By: Claude Opus 5.5 --- .../tests/csla.netcore.test/Mvvm/ViewModelTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs index d6695415bf..161e7d90a1 100644 --- a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs +++ b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs @@ -311,6 +311,19 @@ public async Task RefreshAsync_Failure_CapturesException() 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() { From ac95292f4461d79862f7a4a309551b075f484476 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Mon, 19 Oct 2026 15:27:44 -0500 Subject: [PATCH 8/9] #1766 Address review: failed-save cancel, BusyTimeout, status errors - Save a committed clone and apply the Model's edit only after the save succeeds, so Cancel after a failed save restores the original values - Wait with BusyHelper.WaitForIdle and report BusyTimeout as a TimeoutException - Add protected constructors that set ManageObjectLifetime without raising PropertyChanged during construction (MAUI, Avalonia, WPF) - Report authorization rule failures in the status calculation through Exception, ViewModelErrorText and Error on every host - Retry a failed object-level permission load on the next RefreshAsync - Raise Error when RefreshAsync fails - Track view model and Model busy state separately - Don't raise Saved when there is no Model to save - Cache the child item type, add DynamicallyAccessedMembers to the derived view models, and remove dead ANDROID/IOS code - Document the changes and add a before/after migration snippet Co-Authored-By: Claude Opus 5.5 --- Source/Csla.Blazor/ViewModel.cs | 3 +- Source/Csla.Xaml.Shared/ViewModel.cs | 15 +- Source/Csla.Xaml.Shared/ViewModelBase.cs | 34 +- Source/Csla/Mvvm/ViewModel.cs | 297 +++++++++++++----- .../ViewModelSaveAsyncErrorTests.cs | 2 +- .../Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs | 2 +- .../csla.netcore.test/Mvvm/MvvmTestObjects.cs | 28 +- .../csla.netcore.test/Mvvm/ViewModelTests.cs | 65 ++++ docs/Upgrading to CSLA 11.md | 33 +- releasenotes.md | 2 +- 10 files changed, 360 insertions(+), 121 deletions(-) diff --git a/Source/Csla.Blazor/ViewModel.cs b/Source/Csla.Blazor/ViewModel.cs index 7c12180665..7005d2621e 100644 --- a/Source/Csla.Blazor/ViewModel.cs +++ b/Source/Csla.Blazor/ViewModel.cs @@ -6,6 +6,7 @@ // Base type for creating your own view model //----------------------------------------------------------------------- +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using Csla.Core; using Csla.Reflection; @@ -15,7 +16,7 @@ namespace Csla.Blazor /// /// Base type for creating your own view model. /// - public class ViewModel : Csla.Mvvm.ViewModel + public class ViewModel<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T> : Csla.Mvvm.ViewModel { /// /// Creates an instance of the type. 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 c62f10e1f6..4c34ab2f72 100644 --- a/Source/Csla.Xaml.Shared/ViewModelBase.cs +++ b/Source/Csla.Xaml.Shared/ViewModelBase.cs @@ -9,6 +9,7 @@ using System.Collections.Specialized; using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; #if !MAUI && !AVALONIA using System.Windows; #endif @@ -22,7 +23,7 @@ namespace Csla.Xaml /// implement their own commands/verbs/actions. /// /// Type of the Model object. - public abstract class ViewModelBase : Csla.Mvvm.ViewModel, IViewModel + public abstract class ViewModelBase<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T> : Csla.Mvvm.ViewModel, IViewModel { /// /// Creates an instance of the type that resolves @@ -30,9 +31,8 @@ public abstract class ViewModelBase : Csla.Mvvm.ViewModel, IViewModel /// . /// protected ViewModelBase() - { - ManageObjectLifetime = true; - } + : base(manageObjectLifetime: true) + { } /// /// Creates an instance of the type. @@ -40,10 +40,8 @@ protected ViewModelBase() /// The current ApplicationContext. /// is . protected ViewModelBase(ApplicationContext applicationContext) - : base(applicationContext) - { - ManageObjectLifetime = true; - } + : base(applicationContext, manageObjectLifetime: true) + { } /// protected override ApplicationContext ResolveApplicationContext() => ApplicationContextManager.GetApplicationContext(); @@ -82,7 +80,7 @@ protected ViewModelBase(ApplicationContext applicationContext) /// delegates its behavior to a /// instance rather than inheriting from it. /// - public abstract class ViewModelBase : DependencyObject, + public abstract class ViewModelBase<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T> : DependencyObject, INotifyPropertyChanged, IViewModel { private readonly InnerViewModel _inner; @@ -94,7 +92,7 @@ public abstract class ViewModelBase : DependencyObject, /// protected ViewModelBase() { - _inner = new InnerViewModel(this); + _inner = new InnerViewModel(this, ManageObjectLifetime); Initialize(); } @@ -105,13 +103,12 @@ protected ViewModelBase() /// is . protected ViewModelBase(ApplicationContext applicationContext) { - _inner = new InnerViewModel(this, applicationContext); + _inner = new InnerViewModel(this, applicationContext, ManageObjectLifetime); Initialize(); } private void Initialize() { - _inner.ManageObjectLifetime = ManageObjectLifetime; _inner.Error += (_, e) => Error?.Invoke(this, new Core.ErrorEventArgs(this, e.Error)); } @@ -399,9 +396,7 @@ public event Action? Saved remove => _inner.Saved -= value; } - /// - /// Event raised when a save operation fails. - /// + /// public event EventHandler? Error; /// @@ -462,13 +457,16 @@ private sealed class InnerViewModel : Csla.Mvvm.ViewModel { private readonly ViewModelBase _owner; - public InnerViewModel(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) - : base(applicationContext) + public InnerViewModel(ViewModelBase owner, ApplicationContext applicationContext, bool manageObjectLifetime) + : base(applicationContext, manageObjectLifetime) { _owner = owner; } diff --git a/Source/Csla/Mvvm/ViewModel.cs b/Source/Csla/Mvvm/ViewModel.cs index 5838324c5b..7110f07933 100644 --- a/Source/Csla/Mvvm/ViewModel.cs +++ b/Source/Csla/Mvvm/ViewModel.cs @@ -36,8 +36,21 @@ public class ViewModel<[DynamicallyAccessedMembers(DynamicallyAccessedMemberType /// 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; } /// @@ -48,6 +61,19 @@ public ViewModel(ApplicationContext applicationContext) 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. /// @@ -77,7 +103,9 @@ protected virtual ApplicationContext ResolveApplicationContext() public event Action? Saved; /// - /// Event raised when a save operation fails. + /// 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; @@ -202,7 +230,7 @@ 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)) - IsBusy = e.Busy; + SetBusy(ref _isModelBusy, e.Busy); else OnSetProperties(); } @@ -263,7 +291,9 @@ public T? Model _model = value; OnModelChanged(oldValue, value); OnPropertyChanged(nameof(Model)); - OnSetProperties(); + // the busy state reported by the old model no longer applies + if (!SetBusy(ref _isModelBusy, false)) + OnSetProperties(); } } } @@ -298,22 +328,32 @@ public bool ManageObjectLifetime 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. + /// executing an asynchronous process, or the Model + /// reports that it is busy. /// public bool IsBusy { - get => _isBusy; - protected set - { - if (value != _isBusy) - { - _isBusy = value; - OnPropertyChanged(nameof(IsBusy)); - OnSetProperties(); - } - } + 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; @@ -440,6 +480,10 @@ private void SetStatus(ref bool field, bool value, string propertyName) /// 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() { @@ -466,7 +510,7 @@ private async Task SetPropertiesAsync() canDeleteInstance = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.DeleteObject, targetObject, CancellationToken.None); if (model is ICollection) { - itemType = Utilities.GetChildItemType(model.GetType()); + itemType = GetChildItemType(model.GetType()); if (itemType != null) { canRemoveItem = await BusinessRules.HasPermissionAsync(ApplicationContext, AuthorizationActions.DeleteObject, itemType, CancellationToken.None); @@ -511,6 +555,21 @@ private async Task SetPropertiesAsync() } } + 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) @@ -609,49 +668,51 @@ private void SetPropertiesAtObjectLevel() { if (_objectLevelPermissionsRequested) return; - var task = EnsureObjectLevelPermissionsAsync(); - if (task.IsCompleted) - task.GetAwaiter().GetResult(); // surface exceptions from synchronous rules - else - ObserveTask(task); + ObserveTask(EnsureObjectLevelPermissionsAsync(retryFailed: false)); } - private static async void ObserveTask(Task task) + private async void ObserveTask(Task task) { - // async void so exceptions surface on the SynchronizationContext - // (such as a UI thread), just as a synchronous exception would. - // Without a context an async void exception would crash the - // process, so it is not rethrown; the status properties are - // simply left unchanged. - var context = SynchronizationContext.Current; + // 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) when (context is null) - { } + catch (Exception ex) + { + ReportError(ex); + } } - private Task EnsureObjectLevelPermissionsAsync() + // 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) + 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(); + _objectLevelPermissionsTask = LoadObjectLevelPermissionsAsync(applicationContext); } return _objectLevelPermissionsTask ?? Task.CompletedTask; } - private async Task LoadObjectLevelPermissionsAsync() + 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); + 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(); @@ -662,7 +723,9 @@ private async Task LoadObjectLevelPermissionsAsync() #region Errors /// - /// Gets any error text generated by refresh or save operations. + /// 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; @@ -683,7 +746,9 @@ protected virtual string ModelErrorText /// /// Gets the last exception caught by /// the viewmodel during refresh or save - /// operations. + /// operations, or while evaluating the + /// authorization rules used to calculate + /// the status properties. /// public Exception? Exception { get; private set; } @@ -695,6 +760,18 @@ private void SetError(Exception? exception, string errorText) 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 @@ -702,7 +779,8 @@ private void SetError(Exception? exception, string errorText) /// /// Creates or retrieves an instance of the Model by /// invoking a factory. Exceptions are captured in - /// and . + /// and , + /// and the event is raised. /// /// Async data portal or factory method. /// is . @@ -714,19 +792,17 @@ private void SetError(Exception? exception, string errorText) 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 EnsureObjectLevelPermissionsAsync(); + await permissions; Model = await factory(); } - catch (DataPortalException ex) - { - Model = default; - SetError(ex, ex.BusinessExceptionMessage); - } catch (Exception ex) { Model = default; - SetError(ex, ex.Message); + ReportError(ex); } finally { @@ -736,30 +812,38 @@ private void SetError(Exception? exception, string errorText) 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. + /// 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 { - using var cts = BusyTimeout.ToCancellationTokenSource(); - await SaveAsync(cts.Token); + return await SaveAsync(cts.Token); } - catch (TaskCanceledException tcex) + finally { - var timeout = new TimeoutException(nameof(SaveAsync), tcex); - SetError(timeout, timeout.Message); + _busyTimeoutToken = default; } - return Model; } /// - /// Saves the Model, first committing changes - /// if ManageObjectLifetime is true. Exceptions are captured in + /// 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. /// @@ -767,15 +851,16 @@ private void SetError(Exception? exception, string errorText) 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) + if (model is ITrackStatus obj && !obj.IsSavable) { - while (obj.IsBusy) - { - ct.ThrowIfCancellationRequested(); - await Task.Delay(1, ct); - } + await WaitForIdleAsync(obj, ct); if (!obj.IsValid) { SetError(null, ModelErrorText); @@ -786,34 +871,44 @@ private void SetError(Exception? exception, string errorText) $"{obj.GetType().Name} IsBusy: {obj.IsBusy}, IsValid: {obj.IsValid}, IsSavable: {obj.IsSavable}"); } - UnhookChangedEvents(Model); + UnhookChangedEvents(model); - var savable = Model as ISavable; + 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) + { + // 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 (DataPortalException ex) - { - SetError(ex, ex.BusinessExceptionMessage); - } catch (Exception ex) { - SetError(ex, ex.Message); + 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(); @@ -823,26 +918,60 @@ private void SetError(Exception? exception, string errorText) HookChangedEvents(Model); IsBusy = false; OnSetProperties(); - if (Exception != null) - Error?.Invoke(this, new Core.ErrorEventArgs(this, Exception)); + 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 != null) - { - var saved = (T)await cloned.SaveAsync(); - if (Model is IEditableBusinessObject editable) - await new GraphMerger(ApplicationContext).MergeGraphAsync(editable, (IEditableBusinessObject)saved); - else - Model = saved; - } + 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; } 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 index 3b125d0da0..cfe25c1658 100644 --- a/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs +++ b/Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs @@ -203,7 +203,7 @@ private void Create() [Insert] [Update] - private void Save() + 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 index de108a1ddc..75421babf7 100644 --- a/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs +++ b/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs @@ -70,7 +70,7 @@ private void Fetch(int id) [Insert] [Update] - private void Save() + private void InsertOrUpdate() { if (Name == FailOnSave) throw new InvalidOperationException("Save failed"); @@ -163,6 +163,32 @@ 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() + { } + } + [Serializable] public class MvvmDenyEditRoot : BusinessBase { diff --git a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs index 161e7d90a1..2b3f4d1781 100644 --- a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs +++ b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs @@ -219,6 +219,36 @@ public async Task SaveAsync_WhileSaving_CanSaveIsFalse() 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() { @@ -266,6 +296,25 @@ public async Task ManageObjectLifetime_CancelRevertsChanges() ((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() { @@ -360,6 +409,22 @@ public async Task SyncRules_PermissionsAreSetImmediately() 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 TransientPermissionFailure_LaterRefreshSucceeds() { diff --git a/docs/Upgrading to CSLA 11.md b/docs/Upgrading to CSLA 11.md index d8c2291d0c..218f3fd012 100644 --- a/docs/Upgrading to CSLA 11.md +++ b/docs/Upgrading to CSLA 11.md @@ -239,17 +239,44 @@ Because every view model now has the same behavior, some of them behave differen ### All view models * `SaveAsync` returns `Task` (the saved `Model`) instead of `Task`. Code that awaits `SaveAsync()` is unaffected. -* `SaveAsync` calls `ApplyEdit` on the `Model` before cloning it, when `ManageObjectLifetime` is `true`. +* 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. 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 `SaveAsync` also raises 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. +* `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. diff --git a/releasenotes.md b/releasenotes.md index b273a68323..5757a0e0c5 100644 --- a/releasenotes.md +++ b/releasenotes.md @@ -113,7 +113,7 @@ The new `Csla.Mvvm.ViewModel` in the `Csla` package has the view model behavi * `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 `SaveAsync` raises `Error`) instead of throwing. `SaveAsync` returns `Task` on all view models. On MAUI and Avalonia, `ManageObjectLifetime` defaults to `true`. 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)). +* 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 From b0ad06cc36603a889fa1cf25aea7faf3a0819a6b Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Mon, 19 Oct 2026 18:52:07 -0500 Subject: [PATCH 9/9] #1766 Report a persistent status failure once; check context in Model setter - Report a status-calculation failure when it starts, and again only after a calculation has succeeded, instead of on every model change - Resolve ApplicationContext before starting the status calculation, so a missing context throws from the Model setter Co-Authored-By: Claude Opus 5.5 --- Source/Csla/Mvvm/ViewModel.cs | 28 +++++++++- .../csla.netcore.test/Mvvm/MvvmTestObjects.cs | 41 ++++++++++++++ .../csla.netcore.test/Mvvm/ViewModelTests.cs | 54 +++++++++++++++++++ docs/Upgrading to CSLA 11.md | 2 +- 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/Source/Csla/Mvvm/ViewModel.cs b/Source/Csla/Mvvm/ViewModel.cs index 7110f07933..ece6376e80 100644 --- a/Source/Csla/Mvvm/ViewModel.cs +++ b/Source/Csla/Mvvm/ViewModel.cs @@ -492,10 +492,35 @@ protected virtual void OnSetProperties() 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; @@ -520,7 +545,7 @@ private async Task SetPropertiesAsync() // a newer call has started (or the model changed) while awaiting if (version != _setPropertiesVersion || !ReferenceEquals(model, Model)) - return; + return false; // the viewmodel is busy while it saves a clone of the model, // even though the model itself is idle @@ -553,6 +578,7 @@ private async Task SetPropertiesAsync() CanRemove = false; CanAddNew = false; } + return true; } private Type? _childItemTypeSource; diff --git a/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs b/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs index 75421babf7..5e1ab6be32 100644 --- a/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs +++ b/Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs @@ -189,6 +189,47 @@ 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 { diff --git a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs index 2b3f4d1781..d0c46fd9fd 100644 --- a/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs +++ b/Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs @@ -45,6 +45,17 @@ public void ParameterlessConstructor_WithoutContext_Throws() 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() { @@ -425,6 +436,49 @@ public async Task PermissionRuleFailure_IsReportedThroughError() 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() { diff --git a/docs/Upgrading to CSLA 11.md b/docs/Upgrading to CSLA 11.md index 218f3fd012..bcf93ad4ac 100644 --- a/docs/Upgrading to CSLA 11.md +++ b/docs/Upgrading to CSLA 11.md @@ -240,7 +240,7 @@ Because every view model now has the same behavior, some of them behave differen * `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. Before, the XAML view model posted it to the `SynchronizationContext`, which crashes a desktop app that has no global exception handler. +* 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.