web-dev-qa-db-ja.com

WPF MVVMモーダルオーバーレイダイアログ(ウィンドウではなく)ビューのみ

私はMVVMアーキテクチャ設計にかなり慣れていません...

私は最近、そのような目的のために既に記述された適切なコントロールを見つけるのに苦労していましたが、運が悪かったため、別の同様のコントロールからXAMLの一部を再利用し、独自に作成しました。

私が達成したいのは:

再利用可能なビュー(usercontrol)+ビューモデル(バインド先)を用意し、他のビューの内部で、残りのビューを無効にし、その上にダイアログを表示するダイアログを表示するモーダルオーバーレイとして使用できるようにします。

enter image description here

私がそれを実装したかった方法:

  • 文字列(メッセージ)とアクション+文字列コレクション(ボタン)を取るviewmodelを作成します
  • viewmodelは、これらのアクションを呼び出すICommandsのコレクションを作成します
  • ダイアログビューは、別のビューモデル(親)のプロパティとして公開されるビューモデルにバインドします。
  • ダイアログビューは次のように親のxamlに入れられます:

pseudoXAML:

    <usercontrol /customerview/ ...>
       <grid>
         <grid x:Name="content">
           <various form content />
         </grid>
         <ctrl:Dialog DataContext="{Binding DialogModel}" Message="{Binding Message}" Commands="{Binding Commands}" IsShown="{Binding IsShown}" BlockedUI="{Binding ElementName=content}" />
      </grid>
    </usercontrol>

したがって、ここでモーダルダイアログは、CustomerビューモデルのDialogModelプロパティからデータコンテキストを取得し、コマンドとメッセージをバインドします。また、ダイアログが表示されたときに無効にする必要がある他の要素(ここでは「コンテンツ」)にバインドされます(IsShownへのバインド)。ダイアログのボタンをクリックすると、関連するコマンドが呼び出され、viewmodelのコンストラクターで渡された関連アクションが呼び出されます。

このようにして、Customerビューモデル内からダイアログビューモデルのダイアログのShow()およびHide()を呼び出し、必要に応じてダイアログビューモデルを変更できます。

一度に1つのダイアログしか表示されませんが、それで問題ありません。ユニットテストは、コンストラクターでアクションを使用して作成した後に作成する必要があるコマンドの呼び出しをカバーするため、ダイアログビューモデルはユニットテスト可能のままであると私は思います。ダイアログビューには数行のコードビハインドがありますが、非常に少なく、かなりばかげています(セッターゲッター、ほとんどコードなし)。

私が気になるのは:

これでいい?私が遭遇する可能性のある問題はありますか?これは一部のMVVM原則に違反しますか?

どうもありがとう!

編集:私は完全なソリューションを投稿したので、あなたはよりよく見えるようにすることができます。建築コメントは歓迎します。修正できる構文がある場合は、投稿にコミュニティWikiのフラグが付けられます。

28
Marino Šimić

まあ私の質問への正確な答えではありませんが、ここにこのダイアログを実行した結果があり、必要に応じて使用できるようにコードを完備しています-無料のスピーチやビールのように無料です:

MVVM dialog modal only inside the containing view

別のビュー(ここではCustomerView)でのXAMLの使用法):

<UserControl 
  x:Class="DemoApp.View.CustomerView"
  xmlns="http://schemas.Microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.Microsoft.com/winfx/2006/xaml"
  xmlns:controls="clr-namespace:DemoApp.View"
  >
  <Grid>
    <Grid Margin="4" x:Name="ModalDialogParent">
      <put all view content here/>
    </Grid>
    <controls:ModalDialog DataContext="{Binding Dialog}" OverlayOn="{Binding ElementName=ModalDialogParent, Mode=OneWay}" IsShown="{Binding Path=DialogShown}"/>    
  </Grid>        
</UserControl>

