Using abstraction and dependency injection, what if implementation-specific details need to be configurable in the UI?

Viewed 263

I have an application that loads a list of client/matter numbers from an input file and displays them in a UI. These numbers are simple zero-padded numerical strings, like "02240/00106". Here is the ClientMatter class:

public class ClientMatter
{
    public string ClientNumber { get; set; }
    public string MatterNumber { get; set; }
}

I'm using MVVM, and it uses dependency injection with the composition root contained in the UI. There is an IMatterListLoader service interface where implementations represent mechanisms for loading the lists from different file types. For simplicity, let's say that only one implementation is used with the application, i.e. the application doesn't support more than one file type at present.

public interface IMatterListLoader
{
    IReadOnlyCollection<string> MatterListFileExtensions { get; }
    IReadOnlyCollection<ClientMatter> Load(FileInfo fromFile);
}

Let's say in my initial version, I've chosen an MS Excel implementation to load the list of matters, like this:

enter image description here

I'd like to allow the user to configure at runtime the row and column numbers where the list starts, so the view might look like this:

enter image description here

And here's the MS Excel implementation of IMatterListLoader:

public sealed class ExcelMatterListLoader : IMatterListLoader
{
    public uint StartRowNum { get; set; }
    public uint StartColNum { get; set; }
    public IReadOnlyCollection<string> MatterListFileExtensions { get; set; }

    public IReadOnlyCollection<ClientMatter> Load(FileInfo fromFile)
    {
        // load using StartRowNum and StartColNum
    }
}

The row and column numbers are an implementation detail specific to MS Excel implementations, and the view model doesn't know about it. Nevertheless, MVVM dictates that I control view properties in the view model, so if I were to do that, it would be like this:

public sealed class MainViewModel
{
    public string InputFilePath { get; set; }

    // These two properties really don't belong
    // here because they're implementation details
    // specific to an MS Excel implementation of IMatterListLoader.
    public uint StartRowNum { get; set; }
    public uint StartColNum { get; set; }

    public ICommandExecutor LoadClientMatterListCommand { get; }

    public MainViewModel(IMatterListLoader matterListLoader)
    {
        // blah blah
    }
}

Just for comparison, here's an ASCII text file based implementation that I might consider for the next version of the application:

enter image description here

public sealed class TextFileMatterListLoader : IMatterListLoader
{
    public bool HasHeaderLine { get; set; }
    public IReadOnlyCollection<string> MatterListFileExtensions { get; set; }

    public IReadOnlyCollection<ClientMatter> Load(FileInfo fromFile)
    {
        // load tab-delimited client/matters from each line
        // optionally skipping the header line.
    }
}

Now I don't have the row and column numbers that the MS Excel implementation needed, but I have a Boolean flag indicating whether the client/matter numbers start on the first row (i.e. no header row) or start on the second row (i.e. with a header row).

I believe the view model should be unaware of the change between implementations of IMatterListLoader. How do I let the view model do its job controlling presentation concerns, but still keep certain implementation details unknown to it?


Here's the dependency diagram:

enter image description here

4 Answers

You'd need a seperate viewmodel for each type of file you intend to load.

Each viewmodel does the setup for its particular loader.

These viewmodels can then be passed in as dependencies to the main viewmodel, which calls load on each viewmodel when needed;

public interface ILoaderViewModel
{
    IReadOnlyCollection<ClientMatter> Load();
}

public class ExcelMatterListLoaderViewModel : ILoaderViewModel
{
    private readonly ExcelMatterListLoader loader;

    public string InputFilePath { get; set; }

    public uint StartRowNum { get; set; }

    public uint StartColNum { get; set; }

    public ExcelMatterListLoaderViewModel(ExcelMatterListLoader loader)
    {
        this.loader = loader;
    }

    IReadOnlyCollection<ClientMatter> Load()
    {
        // Stuff

        loader.Load(fromFile);
    }
}

public sealed class MainViewModel
{
    private ExcelMatterListLoaderViewModel matterListLoaderViewModel;

    public ObservableCollection<ClientMatter> ClientMatters
        = new ObservableCollection<ClientMatter>();

    public MainViewModel(ExcelMatterListLoaderViewModel matterListLoaderViewModel)
    {
        this.matterListLoaderViewModel = matterListLoaderViewModel;
    }

