Set Paste Destination as Last Used Row on Another Sheet - vba

Thanks so much for taking a second to help me a bit! I'm working on a project at the moment, and part of it has me stuck. I'm not terribly proficient with VBA, so its entirely possible that I'm missing something very obvious here.
Goals:
Copy a non continuous group of cells (eg. d69,d70,d72,d73,g92,g93, etc.) and paste them to another (This time Continuous) range of cells on another sheet, in the row below the last used row.
Context:
Im creating a database of information filled in from a "User Form" on sheet 1. When the user clicks a macro linked button, the data is copied over to sheet 2 as a new entry.
Thoughts:
I have been thinking that it may be easier to set a variable to the value of the last cell used in sheet 2, then use something like a range("b" & "aa").pastespecial xlPasteValues for each cell that needs to be copied over. However I cant figure this out, or find what I need to do to achieve this. Any help would be greatly appreciated! Thanks so much.
If you have any questions, or need clarification, let me know! Thanks!
See document link below:
Working File

Another way you could achieve this which might be a bit easier is to use a helper column to 'store' your input values and then put that range into an array to write directly to your database sheet.
Assuming your helper columns are on a new sheet named "Helper", data input on a sheet named "BBU Quote Entry" and the data is moving to BBU Quote Database.
Sub BBUEntryToDatabaseUsingHelper()
Dim UserInputsArray() As Variant
Dim HelperRange As Range
Dim Destination As Range
Dim LastBBUDatabaseRow As Long
Dim LastHelperRow As Long
With ThisWorkbook.Sheets("Helper")
LastHelperRow = .Cells(Rows.Count, 2).End(xlUp).Row
Set HelperRange = .Range("B2:B" & LastHelperRow)
End With
UserInputsArray() = HelperRange.Value
With ThisWorkbook.Sheets("BBU Quote Database")
LastBBUDatabaseRow = .Cells(.Rows.Count, 2).End(xlUp).Row + 1
Set Destination = .Cells(LastBBUDatabaseRow, 2)
Set Destination = Destination.Resize(1, UBound(UserInputsArray, 1))
Destination.Value = Application.Transpose(UserInputsArray)
End With
End Sub
Input Form:
Helper columns:
This works simply by referencing the relevant cell from the input sheet on the helper sheet.
E.g. The "Customer Company" value is in cell D6 on sheet BBU Quote Entry so the helper column has ='BBU Quote Entry'!D6
For the "Hazardous" reference I found the cell your Form Control OptionButtons are linked to (E74 on Sheet BBU Quote Entry and used =IF('BBU Quote Entry'!E74 = 1, "Hazardous",IF('BBU Quote Entry'!E74 = 2,"Non-Hazardous","Not Specified"))
As you have some custom formatting, for example the "Desired Clearance" value formats the input as #### Inches, the reference only returns the value entered and not the formatting - You can look further into resolving that but in the mean time I added a string after the value reference, e.g. for "Desired Clearance" =('BBU Quote Entry'!D15) & " Inches".
With the data being held in the correct order for the output onto your "BBU Quote Database" sheet, we can simply put the range from the "Helper" sheet directly into an Array(), and then write the array to the correct range in "BBU Quote Database".
This is what the output looks like:
This is the way I'd probably go about it. Much less code, easier to maintain as both ranges are set dynamically so if you end up adding more inputs to your form, just include their reference on the helper sheet and the code will automatically include the new values the next time you run the code.

After your commented clarification and the addition of the Workbook to your question, I've edited this Answer to reflect these updates.
Assuming you know the cell address for each value on sheet1 AND the cell address are constant.
I've written a Subroutine to capture the values of your BBU Quote Entry form and write them to your BBU Quote Database range. I've added this to Module4.
It should be noted, the code only works with the Basic Information section of your form AND the 2 option buttons for Hazardous or Non-Hazardous using a function ReturnFormControlCaption. You can put in the hard yards for the rest of the data (more or less just copy paste, rename variables, adjust the range values and add the variables to the array of course).
Sub BBUEntryToDatabase()
Dim CustCompany As String
Dim CustName As String
Dim CustLocation As String
Dim CMTRep As String
Dim QuoteNo As String
Dim QuoteDate As String
Dim Hazard as String
With ThisWorkbook.Sheets("BBU Quote Entry")
CustCompany = .Range("D6").Value
CustName = .Range("D8").Value
CustLocaction = .Range("D10").Value
CMTRep = .Range("G6").Value
QuoteNo = .Range("G8").Value
QuoteDate = .Range("G10").Value
Hazard = ReturnFormControlCaption("BBU Quote Entry", "HazardousButton", "NotHazardousButton")
End With
Dim BBUArray As Variant
'The Array is assigned in order of your headings on "BBU Quote Database" sheet
BBUArray = Array(QuoteNo, CustCompany, CustName, CustLocation, CMTRep, QuoteDate, _
"Clearance", "Height", "Material", "Density", Hazard)
Dim Destination As Range
Dim LastRow As Long
With ThisWorkbook.Sheets("BBU Quote Database")
LastRow = .Cells(.Rows.Count, 2).End(xlUp).Row + 1
Set Destination = .Cells(LastRow, 2)
Set Destination = Destination.Resize(1, UBound(BBUArray, 1) + 1) ' + 1 as the array is 0 based (whereas columns start at 1).
Destination.Value = BBUArray
End With
End Sub
Here is a screenshot of my data entry
And the output on "BBU Quote Database" (after 3 tests with the same inputs)
I'm not very familiar with Form Controls as I usually use ActiveX Controls which I find a bit easier to use with VBA - I'd assume there is probably a much cleaner way to deal with the OptionButtons.
The ReturnFormControlCaption() function:
Function ReturnFormControlCaption(ByVal SheetNameTheControlIsOn As String, ByVal FirstFormControlName As String, _
Optional ByVal SecondFormControlName As String, Optional ByVal ThirdFormControlName As String, _
Optional ByVal FourthFormControlName As String, Optional ByVal FifthFormControlName As String, _
Optional ByVal SixthFormControlName As String) As String
With ThisWorkbook.Sheets(SheetNameTheControlIsOn)
If SecondFormControlName = "" Then
If .Shapes(FirstFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(FirstFormControlName).OLEFormat.Object.Caption
Else
ReturnFormControlCaption = "Not Specified"
End If
ElseIf ThirdFormControlName = "" Then
If .Shapes(FirstFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(FirstFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(SecondFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(SecondFormControlName).OLEFormat.Object.Caption
Else
ReturnFormControlCaption = "Not specified"
End If
ElseIf FourthFormControlName = "" Then
If .Shapes(FirstFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(FirstFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(SecondFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(SecondFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(ThirdFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(ThirdFormControlName).OLEFormat.Object.Caption
Else
ReturnFormControlCaption = "Not specified"
End If
ElseIf FifthFormControlName = "" Then
If .Shapes(FirstFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(FirstFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(SecondFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(SecondFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(ThirdFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(ThirdFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(FourthFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(FourthFormControlName).OLEFormat.Object.Caption
Else
ReturnFormControlCaption = "Not specified"
End If
ElseIf SixthFormControlName = "" Then
If .Shapes(FirstFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(FirstFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(SecondFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(SecondFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(ThirdFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(ThirdFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(FourthFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(FifthFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(FifthFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(FifthFormControlName).OLEFormat.Object.Caption
Else
ReturnFormControlCaption = "Not specified"
End If
Else
If .Shapes(FirstFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(FirstFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(SecondFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(SecondFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(ThirdFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(ThirdFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(FourthFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(FifthFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(FifthFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(FifthFormControlName).OLEFormat.Object.Caption
ElseIf .Shapes(SixthFormControlName).OLEFormat.Object.Value = 1 Then
ReturnFormControlCaption = .Shapes(SixthFormControlName).OLEFormat.Object.Caption
Else
ReturnFormControlCaption = "Not specified"
End If
End If
End With
End Function
To briefly explain the function, you pass string variables for the relevant worksheet name, and at least one (up to six) form control name(s).
The lengthy and nested If...ElseIf...Else statements are first establishing up to which argument has been included. Then depending on which argument is the first empty or "" value, it executes the next If...ElseIf...Else statement to determine in this case, which OptionButton is selected and then returns the .Caption of that OptionButton.
If none of the OptionButton being evaluated are selected, it returns "Not specified".
Note: This function will work for determining which CheckBox is checked BUT if in your passed arguments, more than one is selected, it will only return the .Caption of the first CheckBox that is checked. With some modification you could get the function working for both types including all CheckBox being checked.
Chip Pearson has some excellent information about Arrays and how to use them. You can read up on them on his website at www.cpearson.com or specifially what we have done here with arrays on this article on his website

Related

EXCEL VBA , Search Line Error 1004

I am trying to run an excel vba form to search through the lines, but for some unknown reason I get the error:
Method Range of object Global failed
Private Sub CommandButton3_Click()
Dim last, i As Integer
Dim ref, lote As String
'Sheets("analisegeral").Visible = True
Sheets("analisegeral").Select
last = Range("analisegeral").End(xlUp).Row + 1
For i = 2 To last ref = Cells(i, 8)
lote = Cells(i, 13)
If TextBox1.Text = ref Then
TextBox2.Text = lote
GoTo fim
End If
Next i
If TextBox1.Text <> ref Then
TextBox2.Text = ""
MsgBox "Referência não encontrada!", vbInformation
TextBox1.Text = ""
TextBox2.Text = ""
GoTo fim
End If
fim:
End Sub
There are few issues with your code.
Invalid declaration
Dim last, i As Integer
Dim ref, lote As String
Note that last and ref are declared as Variant type here, unless it was your intent, change it to following:
Dim last As Integer, i As Integer
Dim ref As String, lote As String
Failing to activate worksheet where range is located
'Sheets("analisegeral").Visible = True
Sheets("analisegeral").Select
The fact that your sheet is hidden (or very hidden) disallows it's selection.
Probably this is the case of your error.
Wrong method of calculating last row number
last = Range("analisegeral").End(xlUp).Row + 1
Given you will actualy select analisegeral sheet, this still doesn't make sense:
Range("NamedRange") is a construction that allows to refer to previously named range (either with VBA or manualy). Unless you have one, this will raise another error. Perhaps you meant something like this?
last = Range("A" & Rows.Count).End(xlUp).Row
This will give you a number of column A last row.
Final advice: avoid using Select

ListRows.Add doesn't appear to work

I've got a really odd case… hopefully someone is able to help me out, I've search many forums looking for a solution, the closest I could find related to it (kinda) is here, though I've tried all the suggestions to no avail…
I'm trying to run a function to return a data list in a string delimitated by a semicolon from an oracle stored function. (This value function call seems to work fine).
I then loop through the string for each data value and print it to a blank table (0 rows) declared in my subroutine. which I use to load into an access data base. (just trust it make sense in the big picture…).
The issue, fundamentally is that no information is printed into the table. However when I step through the code it works fine.
After troubleshooting I THINK (see my test scenarios below code) the issue comes up after the listrows.add line... though not obviously.
I don't think this line is executed by the time the first value is trying to print to the table.
The most confusing part is I'm running through 2 nearly identical procedures (call function -> Return value -> print values to table) immediately before this portion of the code and they work without fail.
Code Excerpt:
'run function to get string ... this works
DoEvents ' not in original design
RelRtnStr = Prnt(Cat, "A Third Oracle Function Name")
DoEvents ' not in original design
RelChopVar = RelRtnStr
StrFldCnt = 0
Checking = True ''' CodeBreak Test 1
DoEvents ' not in original design
AppendRlLmTbl.ListRows.Add ''''''''This isn't appearing to work...
DoEvents ' not in original design
Debug.Print Now ' not in original design
Application.Wait (Now + TimeValue("0:00:3")) ' not in original design
Debug.Print Now ' not in original design
While StrFldCnt < 80 And (Len(RelChopVar) - Len(Replace(RelChopVar, ";", ""))) > 0 And Checking
'## Count String Position
StrFldCnt = StrFldCnt + 1
'## Find Current String Value & Remainder String
If InStr(RelChopVar, ";") <> 0 Then
'Multiple Values Left
FldVal = Replace(Left(RelChopVar, InStr(RelChopVar, ";")), ";", "")
RelChopVar = Right(RelChopVar, Len(RelChopVar) - InStr(RelChopVar, ";"))
Else
'Last Value
FldVal = RelChopVar
Checking = False
End If
'## Get Field Name For Current Value & Print to Table
FldNm = CStr(RefRtrn(2, CStr(StrFldCnt))) ''' CodeBreak Test 2
AppendRlLmTbl.ListColumns(FldNm).DataBodyRange.Value = FldVal '''CodeBreak 2 error thrown
Debug.Print StrFldCnt & FldNm & FldVal
Wend
AppendRlLmTbl.ListColumns("Catalogue").DataBodyRange.Value = Cat
So far I've tested a ton of options suggested online, not necessarily understanding each test... This is what I've gleaned.
If I step through the code, it works
If I set a breakpoint at "CodeBreak Test 1" and "F5" the rest, it works …
If I set a breakpoint at "CodeBreak Test 2" I get an "Object with variable not set" error thrown …
Things I've tried …
Wrapping anything and everything with DoEvents
setting a wait time after the listObjects.add row
Validated the code performs the While loop when running the "full procured" (as opposed to stepping through)
The worst part, I have no idea why the object won't declare properly when setting a break point after the add row line but sets properly when break point is set before and has no error thrown when running the full procedure (I have no on error declarations.)...
It of course must be related in my mind but I can't find any information online and unfortunately have no formal VBA background and 1 undergrad course as a programming background in general. Aka I'm out of my depth and super frustrated.
PS. first post, so please be nice :p
Full Code Below:
Option Explicit
'## Here's my attempt to clean up and standardize the flow
'## Declare my public variables
' WorkBook
Public WB As Workbook
' Sheets
Public Req2ByWS As Worksheet
Public ReqSpecsWS As Worksheet
Public ReqInstrcWS As Worksheet
Public ConfigReqWS As Worksheet
Public AppendReqWS As Worksheet
Public AppendRlLmWS As Worksheet
' Objects (tables)
Public ReqConfigTbl As ListObject
Public SpecConfigTbl As ListObject
Public CurrRegIDTbl As ListObject
Public AppendReqTbl As ListObject
Public AppendRlLmTbl As ListObject
'## ##
'## Get Data from Tom's Functions ##
Sub GetSpotBuyData()
'## Preliminary Config ##
'## Turn OFF Warnings & Screen Updates
Application.DisplayAlerts = False
Application.ScreenUpdating = False
'## Set global Referances to be used in routine
' WorkBooks
Set WB = Workbooks("MyWb.xlsm")
' WorkSheets
Set Req2ByWS = WB.Sheets("MyWb Pg1")
Set ReqSpecsWS = WB.Sheets("MyWb Pg2")
Set ConfigReqWS = WB.Sheets("MyWb Pg3")
Set AppendReqWS = WB.Sheets("MyWb Pg4")
Set AppendRlLmWS = WB.Sheets("MyWb Pg5")
' Tables
Set ReqConfigTbl = ConfigReqWS.ListObjects("MyWS Tbl1")
Set SpecConfigTbl = ConfigReqWS.ListObjects("MyWS Tbl2")
Set CurrRegIDTbl = ConfigReqWS.ListObjects("MyWS Tbl3")
Set AppendReqTbl = AppendReqWS.ListObjects("MyWS Tbl4")
Set AppendRlLmTbl = AppendRlLmWS.ListObjects("MyWS Tbl5")
'## Declare Routine Specefic Variables
Dim Doit As Variant
Dim Checking As Boolean
Dim Cat As String
Dim CatRtnStr As String
Dim CatChopVar As String
Dim SpecRtnStr As String
Dim SpecChopVar As String
Dim RelRtnStr As String
Dim RelChopVar As String
Dim FldVal As String
Dim FldNm As String
Dim StrFldCnt As Integer
'## 1) General Set-Up ##
'## Unprotect tabs (loop through All Tabs Unprotect)
Doit = Protct(False, WB, "Mypassword")
'## Refresh Data
Doit = RunUpdateAl(WB)
'## 2) Find the Catalgue we are playing with ##
'## Grab Catalogue input from ISR
If [Catalogue].Value = "" Then
MsgBox ("Please Enter a Catalogue")
GoTo ExitSub
Else
Cat = [Catalogue].Value
End If
'## 3) Run Toms Function and print the results to the form & Append Table ##
'## 3a) Do it for Cat Info Function
'## Get Cat Info String From Function
CatRtnStr = Prnt(Cat, "An Oracle Functions Name")
CatChopVar = CatRtnStr
If CatChopVar = "No Info" Then
MsgBox ("No Info Found in Catalogue Data Search.")
GoTo SkipCatInfoPrint
End If
'## Loop Through Data String & Write to Form
StrFldCnt = 0
Checking = True
AppendReqTbl.ListRows.Add
While Checking
'## Count String Position
StrFldCnt = StrFldCnt + 1
'## Find Current String Value & Remainder String
If InStr(CatChopVar, ";") <> 0 Then
'Multiple Values Left
FldVal = Replace(Left(CatChopVar, InStr(CatChopVar, ";")), ";", "")
CatChopVar = Right(CatChopVar, Len(CatChopVar) - InStr(CatChopVar, ";"))
Else
'Last Value
FldVal = CatChopVar
Checking = False
End If
'## Get Field Name For Current Value & Print to Form
FldNm = CStr(RefRtrn(1, CStr(StrFldCnt)))
If FldNm <> "CustomerSpecification" And FldNm <> "ShiptoAddress" Then
'Take Value as is
Req2ByWS.Range(FldNm).Value = FldVal
AppendReqTbl.ListColumns(FldNm).DataBodyRange.Value = FldVal
ElseIf FldNm = "CustomerSpecification" Then
'Replace : with New Line
FldVal = Replace(FldVal, " : ", vbLf)
Req2ByWS.Range(FldNm).Value = FldVal
AppendReqTbl.ListColumns(FldNm).DataBodyRange.Value = FldVal
ElseIf FldNm = "ShiptoAddress" Then
'Replace - with New Line
FldVal = Replace(FldVal, " - ", vbLf)
Req2ByWS.Range(FldNm).Value = FldVal
AppendReqTbl.ListColumns(FldNm).DataBodyRange.Value = FldVal
End If
Wend
'## 3b) Do it for Spec Function
SkipCatInfoPrint:
'## Get Spec Info String From Function
SpecRtnStr = Prnt(Cat, "Another Oracle Functions Name")
SpecChopVar = SpecRtnStr
If SpecChopVar = "No Info" Then
MsgBox ("No Info Found in Data Search.")
GoTo SkipSpecInfoPrint
End If
'## Loop Through Data String & Write to Form
StrFldCnt = 0
Checking = True
While StrFldCnt < 80 And (Len(SpecChopVar) - Len(Replace(SpecChopVar, ";", ""))) > 0 And Checking
'## Count String Position
StrFldCnt = StrFldCnt + 1
'## Find Current String Value & Remainder String
If InStr(SpecChopVar, ";") <> 0 Then
'Multiple Values Left
FldVal = Replace(Left(SpecChopVar, InStr(SpecChopVar, ";")), ";", "")
SpecChopVar = Right(SpecChopVar, Len(SpecChopVar) - InStr(SpecChopVar, ";"))
Else
'Last Value
FldVal = SpecChopVar
Checking = False
End If
'## Get Field Name For Current Value & Print to Form
FldNm = CStr(RefRtrn(2, CStr(StrFldCnt)))
ReqSpecsWS.Range(FldNm).Value = FldVal
AppendReqTbl.ListColumns(FldNm).DataBodyRange.Value = FldVal
Wend
'## 3c) Do it for Rel Limits Function
SkipSpecInfoPrint:
'## Get Rel Limits String From Function
RelRtnStr = Prnt(Cat, "A Third Functions Name")
RelChopVar = RelRtnStr
If RelChopVar = "No Info" Then
MsgBox ("No Info Found in Data Search.")
GoTo ExitSub
End If
'## Loop Through Data String & Write to Form
StrFldCnt = 0
Checking = True
AppendRlLmTbl.ListRows.Add
While StrFldCnt < 80 And (Len(RelChopVar) - Len(Replace(RelChopVar, ";", ""))) > 0 And Checking
'## Count String Position
StrFldCnt = StrFldCnt + 1
'## Find Current String Value & Remainder String
If InStr(RelChopVar, ";") <> 0 Then
'Multiple Values Left
FldVal = Replace(Left(RelChopVar, InStr(RelChopVar, ";")), ";", "")
RelChopVar = Right(RelChopVar, Len(RelChopVar) - InStr(RelChopVar, ";"))
Else
'Last Value
FldVal = RelChopVar
Checking = False
End If
'## Get Field Name For Current Value & Print to Form
FldNm = CStr(RefRtrn(2, CStr(StrFldCnt)))
AppendRlLmTbl.ListColumns(FldNm).DataBodyRange.Value = FldVal
Wend
AppendRlLmTbl.ListColumns("SpecificFieldName").DataBodyRange.Value = Cat
'## 4) Re-Format and Clean Up Program ##
ExitSub:
'## Clean-Up Formatting
Req2ByWS.Range("F:F", "C:C").ColumnWidth = 30
Req2ByWS.UsedRange.Rows.AutoFit
Req2ByWS.UsedRange.Columns.AutoFit
Req2ByWS.Range("G:G").ColumnWidth = 15
Req2ByWS.Range("J:R").ColumnWidth = 12
Req2ByWS.Range("D:D").ColumnWidth = 12
'## Protect tabs (loop through All Tabs Protect)
'Doit = Protct(True, WB, "Mypassword", Req2ByWS.Name)
'Req2ByWS.Unprotect ("Mypassword")
'Application.Wait (Now + TimeValue("0:00:10"))
Req2ByWS.Select
'## Turn ON Warnings & Screen Updates
Application.DisplayAlerts = True
Application.ScreenUpdating = True
End Sub
I stupidly had an enable background refresh for that specific table. An early call to refresh all data triggered the refresh, code would execute and the refresh would finally complete shortly after the code finished executing... in break mode the refresh would complete prior too. Thanks PEH for helping me look into this.

Perfect user input validation in Excel VBA

I need to validate user input on when cells change and show the error in another cell in Excel using VBA.
I run into problems where my validator is called on all cells in the sheet when a user inserts rows or column which makes Excel unresponsive for a long time, how can I fix this?
Below are my requirements and my current solution with full documentation.
Definition and requirements
Consider the following table:
Example User Input Table
| | | Tolerance | | |
| Type | Length | enabled | Tolerance | Note |
|------|--------|-----------|-----------|----------------------------|
| | 4 | 0 | | Type is missing |
| | | 0 | | Type is missing |
| C | 40 | 1 | 110 | |
| D | 50 | 1 | | Tolerance is missing |
| | | | | |
The idea is that the user inputs values in the table, once a value has been changed (the user leaves the cell) the value is validated and if there is a problem the error is printed in the Note column.
Blank lines should be ignored.
I need this to be robust meaning it should not fail on any user input, that means it has to work for the following cases:
Paste values
Delete rows
Insert rows (empty or cut cells)
Insert/delete columns *
Any other case I missed thinking about?
*It is OK if the the validation fails when a user is deleting a column that is part of the table as this is seen as the user willfully vandalizing the sheet, but it has to fail gracefully (i.e. not by validating all cells in the worksheet which takes a long time). It would have been great if this action was undoable, however my current understanding of Excel suggests this is impossible (after a macro has changed something in the sheet, nothing can be undone anymore).
The Note cell can only contain one error at a time, for the user the most relevant error is the one for the cell the user last changed, so it should display this error first. After the user fixes that error the order is not that important anymore, so it could just display the errors from left to right.
Problems with current approach
My problem is that when rows/columns are inserted validation is triggered for all cells in the sheet which is a very slow process and to the user it looks like the program has crashed, but it will return once the validation is complete.
I don't know why Excel does this but I need a way to work around it.
Code placed in a Sheet named 'User Input'
My solution is based on the only on change event handler I know of: the per sheet global Worksheet_Change function (ugh!).
Worksheet_Change function
First it checks if the changed cell(s) intersects with the cells I'm interested in validating. This check is actually quite fast.
OldRowCount here is a try to catch the user inserting or deleting cells depending on how the used range changes, however this only solves some cases and introduces problems whenever Excel forgets the global variable (which happens now and then for as to me unknown reasons) as well as the first time the function is run.
The for loop makes it work for pasted values.
Option Explicit
Public OldRowCount As Long
' Run every time something is changed in the User Input sheet, it then filters on actions in the table
Private Sub Worksheet_Change(ByVal Target As Range)
Dim NewRowCount As Long
NewRowCount = ActiveSheet.UsedRange.Rows.count
If OldRowCount = NewRowCount Then
If Not Intersect(Target, Me.Range(COL_TYPE & ":" & COL_TOLERANCE)) Is Nothing Then
Dim myCell As Range
' This loop makes it work if multiple cells are changed, for example while pasting cells
For Each myCell In Target.Cells
' Protect the header rows
If myCell.row >= ROW_FIRST Then
checkInput_cell myCell.row, myCell.Column, Me
End If
Next
End If
ElseIf OldRowCount > NewRowCount Then
'Row deleted, won't have to deal with this as it solves itself
OldRowCount = NewRowCount
ElseIf OldRowCount < NewRowCount Then
Debug.Print "Row added, TODO: deal with this"
OldRowCount = NewRowCount
End If
End Sub
Code placed in a module
Global variables
Defines the rows/columns to be validated.
Option Explicit
' User input sheet set up
Public Const ROW_FIRST = 8
Public Const COL_TYPE = "B"
Public Const COL_LENGTH = "C"
Public Const COL_TOLERANCE_ENABLED = "D"
Public Const COL_TOLERANCE = "E"
Public Const COL_NOTE = "G"
Cell checking function
This function validates the given cell unless the row where the cell is is empty.
Meaning we are only interested in validating cells on rows where the user has actually started giving values. Blank rows is not a problem.
It first validates the currently changed cell, if it is OK it will then validate the other cells on the given row (since some cells validation depends the values of other cells, see Tolerance enabled in my example table above).
The note will only ever contain one error message at a time, the above is done so that we always get the error of the last changed cell in the Note cell.
Yes, this will make the checker run twice on the current cell, while it is not a problem it could be avoided by a more complex if statement, but for simplicity I skipped it.
Sub checkInput_cell(thisRow As Long, thisCol As Long, sheet As Worksheet)
Dim note As String
note = ""
With sheet
' Ignore blank lines
If .Range(COL_TYPE & thisRow).value <> "" _
Or .Range(COL_LENGTH & thisRow).value <> "" _
Or .Range(COL_TOLERANCE_ENABLED & thisRow).value <> "" _
Or .Range(COL_TOLERANCE & thisRow).value <> "" _
Then
' First check the column the user changed
If col2Let(thisCol) = COL_TYPE Then
note = check_type(thisRow, sheet)
ElseIf col2Let(thisCol) = COL_LENGTH Then
note = check_length(thisRow, sheet)
ElseIf col2Let(thisCol) = COL_TOLERANCE_ENABLED Then
note = check_tolerance_enabled(thisRow, sheet)
ElseIf col2Let(thisCol) = COL_TOLERANCE Then
note = check_tolerance(thisRow, sheet)
End If
' If that did not result in an error, check the others
If note = "" Then note = check_type(thisRow, sheet)
If note = "" Then note = check_length(thisRow, sheet)
If note = "" Then note = check_tolerance_enabled(thisRow, sheet)
If note = "" Then note = check_tolerance(thisRow, sheet)
End If
' Set note string (done outside the if blank lines checker so that it will reset the note to nothing on blank lines)
' only change it actually set it if it has changed (optimization)
If Not .Range(COL_NOTE & thisRow).value = note Then
.Range(COL_NOTE & thisRow).value = note
End If
End With
End Sub
Validators for individual columns
These functions takes a row and validate the a certain column according to it's special requirements. Returns a string if the validation fails.
' Makes sure that type is :
' Unique in its column
' Not empty
Function check_type(affectedRow As Long, sheet As Worksheet) As String
Dim value As String
Dim duplicate_found As Boolean
Dim lastRow As Long
Dim i As Long
duplicate_found = False
value = sheet.Range(COL_TYPE & affectedRow).value
check_type = ""
' Empty value check
If value = "" Then
check_type = "Type is missing"
Else
' Check for uniqueness
lastRow = sheet.Range(COL_TYPE & sheet.Rows.count).End(xlUp).row
If lastRow > ROW_FIRST Then
For i = ROW_FIRST To lastRow
If Not i = affectedRow And sheet.Range(COL_TYPE & i).value = value Then
duplicate_found = True
End If
Next
End If
If duplicate_found Then
check_type = "Type has to be unique"
Else
' OK
End If
End If
End Function
' Makes sure that length is a whole number larger than -1
Function check_length(affectedRow As Long, sheet As Worksheet) As String
Dim value As String
value = sheet.Range(COL_LENGTH & affectedRow).value
check_length = ""
If value = "" Then
check_length = "Length is missing"
ElseIf IsNumeric(value) Then
If Not Int(value) = value Then
check_length = "Length cannot be decimal"
ElseIf value < 0 Then
check_length = "Length is below 0"
ElseIf InStr(1, value, ".") > 0 Then
check_length = "Length contains a dot"
Else
' OK
End If
ElseIf Not IsNumeric(value) Then
check_length = "Length is not a number"
End If
End Function
' Makes sure that tolerance enabled is either 1 or 0:
Function check_tolerance_enabled(affectedRow As Long, sheet As Worksheet) As String
Dim value As String
value = sheet.Range(COL_TOLERANCE_ENABLED & affectedRow).value
check_tolerance_enabled = ""
If Not value = "0" And Not value = "1" Then
check_tolerance_enabled = "Tolerance enabled has to be 1 or 0"
Else
' OK
End If
End Function
' Makes sure that tolerance is a whole number larger than -1
' But only checks tolerance if it is enabled in the tolerance enabled column
Function check_tolerance(affectedRow As Long, sheet As Worksheet) As String
Dim value As String
value = sheet.Range(COL_TOLERANCE & affectedRow).value
check_tolerance = ""
If value = "" Then
If sheet.Range(COL_TOLERANCE_ENABLED & affectedRow).value = 1 Then
check_tolerance = "Tolerance is missing"
End If
ElseIf IsNumeric(value) Then
If Not Int(value) = value Then
check_tolerance = "Tolerance cannot be decimal"
ElseIf value < 0 Then
check_tolerance = "Tolerance is below 0"
ElseIf InStr(1, value, ".") > 0 Then
check_tolerance = "Tolerance contains a dot"
Else
' OK
End If
ElseIf Not IsNumeric(value) Then
check_tolerance = "Tolerance is not a number"
End If
End Function
Addressing support functions
These functions translates a letter to a column and vice versa.
Function let2Col(colStr As String) As Long
let2Col = Range(colStr & 1).Column
End Function
Function col2Let(iCol As Long) As String
Dim iAlpha As Long
Dim iRemainder As Long
iAlpha = Int(iCol / 27)
iRemainder = iCol - (iAlpha * 26)
If iAlpha > 0 Then
col2Let = Chr(iAlpha + 64)
End If
If iRemainder > 0 Then
col2Let = col2Let & Chr(iRemainder + 64)
End If
End Function
Code is tested on/has to work for Excel 2010 and onwards.
Edited for clarity
Finally got it working
After quite a bit of more agonizing, it turned out the fix was quite easy.
I added a new test that checks if the area that the user changed (the Target Range) consists of a column by looking at the address of the Range, if it is a full column the checker will ignore it. This solves the problem where the validation hogs Excel for about one minute.
The result of the intersection calculation is used for the inner loop which limits checks to cells within the area we are interested in validating.
Fixed Worksheet_Change function
Option Explicit
' Run every time something is changed in the User Input sheet
Private Sub Worksheet_Change(ByVal Target As Range)
Dim InterestingRange As Range
Set InterestingRange = Intersect(Target, Me.Range(COL_TYPE & ":" & COL_TOLERANCE))
If Not InterestingRange Is Nothing Then
' Guard against validating every cell in an inserted column
If Not RangeAddressRepresentsColumn(InterestingRange.address) Then
Dim myCell As Range
' This loop makes it work if multiple cells are changed,
' for example when pasting cells
For Each myCell In InterestingRange.Cells
' Protect the header rows
If myCell.row >= ROW_FIRST Then
checkInput_cell myCell.row, myCell.Column, Me
End If
Next
End If
End If
End Sub
New support function
' Takes an address string as input and determines if it represents a full column
' A full column is on the form $A:$A for single or $A:$C for multiple columns
' The unique characteristic of a column address is that it has always two
' dollar signs and one colon
Public Function RangeAddressRepresentsColumn(address As String) As Integer
Dim dollarSignCount As Integer
Dim hasColon As Boolean
Dim Counter As Integer
hasColon = False
dollarSignCount = 0
' Loop through each character in the string
For Counter = 1 To Len(address)
If Mid(address, Counter, 1) = "$" Then
dollarSignCount = dollarSignCount + 1
ElseIf Mid(address, Counter, 1) = ":" Then
hasColon = True
End If
Next
If hasColon And dollarSignCount = 2 Then
RangeAddressRepresentsColumn = True
Else
RangeAddressRepresentsColumn = False
End If
End Function

Knowing the assigned name of a cell instead of the "A1" name

Context:
I have several lists in my sheet (1 column wide, 1-10 rows long). When I right click a cell in these lists, I can do several options, that all work well. I have given a name to the cell at the top of each of these lists (ex. Cell A1 has been given the name cell_1, B10 is names cell_2, etc).
I would like to know if the cell I am right clicking on is the one at the top of the list; is it named "cell_(number)"? If it is not, it checks the cell on top of that one. Does it have a name that starts with "cell_"? If not, check the one on top, etc. Until I can figure out the user clicked on an element of WHICH list.
TL;DR The actual question
I can use ActiveCell.Address, which gives me something like "A1" whether or not I have assigned a name to that cell. ActiveCell.Name gives "Sheet1!A1", so it's not much better. Any idea how to get it to return the name I have assigned instead?
Create a UDF to test the application names, it's less efficient but contains error handling within the function itself:
Sub SO()
'// Example how to call function
Debug.Print GetCellName(Range("A1"))
End Sub
Function GetCellName(myCell As Excel.Range) As Variant
Dim nameCheck As Variant
For Each nameCheck In Application.Names
If Replace(Replace(Replace(nameCheck, "=", ""), "'", ""), "!", "") = _
CStr(myCell.Parent.Name & myCell.Address) Then
GetCellName = CStr(nameCheck.Name)
Exit Function
End If
Next
GetCellName = CVErr(Excel.xlErrName)
End Function
Note you can also use this function in a worksheet cell like so:
=GetCellName(A1)
Perhaps this would work. This function returns the names assigned to a cell (or bigger range for that matter). If there's more than one name, it returns it as an array for array formula...or the user can supply an index to return only the desired name position
Public Function CellIsInRangeNames(sheetname As String, checkRange As Range, Optional itemNumber As Variant) As Variant
Dim oNM As Name
Dim oSht As Worksheet
Dim isect As Range
Dim namesCollection() As Variant
Set oSht = Worksheets(sheetname)
Dim i As Integer
i = -1
For Each oNM In oSht.Names
Set isect = Application.Intersect(Range(oNM.Name), checkRange)
If Not isect Is Nothing Then
i = i + 1
ReDim Preserve namesCollection(0 To i)
namesCollection(i) = CStr(oNM.Name)
End If
Next oNM
If i = -1 Then
'didn't find any
CellIsInRangeNames = xlErrName
ElseIf Not IsMissing(itemNumber) Then
'user wanted this instance only
If (itemNumber - 1 > UBound(namesCollection)) Or (itemNumber - 1 < LBound(namesCollection)) Then
CellIsInRangeNames = xlErrValue
Else
CellIsInRangeNames = namesCollection(itemNumber - 1)
End If
Else 'here's the list as an array
CellIsInRangeNames = namesCollection
End If
End Function

Copy unique records from one workbook to another master workbook

I need some help with copying unique records from one workbook to a master workbook please.
Each month I receive a new workbook with data and I want to be able to copy all new records in that new workbook to one master workbook which will have all the amalgamted records. There is one unique reference field which can be used for the lookup to identify a new record.
In addition to this what I want to do is update values which are in 3 columns for ALL existing records on the master workbook which might be on the new workbook.
Example
Master workbook
Ref Name Value 1 Value 2 Value 3 Description
123 TR 100 50 200 xxxxxxxxxxxxxxx
111 WE 90 45 400 xxxxxxxxxxxxxxx
New workbook
Ref Name Value 1 Value 2 Value 3 Description
123 TR 300 200 200 xxxxxxxxxxxxxxx
456 MA 100 500 700 xxxxxxxxxxxxxxx
Update master workbook
Ref Name Value 1 Value 2 Value 3 Description
123 TR 300 200 200 xxxxxxxxxxxxxxx
111 WE 90 45 400 xxxxxxxxxxxxxxx
456 MA 100 500 700 xxxxxxxxxxxxxxx
I'd appreciate any help with this please. Thanks
I wrote a small module that does what you want (and even more). I tried to make it as generic as possible, but I had to assert a few things and limit it somehow - otherwise it would get quickly out of hand (as I think it already did.. kind of).
The limitations/assertions are the following:
1. the records are considered to be laid out only in rows (as per your example).
2. there is no column checking during the update or insertion of values. The program assumes that both master and new workbooks contain the same columns and laid in the exact same order.
3. There is no validation check for duplicate reference values. The "ref" column that you indicate as your primary key in each data range, is assumed to contain unique values (for that data range).
Apart from those assumptions, my solution is enhanced with flexible arguments (optional or autoconfigurable - see how dataRange is determined) to allow for several types of operation.
optional colorAlertOption flag: allows updated or inserted entries to be colored in order to be more distinguisable (true by default)
optional rangeWithHeaders flag: helps to determine if the supplied dataRange argument needs to be resized (remove headers) or not (true by default)
optional refColIndex integer: the relative to the dataRange - not the whole worksheet - column number pinpointing the column containing the unique references. (1 by default)
required dataRangeNew, dataRangeMaster (Range) arguments: flexible representations of the data-ranges for the new and master datasets respectively. You can either provide them explicitly (e.g. "$A$1:$D$10") or by giving only a single cell contained anywhere within the data-range. The only predicates are that the data-range should be isolated from other possible data coexisting on the same sheet (by means of blank rows or columns) and that it contains at least 1 row.
You can call the updateMasterDataRange procedure like this:
call updateMasterDataRange (Workbooks(2).Sheets("new").Range("a1"), Workbooks(1).Worksheets("master").Range("a1"))
Notice the fully qualified data ranges, including the workbooks and the worksheets in the mix. If you don't prepend these identifiers, VBA will try to associate the unqualified Range with ActiveWorkbook or/and ActiveWorksheet, with unpredictable results.
Here goes the body of the module:
Option Explicit
Option Base 1
Public Sub updateMasterDataRange( _
ByRef dataRangeNew As Range, ByRef dataRangeMaster As Range, _
Optional refColIndexNew As Integer = 1, Optional refColIndexMaster As Integer = 1, _
Optional colorAlertOption = True, Optional rangeWithHeaders = True)
' Sanitize the supplied data ranges based on various criteria (see procedure's documentation)
If sanitizeDataRange(dataRangeMaster, rangeWithHeaders) = False Then GoTo rangeError
If sanitizeDataRange(dataRangeNew, rangeWithHeaders) = False Then GoTo rangeError
' Declaring counters for the final report's updated and appended records respectively
Dim updatedRecords As Integer: updatedRecords = 0
Dim appendedRecords As Integer: appendedRecords = 0
' Declaring the temporary variables which hold intermediate results during the for-loop
Dim updatableMasterRefCell As Range, currentRowIndex As Integer, updatableRowMaster As Range
For currentRowIndex = 1 To dataRangeNew.Rows.Count
' search the master's unique references (refColMaster range) for the current reference
' from dataRangeNew (refcolNew range)
Set updatableMasterRefCell = dataRangeMaster.Columns(refColIndexMaster).Find( _
what:=dataRangeNew.Cells(currentRowIndex, refColIndexNew).Value, _
lookat:=xlWhole, searchorder:=xlByRows, searchDirection:=xlNext)
' perform a check to see if the search has returned a valid range reference in updatableMasterRefCell
' if it is found empty (the reference value in refCellNew is unique to masterDataRange)
If updatableMasterRefCell Is Nothing Then
Call appendRecord(dataRangeNew.Rows(currentRowIndex), dataRangeMaster, colorAlertOption)
appendedRecords = appendedRecords + 1
'ReDim Preserve appendableRowIndices(appendedRecords)
'appendableRowIndices(appendedRecords) = currentRowIndex
Else
Set updatableRowMaster = Intersect(dataRangeMaster, updatableMasterRefCell.EntireRow)
Call updateRecord(dataRangeNew.Rows(currentRowIndex), updatableRowMaster, colorAlertOption)
updatedRecords = updatedRecords + 1
End If
Next currentRowIndex
' output an informative dialog to the user
Dim msg As String
msg = _
"sheet name: " & dataRangeMaster.Parent.Name & vbCrLf & _
"records updated: " & updatedRecords & vbCrLf & _
"records appended: " & appendedRecords
MsgBox msg, vbOKOnly, "--+ Update report +--"
Exit Sub
rangeError:
MsgBox "Either range argument is too small to operate on!", vbExclamation, "Argument Error"
End Sub
Sub appendRecord(ByVal recordRowSource As Range, ByRef dataRangeTarget As Range, Optional ByVal colorAlertOption As Boolean = True)
Dim appendedRowTarget As Range
Set dataRangeTarget = dataRangeTarget.Resize(Rowsize:=dataRangeTarget.Rows.Count + 1)
Set appendedRowTarget = dataRangeTarget.Rows(dataRangeTarget.Rows.Count)
appendedRowTarget.Insert shift:=xlDown, copyorigin:=xlFormatFromLeftOrAbove
Set appendedRowTarget = appendedRowTarget.Offset(-1, 0)
' resize datarangetarget to -1 row (because cells' shifting incurred a +1 row to dataRangeTarget)
Set dataRangeTarget = dataRangeTarget.Resize(Rowsize:=dataRangeTarget.Rows.Count - 1)
recordRowSource.Copy appendedRowTarget
If colorAlertOption = True Then
' fills the cells of the newly appended row with lightgreen color
appendedRowTarget.Interior.color = RGB(156, 244, 164)
End If
End Sub
Sub updateRecord(ByVal recordRowSource As Range, ByVal updatableRowTarget As Range, Optional ByVal colorAlertOption As Boolean = True)
recordRowSource.Copy updatableRowTarget
If colorAlertOption = True Then
' fills the cells of the updated row with lightblue color
updatableRowTarget.Interior.color = RGB(164, 189, 249)
End If
End Sub
Private Function sanitizeDataRange(ByRef target As Range, ByVal rangeWithHeaders As Boolean) As Boolean
' if data range comprises only 1 cell then try to expand the range to currentRegion
' (all neighbouring cells until the selection reaches boundaries of blank rows or columns)
If target.Cells.Count = 1 Then
Set target = target.CurrentRegion
End If
' remove headers from data ranges if flag RangeWithHeaders is true
If (rangeWithHeaders) Then
If (target.Rows.Count >= 2) Then
Set target = target.Offset(1, 0).Resize(Rowsize:=(target.Rows.Count - 1))
Else
sanitizeDataRange = False
End If
End If
sanitizeDataRange = IIf((target.Rows.Count >= 1), True, False)
End Function
The results of a simple execution on your example gave the expected results, as you can see in the attached picture. There is even a dialogue with a brief report on the accomplished operations.
You haven't got much of a start. Will this outline get you started?
open all 3 workbooks
for masterrow = beginrow to endrow
if match in newsheet then
updaterow = newrow
else
updaterow = masterrow
end if
next masterrow
' now pick up unmatched newrows
for newrow = beginrow to endrow
if not match in updatesheet then
updaterow = newrow
end if
next newrow
EDIT: CodeVortex did the whole thing. My outline was flawed.
open both workbooks
appendrow = endrow of mastersheet
for newrow = beginrow to endrow
if match in mastersheet then
update masterrow
else
append into appendrow
appendrow = appendrow + 1
end if
next newrow