親ViewModel(ここではCustomerViewModel)からトリガー:

  public ModalDialogViewModel Dialog // dialog view binds to this
  {
      get
      {
          return _dialog;
      }
      set
      {
          _dialog = value;
          base.OnPropertyChanged("Dialog");
      }
  }

  public void AskSave()
    {

        Action OkCallback = () =>
        {
            if (Dialog != null) Dialog.Hide();
            Save();
        };

        if (Email.Length < 10)
        {
            Dialog = new ModalDialogViewModel("This email seems a bit too short, are you sure you want to continue saving?",
                                            ModalDialogViewModel.DialogButtons.Ok,
                                            ModalDialogViewModel.CreateCommands(new Action[] { OkCallback }));
            Dialog.Show();
            return;
        }

        if (LastName.Length < 2)
        {

            Dialog = new ModalDialogViewModel("The Lastname seems short. Are you sure that you want to save this Customer?",
                                              ModalDialogViewModel.CreateButtons(ModalDialogViewModel.DialogMode.TwoButton,
                                                                                 new string[] {"Of Course!", "NoWay!"},
                                                                                 OkCallback,
                                                                                 () => Dialog.Hide()));

            Dialog.Show();
            return;
        }

        Save(); // if we got here we can save directly
    }

これがコードです:

ModalDialogView XAML:

    <UserControl x:Class="DemoApp.View.ModalDialog"
        xmlns="http://schemas.Microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.Microsoft.com/winfx/2006/xaml"
        x:Name="root">
        <UserControl.Resources>
            <ResourceDictionary Source="../MainWindowResources.xaml" />
        </UserControl.Resources>
        <Grid>
            <Border Background="#90000000" Visibility="{Binding Visibility}">
                <Border BorderBrush="Black" BorderThickness="1" Background="AliceBlue" 
                        CornerRadius="10,0,10,0" VerticalAlignment="Center"
                        HorizontalAlignment="Center">
                    <Border.BitmapEffect>
                        <DropShadowBitmapEffect Color="Black" Opacity="0.5" Direction="270" ShadowDepth="0.7" />
                    </Border.BitmapEffect>
                    <Grid Margin="10">
                        <Grid.RowDefinitions>
                            <RowDefinition />
                            <RowDefinition />
                            <RowDefinition Height="Auto" />
                        </Grid.RowDefinitions>
                        <TextBlock Style="{StaticResource ModalDialogHeader}" Text="{Binding DialogHeader}" Grid.Row="0"/>
                        <TextBlock Text="{Binding DialogMessage}" Grid.Row="1" TextWrapping="Wrap" Margin="5" />
                        <StackPanel HorizontalAlignment="Stretch" VerticalAlignment="Bottom" Grid.Row="2">
                            <ContentControl HorizontalAlignment="Stretch"
                              DataContext="{Binding Commands}"
                              Content="{Binding}"
                              ContentTemplate="{StaticResource ButtonCommandsTemplate}"
                              />
                        </StackPanel>
                    </Grid>
                </Border>
            </Border>
        </Grid>

    </UserControl>

ModalDialogViewコードビハインド:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;

namespace DemoApp.View
{
    /// <summary>
    /// Interaction logic for ModalDialog.xaml
    /// </summary>
    public partial class ModalDialog : UserControl
    {
        public ModalDialog()
        {
            InitializeComponent();
            Visibility = Visibility.Hidden;
        }

        private bool _parentWasEnabled = true;

        public bool IsShown
        {
            get { return (bool)GetValue(IsShownProperty); }
            set { SetValue(IsShownProperty, value); }
        }

        // Using a DependencyProperty as the backing store for IsShown.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty IsShownProperty =
            DependencyProperty.Register("IsShown", typeof(bool), typeof(ModalDialog), new UIPropertyMetadata(false, IsShownChangedCallback));

        public static void IsShownChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if ((bool)e.NewValue == true)
            {
                ModalDialog dlg = (ModalDialog)d;
                dlg.Show();
            }
            else
            {
                ModalDialog dlg = (ModalDialog)d;
                dlg.Hide();
            }
        }

        #region OverlayOn

        public UIElement OverlayOn
        {
            get { return (UIElement)GetValue(OverlayOnProperty); }
            set { SetValue(OverlayOnProperty, value); }
        }

        // Using a DependencyProperty as the backing store for Parent.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty OverlayOnProperty =
            DependencyProperty.Register("OverlayOn", typeof(UIElement), typeof(ModalDialog), new UIPropertyMetadata(null));

        #endregion

