Search code examples
vbauserformmvp

Validate userform inputs where controls are dynamic using MVP approach


TL;DR

I use one UserForm that has a dynamic assignment of its own caption and some control captions with three different variations. Specifically on this UserForm, four CheckBox are required on two variations and are not visible on one.

My data validation checks all mandatory fields have a value entered (including one of those four checkboxes are checked) so when using the form that does not require a checkbox be ticked (as the controls are not visible) I'm getting my "Please enter a value into each field." MessageBox.

How can I avoid this?


I've been having a read of UserForm1.Show over the past few months which for myself, along with many others, has helped me understand what a UserForm is and how it works.

I'm trying to implement the MVP pattern into an existing project I have which had more or less just been 'completed'.

Naturally as I run into problems or confusions, I'll jump to google and in most cases either find another article or a SO question with a more than adequate answer from the author. But. I can't find one for validating an MSForms.Control that may or may not be there - i.e is sometimes used on a form, depending on what variation of the form it is.

Please note, I feel that I'm probably in the wrong with how I've designed my forms (well, singular form), so if that is the case, an answer that identifies and covers that topic would be most helpful also!

So here is my base form (test button is for...testing):
snip of design view of userform

And when any of these 3 buttons are clicked (worksheet ActiveX commandbuttons), it's populated with one of the below UserForms (Captions correspond with buttons):
Worksheet ActiveX Commandbuttons

NEC userform LG userform Other userform

Now, my data validation works fine for the NEC and LG forms, but fails when it gets to the Other form. This is because one Product Type CheckBox is required for the NEC and LG products, but not for the Other products and the data validation fails if there is no Product Type.

Here I'll include the CommandButton1_Click (test button) event and the class module code. My data validation is done in the UserForm module but I was recently reading i should put it in the Model so I think I need to move it to the Module doing all the other things.

UserForm Code Module - MCVE

Option Explicit
Public DataEntryForm As New TestForm

Private Sub CommandButton1_Click()

With Me
    If .CheckBox1.Value = True Then
        DataEntryForm.TestProduct = .CheckBox1.Caption
    ElseIf .CheckBox2.Value = True Then
        DataEntryForm.TestProduct = .CheckBox2.Caption
    ElseIf .CheckBox3.Value = True Then
        DataEntryForm.TestProduct = .CheckBox3.Caption
    ElseIf .CheckBox4.Value = True Then
        DataEntryForm.TestProduct = .CheckBox4.Caption
    End If
End With

If Not FormIsComplete Then
    MsgBox "Please enter a value into each field.", vbCritical, "Missing Values"
    Exit Sub
End If

End Sub

Private Function FormIsComplete() As Boolean
FormIsComplete = False

If DataEntryForm.TestProduct = "" Then Exit Function

FormIsComplete = True

End Function

Class Module - TestForm (MCVE)

Private pTestProduct As String

Public Property Get TestProduct() As String
    TestProduct = pTestProduct
End Property
Public Property Let TestProduct(NewValue As String)
    pTestProduct = NewValue
End Property

So, more specifically;

The problem lies with DataEntryForm.TestProduct. It is within the IsFormCompleted function as 2/3 forms require this property to have a value, but naturally isn't required for the form without any Product Types.

My thoughts are the easy fix is to create another separate form for the Other Products version which can have a separate data validation function, but I want to try keep maintainability and avoid having more than 1 of this form.

How can I have this type of data validation adapt to recognise if the control should have a value or not?


