Feed Icon RSS 1.0 XML Feed available

One User Event, One (or Zero) Error Messages

Date: 30-Dec-2007/23:37

Tags:

Characters: (none)

In a previous post, I laid out why a command processor should have a whole-system (or "holistic") philosophy to ensure that Undo/Redo Actions are Tied to a Single User Event. Please see that first, because in this entry I'm going to describe a closely related idea:
For each command a user runs, they should get at most one error report.
To be able to enforce this rule, you need to prevent arbitrary pieces of your application from communicating with the user through calls to MessageBox (or similar functions). Commands must be sandboxed in such a way that they run silently. The only way of communicating any "dissatisfaction" should be by bubbling up an error message to the outermost invocation of the command processor.
A question arises: If the user is to only receive one error message for a composite action, how can this single message possibly convey the full depth of what went wrong? The answer is that this single error must be able to box up the chain of causes into some kind of object.
The result could possibly be so descriptive that it takes pages to report. Yet the issue is not about the amount of error data; that can be mitigated with a good exploratory interface. It is rather that the error presentation must be confined to the moment when the command is completely done with its failure, and ready to return control to the user.
Conventional error handling in a program that lacks a "systematic" approach like what I am describing might look a little bit like this:
bool DoImportantThing() {
    int error_code = ImportantFunction();
    switch (error_code) {
        case E_NOERROR:
            return true;
        case E_INVALIDUSER:
            DisplayError("Invalid User");
            return false;
    }
}

void TopLevelFunction() {
    int error_code = AnotherImportantFunction();
    switch (error_code) {
       case E_NOERROR:
           break;
       case E_OUTOFMEMORY:
           DisplayError("Out of Memory");
           break;
      }

    if (not DoImportantThing()) {
        DisplayError("Important Thing Failed");
        return;
    }
}
Using this methodology in your program means that the user might see multiple error message dialogs, when they ran what they considered to be only a single command. That's the exact situation I'm suggesting to avoid.
Note I know my argument is not "new" per se. Yet the majority of systems don't follow the rule. So it must not be understood, or at least ignored, by some. Maybe my way of saying it will break through to someone! :-/
When advocating a sane undo/redo architecture, I said you needed distinct user actions (as measured by the their concept of "one thing I did") to be bracketed by some form of BeginUndoGroup() and EndUndoGroup() calls. Then I pointed out that while nesting these in some sort of transaction group may seem technologically feasible, it violates the very premise we are discussing. By definition these calls should never nest, because that would suggest the user took an undoable action while they were taking an undoable action!
I'll now propose the corresponding pattern that gives us the "at most one error message per action" we want. It's simply that the EndUndoGroup() takes a parameter: an error object. This error object represents the complete description of any errors which might have happened in this user triggered event, and it is EndUndoGroup()'s job to present that error to the user.
Let us look at a simple implementation of this, in pseudocode I don't feel like testing at the moment. :-)
class Error : public std::string {
public:
    Error () {}
    Error (std::string init) : std::string (init) {}
    static Error becauseOf(Error effect, Error cause) {
        return Error (effect + " because of " + cause); 
    }
    std::string toString() { return c_str(); }
}

Error errorNone ();

bool insideUndoGroup = false;
std::string currentCommandName;

void BeginUndoGroup(std::string commandName) {
   assert(not insideUndoGroup);
   insideUndoGroup = true;
   currentCommandName = commandName;
}

void EndUndoGroup(Error e) {
   assert(insideUndoGroup);
   MessageBox(NULL,
        "An error occurred during: " + currentCommandName, 
        e.toString(), MB_OK | MB_ICONEXCLAMATION);
   insideUndoGroup = false;
}
Now let's look at how we might rewrite the example above:
Error DoImportantThing() {
    int error_code = ImportantFunction();
    switch (error_code) {
        case E_NOERROR:
            return errorNone;
        case E_INVALIDUSER:
            return Error("Invalid User");
    }
}

Error CommandForCtrlF2() {
    Error e;
    int error_code = AnotherImportantFunction();
    switch (error_code) {
        case E_NOERROR:
            break;
        case E_OUTOFMEMORY:
            return Error("Out of Memory");
    }

   Error eImportantThing = DoImportantThing();
   if (eImportantThing != errorNull)
       return Error::causedBy(
           Error ("Ctrl F2 Couldn't Do Important Thing"),
               eImportantThing);
               
   return errorNone;
}

void OnKeyPressed(Key k) {
   if (k == Key(Ctrl, F2)) {
       BeginUndoGroup();
       Error e = CommandForCtrlF2();
       EndUndoGroup(e);
    }
}
This is very rough, but hopefully it gets the point across. I'll improve it if this article gets any attention. :-) Main thrust is that if you design your command processing in this way, it will present an easy and consistent interface for your users. But not doing it is a recipe for problems and bad UI...
Business Card from SXSW
Copyright (c) 2007-2018 hostilefork.com

Project names and graphic designs are All Rights Reserved, unless otherwise noted. Software codebases are governed by licenses included in their distributions. Posts on blog.hostilefork.com are licensed under the Creative Commons BY-NC-SA 4.0 license, and may be excerpted or adapted under the terms of that license for noncommercial purposes.