        public void Show()
        {

            // Force recalculate binding since Show can be called before binding are calculated            
            BindingExpression expressionOverlayParent = this.GetBindingExpression(OverlayOnProperty);
            if (expressionOverlayParent != null)
            {
                expressionOverlayParent.UpdateTarget();
            }

            if (OverlayOn == null)
            {
                throw new InvalidOperationException("Required properties are not bound to the model.");
            }

            Visibility = System.Windows.Visibility.Visible;

            _parentWasEnabled = OverlayOn.IsEnabled;
            OverlayOn.IsEnabled = false;           

        }

        private void Hide()
        {
            Visibility = Visibility.Hidden;
            OverlayOn.IsEnabled = _parentWasEnabled;
        }

    }
}

ModalDialogViewModel:

using System;
using System.Windows.Input;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Windows;
using System.Linq;

namespace DemoApp.ViewModel
{

    /// <summary>
    /// Represents an actionable item displayed by a View (DialogView).
    /// </summary>
    public class ModalDialogViewModel : ViewModelBase
    {

        #region Nested types

        /// <summary>
        /// Nested enum symbolizing the types of default buttons used in the dialog -> you can localize those with Localize(DialogMode, string[])
        /// </summary>
        public enum DialogMode
        {
            /// <summary>
            /// Single button in the View (default: OK)
            /// </summary>
            OneButton = 1,
            /// <summary>
            /// Two buttons in the View (default: YesNo)
            /// </summary>
            TwoButton,
            /// <summary>
            /// Three buttons in the View (default: AbortRetryIgnore)
            /// </summary>
            TreeButton,
            /// <summary>
            /// Four buttons in the View (no default translations, use Translate)
            /// </summary>
            FourButton,
            /// <summary>
            /// Five buttons in the View (no default translations, use Translate)
            /// </summary>
            FiveButton
        }

        /// <summary>
        /// Provides some default button combinations
        /// </summary>
        public enum DialogButtons
        {
            /// <summary>
            /// As System.Window.Forms.MessageBoxButtons Enumeration Ok
            /// </summary>
            Ok,
            /// <summary>
            /// As System.Window.Forms.MessageBoxButtons Enumeration OkCancel
            /// </summary>
            OkCancel,
            /// <summary>
            /// As System.Window.Forms.MessageBoxButtons Enumeration YesNo
            /// </summary>
            YesNo,
            /// <summary>
            /// As System.Window.Forms.MessageBoxButtons Enumeration YesNoCancel
            /// </summary>
            YesNoCancel,
            /// <summary>
            /// As System.Window.Forms.MessageBoxButtons Enumeration AbortRetryIgnore
            /// </summary>
            AbortRetryIgnore,
            /// <summary>
            /// As System.Window.Forms.MessageBoxButtons Enumeration RetryCancel
            /// </summary>
            RetryCancel
        }

        #endregion

        #region Members

        private static Dictionary<DialogMode, string[]> _translations = null;

        private bool _dialogShown;
        private ReadOnlyCollection<CommandViewModel> _commands;
        private string _dialogMessage;
        private string _dialogHeader;

        #endregion

        #region Class static methods and constructor

        /// <summary>
        /// Creates a dictionary symbolizing buttons for given dialog mode and buttons names with actions to berform on each
        /// </summary>
        /// <param name="mode">Mode that tells how many buttons are in the dialog</param>
        /// <param name="names">Names of buttons in sequential order</param>
        /// <param name="callbacks">Callbacks for given buttons</param>
        /// <returns></returns>
        public static Dictionary<string, Action> CreateButtons(DialogMode mode, string[] names, params Action[] callbacks) 
        {
            int modeNumButtons = (int)mode;

            if (names.Length != modeNumButtons)
                throw new ArgumentException("The selected mode needs a different number of button names", "names");

            if (callbacks.Length != modeNumButtons)
                throw new ArgumentException("The selected mode needs a different number of callbacks", "callbacks");

            Dictionary<string, Action> buttons = new Dictionary<string, Action>();

            for (int i = 0; i < names.Length; i++)
            {
                buttons.Add(names[i], callbacks[i]);
            }

            return buttons;
        }

