Introduction
This article discusses an approach on how to make a WPF UI more user friendly by adapting warning dialogs to the way the user uses them. This particular way of changing the way the UI behaves depending on the user's preference is not new at all. The method suggested in this article focuses on how to achieve it using an implementation of ICommand
.
Background
When I hit F5 in Visual Studio when I still have compiler errors in the code, a dialog like this appears:
The highlighted option allows me to tell the IDE that I never want to run the last successful build. I find this feature very useful because the option to disable the warning is local to the action. That means that I do not have to find the setting for it in some preference page. It also tells me that the option to disable the warning even exists without me having to go look for such a setting.
This article aims to show how such behaviour can be added to almost any WPF command without too much hassle. It will also show how the responsibility of remembering the warnings across instances of the application or even across applications can be abstracted away from the logic spawning the warning in the first place.
This is important to me as I've seen a lot of examples trying to achieve the same functionality but succeeding only in messing up the inner cohesion of the view model owning the command.
Using the Code
Download the solution, unzip and open. The solution has a class library and a WPF test app showing of a very simple sample implementation. It's all been written using VS2010 Express Edition.
The Problem
Let's assume that there exists a command that will write some data to a file on the press of a button in some UI. The command will create the target file if it does not exist and it will overwrite the existing file if it does exist.
In such a scenario, it is reasonable to warn the user about the file being created (although it's not that important as the user should expect this), and also (more importantly) that the file will be overwritten. Other warnings may also apply such as a bad or weird filename being used.
The implementation of such a command in a view model might look something like this:
public class ViewModel
{
public ICommand SaveFileCommand { get; private set; }
public ViewModel()
{
SaveFileCommand = new SomeCommand(x => true, SaveFile);
}
private void SaveFile(object parameter)
{
if (File.Exists(Filename))
{
MessageBoxResult result = MessageBox.Show(
"This will overwrite the file, are you sure you want to do this?", #
"Warning",
MessageBoxButton.YesNo);
if (result == MessageBoxResult.No)
return;
}
else
{
MessageBoxResult result = MessageBox.Show(
"This will create a new file, are you sure you want to do this?",
"Warning",
MessageBoxButton.YesNo);
if (result == MessageBoxResult.No)
return;
}
File.WriteAllText(Filename, "Go do that voodoo that you do so well.");
}
}
Assuming SomeCommand
is a class implementing ICommand
in a reasonable way.
The problem with this approach, as I see it, is that it adds very UI specific elements to the view model. Sure, the creation of the dialogs could be, and should be, abstracted using some interface to allow the view model to be unit tested, but that doesn't change the fact that the creation of and checking result of the dialog is the responsibility of the view model. I think that breaks a fundamental pillar of good design; high inner cohesion.
Also, notice that I haven't even taken into account that the user should be able to permanently ignore the warnings, and that such a decision needs to be remembered even if the application is closed and restarted. That implies that the preference needs to be stored somewhere, which means it has to also be loaded from somewhere at start up. If all that code has to go in to the view model, then another fundamental pillar of good design is broken; low coupling.
My Approach
The approach I've gone for moves the responsibility of loading and persisting, as well as ignoring new warnings to the ICommand
implementation. This means that the view model is left with the responsibility of notifying the ICommand
implementation that a warning has triggered, something that is business logic central and should be placed in the view model. I call my class implementing ICommand
a TolerantCommand
.
In my approach, the view model notifies the TolerantCommand
using exceptions.
This means that the showing of the dialog is the responsibility of the TolerantCommand
as well, but since this can be done in very different ways depending on the application, I decided to abstract that away using an interface called IDialogDisplayer
.
The responsibility of persisting and loading the preferences is also abstracted in the same manner using the IWarningRepository
interface.
If you think this flowchart looks weird, it's because Google doesn't support angled connectors yet in docs.
It's obvious from this flowchart that no state whatsoever can be changed by a command that aborts early due to a warning or an error, since the command may be executed any number of times for any one invocation of the ICommand.Execute
. Essentially, this means that the structure of the command implementation has to be something like this:
if HasWarning_A && IsNotIgnored(A)
throw Warning("A");
if HasWarning_B && IsNotIgnored(B)
throw Warning("B");
SomeLogic();
The Implementation
IWarningRepository
The definition of IWarningRepository
is fairly simple since it needs to be able to do only three things:
- Provide a list of currently ignored warnings
- Ignore a warning
- Acknowledge a warning (un-ignore it)
To acknowledge a warning isn't something that is done using the dialog, it's something that should be available through some preference page. I've left that implementation out in this sample because it's very application specific.
namespace Bornander.UI.Commands
{
public interface IWarningRepository<T>
{
IEnumerable<T> Ignored { get; }
void Ignore(T warning);
void Acknowledge(T warning);
}
}
Notice that this is a generic interface taking a type parameter T
. This is because I don't think warning definitions are necessarily always the same for all applications. In some cases, an int
will make sense, in another a string
or an enum
. It all depends on the application being written. In the sample app, I've gone for an enumeration but the implementation caters for any type.
IDialogDisplayer
The IDialogDisplayer
is responsible for displaying the dialog (duh!), and returning back a result indicating what the user pressed. Much like the standard MessageBox
and MessageBoxResult
.
Typically, an implementation of this interface would be very minimal, pretty much only creating some sort of dialog window and depending on what the user selects, just return the value to the TolerantCommand
. Regardless of what the user selects, the result is just returned because the responsibility of retrying or requesting the preference to be persisted is down to the command.
namespace Bornander.UI.Commands.Tolerant
{
public enum DialogResult
{
Yes,
YesAndRememberMyDecision,
No
}
public interface IDialogDisplayer
{
DialogResult ShowWarning(CommandWarningException warning);
DialogResult ShowError(CommandRetryableErrorException error);
}
}
The interface exposes three methods for the two different scenarios that require a dialog to be shown:
ShowWarning(CommandWarningException)
for when a warning triggers.
ShowError(CommandRetryableErrorException)
for when an transient error triggers that might work if the user tries again (like a file being locked by someone else).
In the sample application bundled with this article, the dialog looks like this:
But that look is not in any way dictated by the command implementation or the supporting types, the IDialogDisplayer
can delegate the actual showing of the dialog to any type of visualisation. I think this is an important aspect because the logic that drives retry-able commands should be completely decoupled from any UI specific code.
TolerantCommand
The TolerantCommand
is essentially the same as the RelayCommand
from this article but with added logic to the ICommand.Execute
implementation. The Execute
method is responsible to inspect any exceptions thrown by the execute delegate and to spawn a dialog by delegating to the IDialogDisplayer
while also employing the IWarningRepository
to figure out which warnings to ignore or persist.
Silent Execution
Since I sometimes run into the need to execute one or several commands programmatically (i.e. not as the result of a user action), the TolerantCommand
supports a Silent execution mode where any non-ignored warnings cause the command to abort without spawning any dialogs. Granted, this kind of behaviour is for the most part overkill but I've decided to include it in this article anyway.
When constructing the TolerantCommand
, both a IDialogDisplayer
and a IWarningrepository
instance has to be passed as well as the execute delegate and can execute predicate:
public TolerantCommand(IDialogDisplayer dialogDisplayer,
IWarningRepository<T> repository,
Predicate<object> canExecute,
Action<object, IEnumerable<T>> execute)
{
if (execute == null)
throw new ArgumentNullException("execute");
this.dialogDisplayer = dialogDisplayer;
this.repository = repository;
CanExecutePredicate = canExecute;
ExecuteAction = execute;
}
The execute delegate takes not one (as in the RelayCommand
implementation), but two parameters; one which is the actual command parameter, and one list of ignored warnings.
The Execute
method is essentially going into a loop, trying to execute the execute delegate until successful or until the user aborts due to a warning.
public void Execute(object parameter)
{
bool isSilent = parameter is ExecuteSilent;
object actualParameter = isSilent ? ((ExecuteSilent)parameter).Parameter : parameter;
IList<T> localIgnorableWarnings = new List<T>(
repository != null ?
repository.Ignored : new T[0]);
while (true)
{
try
{
ExecuteAction(actualParameter,
isSilent && ((ExecuteSilent)parameter).IgnoreAllWarnings ?
null :
localIgnorableWarnings);
return;
}
catch (CommandWarningException warning)
{
if (isSilent)
return;
switch (dialogDisplayer.ShowWarning(warning))
{
case DialogResult.No:
return;
case DialogResult.YesAndRememberMyDecision:
if (repository != null)
repository.Ignore((T)warning.Warning);
break;
}
localIgnorableWarnings.Add((T)warning.Warning);
}
catch (CommandRetryableErrorException error)
{
if (isSilent)
return;
if (dialogDisplayer.ShowError(error) == DialogResult.No)
return;
}
}
}
The ExecuteSilent
class is a wrapper class that wraps a command parameter allowing a parameter to be passed while still indicating that this execution is silent. Since the ICommand.Execute
method takes only a single parameter, a construct like this is required to cope with the silent execution.
The Result
The resulting command implementation for the example given at the beginning of this article using TolerantCommand
s then becomes:
private void SaveFile(object parameter, IEnumerable<Warnings> ignorableWarnings)
{
if (File.Exists(Filename) &&
!TolerantCommand<Warnings>.IsWarningIgnored
(Warnings.OverwriteFile, ignorableWarnings))
{
throw new CommandWarningException(
String.Format("This will overwrite the file \"{0}\",
are you sure you want to do that?", Filename),
Warnings.OverwriteFile);
}
if (!Filename.Contains('.') &&
!TolerantCommand<Warnings>.IsWarningIgnored(Warnings.NoFileSuffix, ignorableWarnings))
{
throw new CommandWarningException(
String.Format("The filename
\"{0}\" has no file suffix, are you sure you want keep it like that?", Filename),
Warnings.NoFileSuffix);
}
File.WriteAllText(Filename, "Go do that voodoo that you do so well.");
}
Admittedly, it doesn't look like it's that much less code, but that is mainly because the initial example didn't take persisting the preferences into account. Also, the intention was to create a sensible separation of concern where the command implementation is responsible for executing the command logic, not handling UI elements or storing user preferences, and I think this approach achieves that nicely.
Points of Interest
The sample project includes implementations of the IDialogDisplayer
and IWarningRepository
interfaces, but note that they're just that; sample implementations. The whole point in abstracting these elements using interfaces is that there's no way to create implementation generic enough to cater to all applications.
The IWarningRepository
sample implementation for example, EnumRepository
, handles warnings defined as enum
s and persists them local to the user and application. But this might not be what suits your application where warnings might be defined as int
s or string
s, and the preferences might need to be stored in a different way.
Thanks
Thanks to George Barbu for reviewing and commenting on this article, he had some very valid points around the exception handling.
History
- 2011-02-27: First version