Solution

  • Your model class being named *Form got me confused for a minute; I might have named the form like that (or TestView) and used TestModel for the model class :)

    If the role of the view/form is to present the data, the role of the model is to, well, be the data. TestProduct is one such piece of data: its validity is also presentable data. You could consider this metadata and go wild and have a some TestModelValidator class implementing some IModelValidator interface that might look like this:

    Public Function IsValid() As Boolean
    End Function
    

    ...but that's probably overkill. If we're good with having the model responsible for both the data and its validation, then the model class could look like this:

    Option Explicit
    Private Type TState
        ValidationErrors As Collection
        ProductName As String
        '...other state members
    End Type
    Private this As TState
    
    Private Sub Class_Initialize()
        Set this.ValidationErrors = New Collection
    End Sub
    
    Public Property Get ProductName() As String
        ProductName = this.ProductName
    End Property
    
    Public Property Let ProductName(ByVal value As String)
        this.ProductName = value
    End Property
    
    '...other model properties...
    
    Public Property Get IsValid() As Boolean
    
        Dim validProductName As Boolean
        validProductName = Len(this.ProductName) <> 0
        this.ValidationErrors.Remove "ProductName" '<~ NOTE air code, verify this works
        If Not validProductName Then this.ValidationErrors("ProductName") = "Product name cannot be empty"
        '...validation logic for other properties...
    
        IsValid = validProductName
    End Property
    
    Public Property Get ValidationErrors() As String
        ReDim result(0 To this.ValidationErrors.Count)
        Dim e As Variant, i As Long
        For Each e In this.ValidationErrors
            result(i) = e
            i = i + 1
        Next
        ValidationErrors = Join(vbNewLine, result)
    End Property
    

    Now the view can manipulate the model - not what's happening here:

    Private Sub CommandButton1_Click()
    
    With Me
        If .CheckBox1.Value = True Then
            DataEntryForm.TestProduct = .CheckBox1.Caption
        ElseIf .CheckBox2.Value = True Then
            DataEntryForm.TestProduct = .CheckBox2.Caption
    

    Instead of querying the UI, listen in when the UI tells you what's going on - handle each control's Change event, and then let the model drive the state of the UI:

    Private Sub CheckBox1_Change()
        If Me.CheckBox1.Value Then
            Model.ProductName = Me.CheckBox1.Caption
            Validate
        End If
    End Sub
    
    Private Sub CheckBox2_Change()
        If Me.CheckBox2.Value Then
            Model.ProductName = Me.CheckBox2.Caption
            Validate
        End If
    End Sub
    
    Private Sub CodeBox_Change()
        Model.Code = CodeBox.Text
        Validate
    End Sub
    
    Private Sub DescriptionBox_Change()
        Model.Description = DescriptionBox.Text
        Validate
    End Sub
    
    Private Sub Validate()
        Dim valid As Boolean
        valid = Model.IsValid
    
        Me.OkButton.Enabled = valid
        Me.ValidationErrorsLabel.Caption = Model.ValidationErrors
        Me.ValidationErrorsLabel.Visible = Not valid
    End Sub
    

    Hope it helps!


    Edit/Addendum: use the model state to also drive whether such or control should be visible or not; your model class should encapsulate as much of the logic as possible (vs having it in the form's code-behind) - that way you can easily write tests against your model class that validate and document its behavior, without needing to manually test every edge case in the actual form every time you make a change that could break something! In other words, if the view/form needs to have a collection of supplier names to populate a combobox or to create as many checkbox controls, then it's the model's job to encapsulate this data.

    In other words, if you need a flag to drive some piece of model logic, have that flag be part of your model state:

    Private Type TState
        '...
        ProductTypes As Collection
    End Type
    
    Public Property Get HasProductTypes() As Boolean
        HasProductTypes = this.ProductTypes.Count > 0
    End Property
    
    Public Property Get ProductTypes() As Variant
        Dim result(0 To ProductTypes.Count)
        Dim pt As Variant, i As Long
        For Each pt In this.ProductTypes
            result(i) = pt
            i = i + 1
        Next
        ProductTypes = result
    End Property
    
    Public Property Get IsValid() As Boolean
    
        Dim validProductName As Boolean
        validProductName = Len(this.ProductName) <> 0
        this.ValidationErrors.Remove "ProductName" '<~ NOTE air code, verify this works
        If Not validProductName Then this.ValidationErrors("ProductName") = "Product name cannot be empty"
        '...validation logic for other properties...
    
        Dim validProductType As Boolean '<~ model is valid with an empty ProductType if there are no product types
        validProductType = IIf(HasProductTypes, Len(this.ProductType) > 0, True)
    
        IsValid = validProductName And validProductType
    End Property