        /// <summary>
        /// Static contructor for all DialogViewModels, runs once
        /// </summary>
        static ModalDialogViewModel()
        {
            InitTranslations();
        }

        /// <summary>
        /// Fills the default translations for all modes that we support (use only from static constructor (not thread safe per se))
        /// </summary>
        private static void InitTranslations()
        {
            _translations = new Dictionary<DialogMode, string[]>();

            foreach (DialogMode mode in Enum.GetValues(typeof(DialogMode)))
            {
                _translations.Add(mode, GetDefaultTranslations(mode));
            }
        }

        /// <summary>
        /// Creates Commands for given enumeration of Actions
        /// </summary>
        /// <param name="actions">Actions to create commands from</param>
        /// <returns>Array of commands for given actions</returns>
        public static ICommand[] CreateCommands(IEnumerable<Action> actions)
        {
            List<ICommand> commands = new List<ICommand>();

            Action[] actionArray = actions.ToArray();

            foreach (var action in actionArray)
            {
                //RelayExecuteWrapper rxw = new RelayExecuteWrapper(action);
                Action act = action;
                commands.Add(new RelayCommand(x => act()));
            }

            return commands.ToArray();
        }

        /// <summary>
        /// Creates string for some predefined buttons (English)
        /// </summary>
        /// <param name="buttons">DialogButtons enumeration value</param>
        /// <returns>String array for desired buttons</returns>
        public static string[] GetButtonDefaultStrings(DialogButtons buttons)
        {
            switch (buttons)
            {
                case DialogButtons.Ok:
                    return new string[] { "Ok" };
                case DialogButtons.OkCancel:
                    return new string[] { "Ok", "Cancel" };
                case DialogButtons.YesNo:
                    return new string[] { "Yes", "No" };
                case DialogButtons.YesNoCancel:
                    return new string[] { "Yes", "No", "Cancel" };
                case DialogButtons.RetryCancel:
                    return new string[] { "Retry", "Cancel" };
                case DialogButtons.AbortRetryIgnore:
                    return new string[] { "Abort", "Retry", "Ignore" };
                default:
                    throw new InvalidOperationException("There are no default string translations for this button configuration.");
            }
        }

        private static string[] GetDefaultTranslations(DialogMode mode)
        {
            string[] translated = null;

            switch (mode)
            {
                case DialogMode.OneButton:
                    translated = GetButtonDefaultStrings(DialogButtons.Ok);
                    break;
                case DialogMode.TwoButton:
                    translated = GetButtonDefaultStrings(DialogButtons.YesNo);
                    break;
                case DialogMode.TreeButton:
                    translated = GetButtonDefaultStrings(DialogButtons.YesNoCancel);
                    break;
                default:
                    translated = null; // you should use Translate() for this combination (ie. there is no default for four or more buttons)
                    break;
            }

            return translated;
        }

        /// <summary>
        /// Translates all the Dialogs with specified mode
        /// </summary>
        /// <param name="mode">Dialog mode/type</param>
        /// <param name="translations">Array of translations matching the buttons in the mode</param>
        public static void Translate(DialogMode mode, string[] translations)
        {
            lock (_translations)
            {
                if (translations.Length != (int)mode)
                    throw new ArgumentException("Wrong number of translations for selected mode");

                if (_translations.ContainsKey(mode))
                {
                    _translations.Remove(mode);
                }

                _translations.Add(mode, translations);

            }
        }

        #endregion

        #region Constructors and initialization

        public ModalDialogViewModel(string message, DialogMode mode, params ICommand[] commands)
        {
            Init(message, Application.Current.MainWindow.GetType().Assembly.GetName().Name, _translations[mode], commands);
        }

        public ModalDialogViewModel(string message, DialogMode mode, params Action[] callbacks)
        {
            Init(message, Application.Current.MainWindow.GetType().Assembly.GetName().Name, _translations[mode], CreateCommands(callbacks));
        }

        public ModalDialogViewModel(string message, Dictionary<string, Action> buttons)
        {
            Init(message, Application.Current.MainWindow.GetType().Assembly.GetName().Name, buttons.Keys.ToArray(), CreateCommands(buttons.Values.ToArray()));
        }