    public void LoadCommand()
    {
        var clientMatters = matterListLoaderViewModel.Load();

        foreach (var matter in clientMatters)
        {
            ClientMatters.Add(matter)
        }
    }
}

As you add more types to the application, you'd create new view models and add those as dependencies.

You could have a function that constructs the UI elements based on the specific type of the interface.

public static void ConstructUI(IMatterListLoader loader) {
    Type loaderType = loader.GetType();
    // Do logic based on type
}

You could have classes for each of the IMatterListLoader implementations, which contains logic concerning the presentation. (You don't want to mix the UI presentation logic in with the IMatterListLoader implementations).

Based on the type of the loader, you use the correct class to generate the UI elements.

I would add a Draw() method to the IMatterListLoader interface. Your MainViewModel would then just call Draw() and the the actual IMatterListLoader will add whatever parameters are needed to the UI.

This is a bit conceptual as I'm not too familiar with WPF, so you might need to change the code to use UserControl's or something but the logic is the same.

For example, lets say you have AsciiMatterListLoader which requires no input from the client, then nothing will be shown in the MainViewModel. But if the ExcelMatterListLoader is loaded, the MainViewModel should add the necessary user inputs.

public sealed class AsciiMatterListLoader : IMatterListLoader
{
    public IReadOnlyCollection<string> MatterListFileExtensions { get; set; }

    public IReadOnlyCollection<ClientMatter> Load(FileInfo fromFile)
    {
        // load data with no parameters
    }

    public Panel Draw()
    {
        // Nothing needs to be drawn
        return null;
    }
}

public sealed class ExcelMatterListLoader : IMatterListLoader
{
    public uint StartRowNum { get; set; }
    public uint StartColNum { get; set; }
    public IReadOnlyCollection<string> MatterListFileExtensions { get; set; }

    public IReadOnlyCollection<ClientMatter> Load(FileInfo fromFile)
    {
        // load using StartRowNum and StartColNum
    }

    public Panel Draw()
    {
        Panel panelForUserParams = new Panel();
        panelForUserParams.Height = 400;
        panelForUserParams.Width = 200;
        TextBox startRowTextBox = new TextBox();
        startRowTextBox.Name = "startRowTextBox";
        TextBox startColumnTextBox = new TextBox();
        startColumnTextBox.Name = "startColumnTextBox";
        panelForUserParams.Children().Add(startRowTextBox);
        panelForUserParams.Children().Add(startColumnTextBox);
        return panelForUserParams;
    }
}

public sealed class MainViewModel
{
    public string InputFilePath { get; set; }
    public ICommandExecutor LoadClientMatterListCommand { get; }

    public MainViewModel(IMatterListLoader matterListLoader)
    {
        var panel = matterListLoader.Draw();
        if (panel != null)
        {
                // Your MainViewModel should have a dummy empty panel called "placeHolderPanelForChildPanel"
                var parent = this.placeHolderPanelForChildPanel.Parent;
                parent.Children.Remove(this.placeHolderPanelForChildPanel); // Remove the dummy panel
                parent.Children.Add(panel); // Replace with new panel
        }
    }
}

You might need to use event handlers to pass the user input changes to the IMatterListLoader or maybe make IMatterListLoader a UserControl.

Edit

@rory.ap is right, the service layer shouldn't know about the UI components. Here is my adjusted answer where the IMatterListLoader just exposes the properties it needs by using a dictionary as a PropertyBag instead of telling the UI what to draw. This way the UI layer does all the UI work:

public interface IMatterListLoader
{
    IReadOnlyCollection<ClientMatter> Load(FileInfo fromFile);
    IDictionary<string, object> Properties { get; }
    void SetProperties(IDictionary<string, object> properties);
}

public sealed class AsciiMatterListLoader : IMatterListLoader
{
    public IReadOnlyCollection<string> MatterListFileExtensions { get; set; }

    public IDictionary<string, object> Properties
    {
        get 
        {
            return new Dictionary<string, object>(); // Don't need any parameters for ascii files
        }
    }

    public void SetProperties(IDictionary<string, object> properties)
    {
        // Nothing to do
    }

    public IReadOnlyCollection<ClientMatter> Load(FileInfo fromFile)
    {
        // Load without using any additional params
        return null;
    }
}

public sealed class ExcelMatterListLoader : IMatterListLoader
{
    private const string StartRowNumParam = "StartRowNum";
    private const string StartColNumParam = "StartColNum";

    public uint StartRowNum { get; set; }
    public uint StartColNum { get; set; }
    public IReadOnlyCollection<string> MatterListFileExtensions { get; set; }

    private bool havePropertiesBeenSet = false;

    public IDictionary<string, object> Properties
    {
        get
        {
            var properties = new Dictionary<string, object>();
            properties.Add(StartRowNumParam, (uint)0); // Give default UINT value so UI knows what type this property is
            properties.Add(StartColNumParam, (uint)0); // Give default UINT value so UI knows what type this property is

            return properties;
        }
    }

    public void SetProperties(IDictionary<string, object> properties)
    {
        if (properties != null)
        {
            foreach(var property in properties)
            {
                switch(property.Key)
                {
                    case StartRowNumParam:
                        this.StartRowNum = (uint)property.Value;
                        break;
                    case StartColNumParam:
                        this.StartColNum = (uint)property.Value;
                        break;
                    default:
                        break;
                }
            }

            this.havePropertiesBeenSet = true;
        }
        else
            throw new ArgumentNullException("properties");
    }

    public IReadOnlyCollection<ClientMatter> Load(FileInfo fromFile)
    {
        if (this.havePropertiesBeenSet)
        {
            // Load using StartRowNum and StartColNum
            return null;
        }
        else
            throw new Exception("Must call SetProperties() before calling Load()");
    }
}

public sealed class MainViewModel
{
    public string InputFilePath { get; set; }
    public ICommandExecutor LoadClientMatterListCommand { get; }
    private IMatterListLoader matterListLoader;

    public MainViewModel(IMatterListLoader matterListLoader)
    {
        this.matterListLoader = matterListLoader;

        if (matterListLoader != null && matterListLoader.Properties != null)
        {
            foreach(var prop in matterListLoader.Properties)
            {
                if (typeof(prop.Value) == typeof(DateTime))
                {
                    // Draw DateTime picker for datetime value
                    this.placeHolderPanelForParams.Add(new DateTimePicker() { Name = prop.Key });
                }
                else 
                {
                    // Draw textbox for everything else
                    this.placeHolderPanelForParams.Add(new TextBox() { Name = prop.Key });

                    // You can also add validations to the input here (E.g. Dont allow negative numbers of prop is unsigned)
                    // ...
                }
            }
        }
    }

    public void LoadFileButtonClick(object sender, EventArgs e)
    {
        //Get input params from UI
        Dictionary<string, object> properties = new Dictionary<string, object>();
        foreach(Control propertyControl in this.placeHolderPanelForParams().Children())
        {
            if (propertyControl is TextBox)
                properties.Add(propertyControl.Name, ((TextBox)propertyControl).Text);
            else if (propertyControl is DateTimePicker)
                properties.Add(propertyControl.Name, ((DateTimePicker)propertyControl).Value);
        }

        this.matterListLoader.SetProperties(properties);
        this.matterListLoader.Load(null); //Ready to load
    }
}

Not sure why nobody suggested property attributes and reflection

Just create a new Attribute, eg:

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class ExposeToViewAttribute : Attribute
{
    public string Name { get; set; }

    public ExposeToViewAttribute([System.Runtime.CompilerServices.CallerMemberName]string name = "")
    {
        this.Name = name;
    }
}

and make sure it gets added in your view

var t = matterListLoader.GetType();
var props = t.GetProperties().Where((p) => p.GetCustomAttributes(typeof(ExposeToViewAttribute), false).Any());
foreach(var prop in props)
{
    var att = prop.GetCustomAttributes(typeof(ExposeToViewAttribute), true).First() as ExposeToViewAttribute;
    //Add to view
}

approach will not get much cleaner.

Usage then would be as simple as:

[ExposeToView]
public int Something { get; set; }

[ExposeToView("some name")]
public int OtherFieldWithCustomNameThen { get; set; }

If you use something like WPF however, there are other solutions that may work better for you.

Related