        public ModalDialogViewModel(string message, string header, Dictionary<string, Action> buttons)
        {
            if (buttons == null)
                throw new ArgumentNullException("buttons");

            ICommand[] commands = CreateCommands(buttons.Values.ToArray<Action>());

            Init(message, header, buttons.Keys.ToArray<string>(), commands);
        }

        public ModalDialogViewModel(string message, DialogButtons buttons, params ICommand[] commands)
        {
            Init(message, Application.Current.MainWindow.GetType().Assembly.GetName().Name, ModalDialogViewModel.GetButtonDefaultStrings(buttons), commands);
        }

        public ModalDialogViewModel(string message, string header, DialogButtons buttons, params ICommand[] commands)
        {
            Init(message, header, ModalDialogViewModel.GetButtonDefaultStrings(buttons), commands);
        }

        public ModalDialogViewModel(string message, string header, string[] buttons, params ICommand[] commands)
        {
            Init(message, header, buttons, commands);
        }

        private void Init(string message, string header, string[] buttons, ICommand[] commands)
        {
            if (message == null)
                throw new ArgumentNullException("message");

            if (buttons.Length != commands.Length)
                throw new ArgumentException("Same number of buttons and commands expected");

            base.DisplayName = "ModalDialog";
            this.DialogMessage = message;
            this.DialogHeader = header;

            List<CommandViewModel> commandModels = new List<CommandViewModel>();

            // create commands viewmodel for buttons in the view
            for (int i = 0; i < buttons.Length; i++)
            {
                commandModels.Add(new CommandViewModel(buttons[i], commands[i]));
            }

            this.Commands = new ReadOnlyCollection<CommandViewModel>(commandModels);

        }

        #endregion

                                                                                                                                                                                                                                                            #region Properties

    /// <summary>
    /// Checks if the dialog is visible, use Show() Hide() methods to set this
    /// </summary>
    public bool DialogShown
    {
        get
        {
            return _dialogShown;
        }
        private set
        {
            _dialogShown = value;
            base.OnPropertyChanged("DialogShown");
        }
    }

    /// <summary>
    /// The message shown in the dialog
    /// </summary>
    public string DialogMessage
    {
        get
        {
            return _dialogMessage;
        }
        private set
        {
            _dialogMessage = value;
            base.OnPropertyChanged("DialogMessage");
        }
    }

    /// <summary>
    /// The header (title) of the dialog
    /// </summary>
    public string DialogHeader
    {
        get
        {
            return _dialogHeader;
        }
        private set
        {
            _dialogHeader = value;
            base.OnPropertyChanged("DialogHeader");
        }
    }

    /// <summary>
    /// Commands this dialog calls (the models that it binds to)
    /// </summary>
    public ReadOnlyCollection<CommandViewModel> Commands
    {
        get
        {
            return _commands;
        }
        private set
        {
            _commands = value;
            base.OnPropertyChanged("Commands");
        }
    }

    #endregion

        #region Methods

        public void Show()
        {
            this.DialogShown = true;
        }

        public void Hide()
        {
            this._dialogMessage = String.Empty;
            this.DialogShown = false;
        }

        #endregion
    }
}

ViewModelBaseには以下が含まれます。

public virtual string DisplayName { get; protected set; }

INotifyPropertyChangedを実装します

リソースディクショナリに入れるリソース:

<!--
This style gives look to the dialog head (used in the modal dialog)
-->
<Style x:Key="ModalDialogHeader" TargetType="{x:Type TextBlock}">
    <Setter Property="Background" Value="{StaticResource Brush_HeaderBackground}" />
    <Setter Property="Foreground" Value="White" />
    <Setter Property="Padding" Value="4" />
    <Setter Property="HorizontalAlignment" Value="Stretch" />
    <Setter Property="Margin" Value="5" />
    <Setter Property="TextWrapping" Value="NoWrap" />
</Style>

<!--
This template explains how to render the list of commands as buttons (used in the modal dialog)
-->
<DataTemplate x:Key="ButtonCommandsTemplate">
    <ItemsControl IsTabStop="False" ItemsSource="{Binding}" Margin="6,2">
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <Button MinWidth="75" Command="{Binding Path=Command}" Margin="4" HorizontalAlignment="Right">
                    <TextBlock Text="{Binding Path=DisplayName}" Margin="2"></TextBlock>
                </Button>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" />
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
    </ItemsControl>
</DataTemplate>
20
Marino Šimić

GitHub ページにカスタムオープンソースFrameworkElementがあり、プライマリコンテンツの上にモーダルコンテンツを表示できます。

コントロールは次のように使用できます。

<c:ModalContentPresenter IsModal="{Binding DialogIsVisible}">
    <TabControl Margin="5">
            <Button Margin="55"
                    Padding="10"
                    Command="{Binding ShowModalContentCommand}">
                This is the primary Content
            </Button>
        </TabItem>
    </TabControl>

    <c:ModalContentPresenter.ModalContent>
        <Button Margin="75"
                Padding="50"
                Command="{Binding HideModalContentCommand}">
            This is the modal content
        </Button>
    </c:ModalContentPresenter.ModalContent>

</c:ModalContentPresenter>

特徴:

  • 任意のコンテンツを表示します。
  • モーダルコンテンツが表示されている間は、プライマリコンテンツを無効にしません。
  • モーダルコンテンツが表示されている間、マウスとキーボードによるプライマリコンテンツへのアクセスを無効にします。
  • アプリケーション全体ではなく、対象となるコンテンツにモーダルである。
  • IsModalプロパティにバインドすることにより、MVVMフレンドリーな方法で使用できます。
9
Benjamin Gale

以下のサンプルコードの行に沿って、ViewModelに挿入されるサービスとしてこれに取り組みます。あなたがやりたいことは、実際にはメッセージボックスの動作ですが、私のサービス実装ではMessageBoxを使用します!

コンセプトを示すためにKISSここを使用しています。背後にコードはなく、図のように完全にユニットテスト可能です。

余談ですが、あなたが取り組んでいるジョシュ・スミスの例は、たとえそれがカバーしていなくても、私にとっても非常に役に立ちましたすべて

HTH、
ベリー

/// <summary>
/// Simple interface for visually confirming a question to the user
/// </summary>
public interface IConfirmer
{
    bool Confirm(string message, string caption);
}

public class WPFMessageBoxConfirmer : IConfirmer
{
    #region Implementation of IConfirmer

    public bool Confirm(string message, string caption) {
        return MessageBox.Show(message, caption, MessageBoxButton.YesNo) == MessageBoxResult.Yes;
    }

    #endregion
}

// SomeViewModel uses an IConfirmer
public class SomeViewModel
{

    public ShellViewModel(ISomeRepository repository, IConfirmer confirmer) 
    {
        if (confirmer == null) throw new ArgumentNullException("confirmer");
        _confirmer = confirmer;

        ...
    }
    ...

    private void _delete()
    {
        var someVm = _masterVm.SelectedItem;
        Check.RequireNotNull(someVm);

        if (detailVm.Model.IsPersistent()) {
            var msg = string.Format(GlobalCommandStrings.ConfirmDeletion, someVm.DisplayName);
            if(_confirmer.Confirm(msg, GlobalCommandStrings.ConfirmDeletionCaption)) {
                _doDelete(someVm);
            }
        }
        else {
            _doDelete(someVm);
        }
    }
    ...
}

// usage in the Production code 
var vm = new SomeViewModel(new WPFMessageBoxConfirmer());

// usage in a unit test
[Test]
public void DeleteCommand_OnExecute_IfUserConfirmsDeletion_RemovesSelectedItemFrom_Workspaces() {
    var confirmerMock = MockRepository.GenerateStub<IConfirmer>();
    confirmerMock.Stub(x => x.Confirm(Arg<string>.Is.Anything, Arg<string>.Is.Anything)).Return(true);
    var vm = new ShellViewModel(_repository, _crudConverter, _masterVm, confirmerMock, _validator);

    vm.EditCommand.Execute(null);
    Assert.That(vm.Workspaces, Has.Member(_masterVm.SelectedItem));
    Assert.That(vm.Workspaces, Is.Not.Empty);

    vm.DeleteCommand.Execute(null);
    Assert.That(vm.Workspaces, Has.No.Member(_masterVm.SelectedItem));
    Assert.That(vm.Workspaces, Is.Empty);
}
1
Berryl