Expert Programming Techniques for Microsoft Excel and Visual Basic for Applications

Presented by Eric Wells

Eric Wells is the author of Developing Microsoft Excel 5 Solutions, a book on advanced Excel/VBA development published by Microsoft Press. Eric has been with Microsoft since July of 1992. He worked for two years as the product manager for Microsoft® Excel, directing the marketing rollout of Visual Basic® for Applications (VBA) in Excel 5. Eric has lectured on developing with Excel and VBA at numerous conferences throughout the United States and Europe. Eric is currently the product manager for Microsoft Office and host of the Microsoft Office Solutions show on Microsoft Television. Eric can be reached over the Internet at ericwell@microsoft.com.

Much of the material in this paper is covered in detail in Developing Microsoft Excel 5 Solutions (ISBN 1-55615-684-7) published by Microsoft Press.

Sample Files

Sample files for this session include:

    xl301.doc
  1. saledata.mdb
  2. xlodbc.xla
  3. xlodbc.dll

All macros in this document are included in the xl301.xls file. Load all files into a common directory on your hard drive. In order to run the VBA macros in xl301.xls, you must set up an Access 2 ODBC data source that points to the saledata.mdb file. Name this data source "saledata".

The xl301.xls workbook contains macros that make reference to the SQL functions in xlodbc.xla. To run these functions, you must have copies of xlodbc.xla and xlodbc.dll on your hard drive as well—copy the versions of these files from your session files diskette/CD into the same directory as xl301.xls.

PivotTables

PivotTables Defined

The PivotTable object is one of the most advanced objects in Microsoft Excel. PivotTables provide a tool that allows users to view data from either an external database or an Excel worksheet database in various different dynamic views. The versatility of the pivottable provides a powerful interface for summarizing and interpreting large amounts of database data.

The pivottable object is segmented into four main areas:

Graphically, the four areas of the pivottable appear as follows:

Building a pivottable involves reading a data set into an internal RAM memory cache and then specifying which columns of the data set will go into each of the four areas of the pivottable—row, column, data and page areas. Two things are important to note about assigning different data set columns to the different areas of the pivottable: first, it is possible to assign multiple columns to each area of the pivottable; second, if a data set column is assigned to the row, column or page areas of the pivottable, then only the unique values of that particular dataset column will be displayed in the pivottable—that is the data in the data area of the pivottable will be summarized according to items displayed in the row, column and page areas.

The pivottable object actually contains an OLE object hierarchy in its own right. There are three levels of programmable objects in the pivottable hierarchy:

The PivotTable object is used to reference the pivottable itself. The PivotField object is used to reference the different data set columns which are placed in the row, column, data and page areas of the pivottable. The PivotItem object is used to reference the unique values that exist in a pivotfield—PivotItem is therefore, really only of use in working with unique items in pivotfields in the row, column or page areas of a pivottable.

Because the data behind a pivottable is stored in a RAM memory cache, manipulation of the data within a pivottable is generally accomplished at a very high level of performance. It is possible for a user to move a pivotfield from one area of the pivottable to another—say from the page area to the row or column area. With data stored in RAM, Excel can quickly perform the operation of re-architecting the pivottable to meet the new configuration as specified by the user.

Programmatic Creation of PivotTables

One may question when in an Excel application it is necessary to programmatically create a pivottable. In reality, there are few instances where such programmatic creation of a pivottable is necessary or even practical. However, it is quite useful in some applications to "rebuild" an existing pivottable based on certain conditions. We, therefore, are going to look at the process of programmatically creating a pivottable first, and then extend the discussion to cover instances in applications where it is useful to re-build a pivottable.

The PivotTableWizard method

A pivottable is programmatically created through calling the PivotTableWizard method of the Worksheet object. The PivotTableWizard method takes the following arguments:

SourceType: One of four values indicating the source of the pivottable data:

SourceData: The actual source of the data. Values passed for SourceData will differ depending on the value passed for sourcetype:

TableDestination: A worksheet range where the resulting pivottable will reside.

TableName: A string to be assigned as the name of the pivottable.

RowGrand: True/False—if True, then grand totals will be displayed for each row in the pivottable.

ColumnGrand: True/False—if True, then grand totals will be displayed for each column in the pivottable.

SaveData: True/False—if True, then pivottable cache data is saved with the pivottable when the workbook file containing the pivottable is saved to disk.

HasAutoFormat: True/False—if True, then any autoformats applied to the pivottable will be saved with the pivottable—and such autoformats will "stick" with the pivottable no matter what shape or size the pivottable takes on.

AutoPage: True/False—valid only if the sourcetype argument is True. If True, Excel creates a pagefield for the consolidation.

Let's take a look at a macro that can be used to create a pivottable programmatically. The example below as well as the rest of the pivottable examples in this paper are based off of an external Access 2 database named saledata.mdb and represented by an ODBC data source named "saledata".

Sub Mod1CreatePivot1()
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Set PivotSheet = Worksheets("PivotSheet")
    SQLString = "Select * FROM Revenue"
    PivotSheet.PivotTableWizard _
        SourceType:=xlExternal, _
        SourceData:=Array("DSN=SaleData", SQLString), _
        TableDestination:=PivotSheet.Range("B5"), _
        TableName:="Pivot1"
End Sub

When you run CreatePivot1 above, the resulting worksheet appears as follows:

Calling the PivotTableWizard method in CreatePivot1 above only served to read the data set into the internal RAM memory cache and allocate space on the worksheet for the pivottable. The macro gave no instructions to Excel on which areas of the table to place which pivotfields. To actually build the pivottable, then, you must specify where to place the pivotfields that make up the data set—this is done by setting the Orientation property of the PivotField object. Before we get to that, let's take a quick look at a simple macro that we will use to delete all pivottables on a worksheet. Macro DeletePivotTables below uses a For-Each-Next loop on the pivottables collection on PivotSheet worksheet to call the Clear method on TableRange2 of each pivottable on the worksheet—this will effectively, delete each pivottable that exists on the first worksheet:

Sub Mod1DeletePivotTables()
    Dim x As Variant
    For Each x In Worksheets("PivotSheet").PivotTables
        x.TableRange2.Clear
    Next
End Sub

Okay, back to setting the Orientation property of the PivotField object. Orientation can take on one of four values corresponding to the four areas of the pivottable: xlRowField, xlColumnField, xlDataField and xlPageField. Let's now take a look at a macro that will build a pivottable and actually place different pivotfields into the different areas of the pivottable:

Sub Mod1CreatePivot2()
    Dim Pivot1 As PivotTable
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Set PivotSheet = Worksheets("PivotSheet")
    Mod1DeletePivotTables
    SQLString = "Select * From Revenue"
    Set Pivot1 = PivotSheet.PivotTableWizard _
        (SourceType:=xlExternal, _
        SourceData:=Array("DSN=SaleData", SQLString), _
        TableDestination:=PivotSheet.Range("B5"), _
        TableName:="Pivot1")
    With Pivot1
        .PivotFields("Region").Orientation = xlPageField
        .PivotFields("Period").Orientation = xlRowField
        .PivotFields("Category").Orientation = xlColumnField
        .PivotFields("Revenue").Orientation = xlDataField
    End With
End Sub

The resulting pivottable from CreatePivot2 above appears as follows:

CreatePivot2 has resulted in the creation of what appears to be much more like a pivottable. Moving forward, there may be additional formatting or various other options that we may wish to implement on the pivottable. CreatePivot3 below builds the same pivottable as CreatePivot2 above, however, in this case, the Revenue pivotfield in the data area is formatted with a currency format, and one of Excel's built-in autoformats is applied to the table—xlClassic3 in this case.

Sub Mod1CreatePivot3()
    Dim Pivot1 As PivotTable
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Set PivotSheet = Worksheets("PivotSheet")
    Mod1DeletePivotTables
    SQLString = "Select * From Revenue"
    Set Pivot1 = PivotSheet.PivotTableWizard _
        (SourceType:=xlExternal, _
        SourceData:=Array("DSN=SaleData", SQLString), _
        TableDestination:=PivotSheet.Range("B5"), _
        TableName:="Pivot1")
    With Pivot1
        .PivotFields("Region").Orientation = xlPageField
        .PivotFields("Period").Orientation = xlRowField
        .PivotFields("Category").Orientation = xlColumnField
        With .PivotFields("Revenue")
            .Orientation = xlDataField
            .NumberFormat = "$#,##0_);($#,##0)"
            .Name = "Rev"
        End With
        .TableRange2.AutoFormat format:=xlClassic3
    End With
End Sub

Using the PivotTableWizard method to alter the PivotTable cache

There are certain situations where it is not practical to bring an entire database table into the pivottable RAM cache. When dealing with large datasets in a pivottable RAM cache, the main performance drain comes in the initial population of the cache, as well as any refreshing of the cache that is done (by calling the RefreshTable method on the pivottable object). To avoid any major negative impact on performance that might come with the initial population or refresh of a pivottable cache, it may be desirable to bring only a portion of a database table into the pivottable cache. When working with external data sources, this can be accomplished through manipulation of the SQL string that is passed as part of the source data argument for the pivottablewizard method. In the macros above, we used the pivottablewizard method to bring an entire database table into the RAM cache—the Revenue table. Let's assume that the Revenue table actually contains tens of thousands of records and that bringing the entire table into the RAM cache results in an unacceptable drain on performance. It may be more feasible to bring in only a portion of the data—say for the "West" region only. To do this, we change the SQL statement to the following:

Select * From Revenue WHERE Region Like 'West'

Macro CreatePivot4 builds a pivottable that contains data for the West region only:

Sub Mod1CreatePivot4()
    Dim Pivot1 As PivotTable
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Set PivotSheet = Worksheets("PivotSheet")
    Mod1DeletePivotTables
    SQLString = "Select * From Revenue WHERE Region Like 'West'"
    Set Pivot1 = PivotSheet.PivotTableWizard _
        (SourceType:=xlExternal, _
        SourceData:=Array("DSN=SaleData", SQLString), _
        TableDestination:=PivotSheet.Range("B5"), _
        TableName:="Pivot1")
    With Pivot1
        .PivotFields("Region").Orientation = xlPageField
        .PivotFields("Period").Orientation = xlRowField
        .PivotFields("Category").Orientation = xlColumnField
        With .PivotFields("Revenue")
            .Orientation = xlDataField
            .NumberFormat = "$#,##0_);($#,##0)"
            .Name = "Rev"
        End With
        .TableRange2.AutoFormat format:=xlClassic3
    End With
End Sub

By altering the SQL statement as we have done in CreatePivot4 above, we can greatly reduce the amount of time that is required to populate the pivottable cache. However, there is one glaring flaw in the pivottable that is created with CreatePivot4 above—there are only two items in the pagefield dropdown—"West" and "(All)"—choosing either will display data for only the West region. With this pivottable, how would a user be able to choose to view data for one of the other regions? With the current structure there is no way—and essentially, it doesn't make a whole lot of sense to have a pagefield dropdown with two items that cause the pivottable to display the same thing. So before we go on, let's first build the pivottable again—but this time without the pagefield dropdown:

Sub Mod1CreatePivot5()
    Dim Pivot1 As PivotTable
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Set PivotSheet = Worksheets("PivotSheet")
    Mod1DeletePivotTables
    SQLString = "Select * From Revenue WHERE Region Like 'West'"
    Set Pivot1 = PivotSheet.PivotTableWizard _
        (SourceType:=xlExternal, _
        SourceData:=Array("DSN=SaleData", SQLString), _
        TableDestination:=PivotSheet.Range("B5"), _
        TableName:="Pivot1", _
        SaveData:=False)
    With Pivot1
        .PivotFields("Period").Orientation = xlRowField
        .PivotFields("Category").Orientation = xlColumnField
        With .PivotFields("Revenue")
            .Orientation = xlDataField
            .NumberFormat = "$#,##0_);($#,##0)"
            .Name = "Rev"
        End With
        .TableRange2.AutoFormat format:=xlClassic3
    End With
End Sub

You will notice that in CreatePivot5 above, we've added an additional argument for the pivottablewizard method—False has been passed for SaveData. Passing False for SaveData results in the pivottable cache not being saved with the workbook file when the file is saved to disk. If False is passed for SaveData, then if the file is saved, closed and then reopened, no data will exist in the pivottable cache—attempts to use the pivottable will fail under such circumstances. While setting SaveData to False will result in smaller file size (due to no pivottable cache being saved with the file), you must be certain to automatically refresh the cache when the file is opened—this can be accomplished through use of an auto_open macro that includes the following statement:

Worksheets("PivotSheet").PivotTables("Pivot1").RefreshTable

Next, let's add a DropDown object to the worksheet containing the pivottable. We want the dropdown object to contain a listing of all of the unique items in the Region field in the Revenue table. The best way to obtain the unique items is to issue a SQLRequest command and pass the following SQL statement (this statement will also order the return items):

Select Distinct Region From Revenue Order by Region

The AddRegionDropDown macro below will actually add a dropdown object to the worksheet containing the pivottable and initialize it with unique values from the Region column of the Revenue table in the external database. It is possible to add a dropdown to a worksheet manually, by using the dropdown tool on the forms toolbar—but in this case, we'll go ahead and add one programmatically:

Sub Mod1AddRegionDropDown()
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Dim Drop1 As DropDown
    Dim x As Integer
    Set PivotSheet = Worksheets("PivotSheet")
    SQLString = "Select Distinct Region " & _
                "From Revenue Order by Region"
    PivotSheet.DropDowns.Add(49, 20, 90, 10).Name = "Drop1"
    Set Drop1 = PivotSheet.DropDowns("Drop1")
    PivotSheet.Select
    With Drop1
        .List = SQLRequest("DSN=SaleData", SQLString)
        .Placement = xlFreeFloating
        .OnAction = "Mod1ChangeRegion1"
        For x = 1 To .ListCount
            If .List(x) = "West" Then
                .Value = x
                Exit For
            End If
        Next
    End With
End Sub

After adding the dropdown to the worksheet, the pivottable and dropdown together appear as follows:

Macro ChangeRegion1 below builds a SQL string based on the item selected from dropdown Drop1 on the PivotSheet worksheet—this SQL string is then passed to the pivottablewizard method to refresh the pivottable cache with a new data set. A couple of things to note: first, we are not actually deleting and then re-building the pivottable here—instead, we are merely calling the pivottablewizard method on an existing pivottable; second, before we can call the pivottablewizard method on an existing table, we must first select the worksheet containing the pivottable and then select a range within the pivottable—in this case, tablerange2 as been selected (tablerange2 encompasses the entire range that is occupied by all areas of the pivottable):

Sub Mod1ChangeRegion1()
    Dim PivotSheet As Worksheet
    Dim Drop1 As DropDown
    Dim Pivot1 As PivotTable
    Dim RegionName As String
    Dim SQLString As String
    Set PivotSheet = Worksheets("PivotSheet")
    Set Pivot1 = PivotSheet.PivotTables("Pivot1")
    Set Drop1 = PivotSheet.DropDowns("Drop1")
    RegionName = Drop1.List(Drop1.Value)
    SQLString = "Select * From Revenue Where Region " & _
                "Like '" & RegionName & "'"
    Application.ScreenUpdating = False
    PivotSheet.Select
    Pivot1.TableRange2.Select
    PivotSheet.PivotTableWizard _
            SourceType:=xlExternal, _
            SourceData:=Array("DSN=SaleData", SQLString), _
            SaveData:=False
End Sub

With the ChangeRegion1 macro now assigned to the Drop1 dropdown, we can execute new SQL queries to populate the pivottable cache. The benefit that we have realized in doing this is that we have successfully reduced the size of the cache behind the pivottable—this will result in better performance in manipulation of the data in the pivottable, better performance in populating and refreshing the cache, and ultimately a smaller workbook file (if the cache is saved with the file, then the file will occupy less space on disk due to the smaller cache).

Calculated Fields in PivotTables

PivotTables are limited in the degree to which they support calculated fields. Certain calculations can be performed on pivotfields in the data area of the pivottable—this is accomplished by setting the Calculation property of the pivotfield object. For example, setting the Calculation property of a pivotfield object to xlPercentOfTotal causes Excel to calculate percentages of each pivotitem in the data area as a percentage of the total sum of all data items for the particular field in question. CreatePivot6 below provides an example of this—all numbers in the body of the resulting table are displayed as percentages of the total sum of all the numbers in the table:

Sub Mod1CreatePivot6()
    Dim Pivot1 As PivotTable
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Dim Drop1 As DropDown
    Dim x As Integer
    Set PivotSheet = Worksheets("PivotSheet")
    Set Drop1 = PivotSheet.DropDowns("Drop1")
    Mod1DeletePivotTables
    SQLString = "Select * From Revenue WHERE Region Like 'West'"
    Set Pivot1 = PivotSheet.PivotTableWizard _
        (SourceType:=xlExternal, _
        SourceData:=Array("DSN=SaleData", SQLString), _
        TableDestination:=PivotSheet.Range("B5"), _
        TableName:="Pivot1", _
        SaveData:=False)
    With Pivot1
        .PivotFields("Period").Orientation = xlRowField
        .PivotFields("Category").Orientation = xlColumnField
        With .PivotFields("Revenue")
            .Orientation = xlDataField
            .Calculation = xlPercentOfTotal
            .NumberFormat = "0.00%"
            .Name = "%Rev"
        End With
        .TableRange2.AutoFormat format:=xlClassic3
    End With
    For x = 1 To Drop1.ListCount
        If Drop1.List(x) = "West" Then
            Drop1.Value = x
            Exit For
        End If
    Next
End Sub

There are a few other calculations which the pivottable supports directly—including xlPercentofRow, xlPercentOfColumn, xlDifferenceFrom, xlPercentDifferenceFrom, xlRunningTotal, xlPercentOf and xlIndex. In truth, the calculation capabilities of pivottables are limited.

Quite often there is a need to expand the calculation capabilities of the pivottable to accommodate two scenarios:

    Creating a calculated field that is based on a calculation that involves constants.
  1. Creating a calculated field that is based on a calculation done between values in two different pivotfields.

Accomplishing either of the above is not trivial—but accomplishing number 2, creating a calculation between two pivotfields, is quite difficult, and as it turns out, in certain cases cannot be done directly in a pivottable. Let's take a look at both of these.

Creating a Calculated Field using constants

In CreatePivot6 above, we used the Calculation property of the pivotfield object to calculate the percentages of each of the revenue numbers in the data area of the table—however, these percentages were based on the total of the numbers displayed in the table, not the total for revenue for the entire Revenue table. For example, if the data for the West region is displayed, then the calculations will be performed on the total revenue for the West region only—not the total revenue for the entire database table. How does one achieve displaying a calculated pivotfield that shows percentages based on total revenue for the entire Revenue table as opposed to total revenue just for the region displayed? This can be accomplished through SQL statements. First, we must issue a SQL statement that will return the total for revenue for the entire database table:

Select DistinctRow Sum([Revenue]) As RevSum From Revenue

We can then combine the result of this SQL statement (which will be the sum of revenue from the table) with a second SQL statement that is used to build the pivottable. CreatePivot7 below builds a pivottable that contains two pivotfields in the data area = Rev, and %TotRev. %TotRev is actually a calculated column that displays revenue data as a percentage of the total revenue for all four regions (not just the region displayed). The total for all four regions is retrieved from the external database by making a call to SQLRequest. Note that in order to use SQLRequest, you must establish a reference to the xlodbc.xla addin (from a VBA module, select Tools-References-Browse, and then select \excel\library\msquery\xlodbc.xla). This is then combined into an additional SQL statement which is passed to the PivotTableWizard method in creating the table:

Sub Mod1CreatePivot7()
    Dim Pivot1 As PivotTable
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Dim SQLArray As Variant
    Dim Drop1 As DropDown
    Dim x As Integer
    Mod1DeletePivotTables
    SQLArray = SQLRequest("DSN=SaleData", "Select " & _
        "DistinctRow Sum([Revenue]) As RevSum From Revenue")
    SQLString = "Select *, Revenue / " & SQLArray(1) & _
        " As PercentRev From Revenue WHERE Region Like 'West'"
    Set PivotSheet = Worksheets("PivotSheet")
    Set Drop1 = PivotSheet.DropDowns("Drop1")
    Set Pivot1 = PivotSheet.PivotTableWizard _
        (SourceType:=xlExternal, _
        SourceData:=Array("DSN=SaleData", SQLString), _
        TableDestination:=PivotSheet.Range("B5"), _
        TableName:="Pivot1", _
        SaveData:=False)
    With Pivot1
        .PivotFields("Period").Orientation = xlRowField
        .PivotFields("Category").Orientation = xlColumnField
        With .PivotFields("Revenue")
            .Orientation = xlDataField
            .NumberFormat = "$#,##0_);($#,##0)"
            .Name = "Rev"
        End With
        With .PivotFields("PercentRev")
            .Orientation = xlDataField
            .NumberFormat = "0.00%"
            .Name = "%TotRev"
        End With
        With .PivotFields("Data")
            .Orientation = xlColumnField
            .Position = 2
        End With
        .TableRange2.AutoFormat format:=xlClassic3
    End With
    For x = 1 To Drop1.ListCount
        If Drop1.List(x) = "West" Then
            Drop1.Value = x
            Exit For
        End If
    Next
    Drop1.OnAction = "Mod1ChangeRegion2"
End Sub

After changing the pivottable to display the calculated field based on a constant, we must now go back and change the ChangeRegion macro so that our dropdown will still work. ChangeRegion2 below employs the necessary SQL strings to update the pivottable cache with the proper data set to keep the same table structure when a new value is selected from the dropdown.

Sub Mod1ChangeRegion2()
    Dim PivotSheet As Worksheet
    Dim Drop1 As DropDown
    Dim Pivot1 As PivotTable
    Dim RegionName As String
    Dim SQLString As String
    Dim SQLArray As Variant
    Set PivotSheet = Worksheets("PivotSheet")
    Set Pivot1 = PivotSheet.PivotTables("Pivot1")
    Set Drop1 = PivotSheet.DropDowns("Drop1")
    RegionName = Drop1.List(Drop1.Value)
    SQLArray = SQLRequest("DSN=SaleData", "Select " & _
        "DistinctRow Sum([Revenue]) As RevSum From Revenue")
    SQLString = "Select *, Revenue / " & SQLArray(1) & _
        " As PercentRev From Revenue WHERE Region Like '" & _
        RegionName & "'"
    Application.ScreenUpdating = False
    PivotSheet.Select
    Pivot1.TableRange2.Select
    PivotSheet.PivotTableWizard _
            SourceType:=xlExternal, _
            SourceData:=Array("DSN=SaleData", SQLString), _
            SaveData:=False
End Sub
        

Creating a Calculated Field based on 2 or more PivotFields

As stated above, creating a calculated field that employs a calculation involving some mathematical expression between two or more pivotfields can be quite difficult—this is the case due to the fact that the pivottable object, as it is currently architected, is not set up to handle such multi-field calculations in all circumstances. CreatePivot8 below uses a SQL statement to generate a calculated pivotfield named "Margin" that is the percentage result of the Profit field divided by the Revenue field.

Sub Mod1CreatePivot8()
    Dim Pivot1 As PivotTable
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Dim Drop1 As DropDown
    Dim x As Integer
    Mod1DeletePivotTables
    SQLString = "Select *, Profit / Revenue As Margin From " & _
                "Revenue WHERE Region Like 'West'"
    Set PivotSheet = Worksheets("PivotSheet")
    Set Drop1 = PivotSheet.DropDowns("Drop1")
    Set Pivot1 = PivotSheet.PivotTableWizard _
        (SourceType:=xlExternal, _
        SourceData:=Array("DSN=SaleData", SQLString), _
        TableDestination:=PivotSheet.Range("B5"), _
        TableName:="Pivot1", _
        SaveData:=False, _
        RowGrand:=False, _
        ColumnGrand:=False)
    With Pivot1
        .PivotFields("Period").Orientation = xlRowField
        .PivotFields("Category").Orientation = xlColumnField
        With .PivotFields("Revenue")
            .Orientation = xlDataField
            .NumberFormat = "$#,##0_);($#,##0)"
            .Name = "Rev"
        End With
        With .PivotFields("Profit")
            .Orientation = xlDataField
            .NumberFormat = "$#,##0_);($#,##0)"
            .Name = "Prof"
        End With
        With .PivotFields("Margin")
            .Orientation = xlDataField
            .NumberFormat = "0.00%"
            .Name = "%Margin"
        End With
        With .PivotFields("Data")
            .Orientation = xlColumnField
            .Position = 2
        End With
        .TableRange2.AutoFormat format:=xlClassic3
    End With
    For x = 1 To Drop1.ListCount
        If Drop1.List(x) = "West" Then
            Drop1.Value = x
            Exit For
        End If
    Next
    Drop1.OnAction = "Mod1ChangeRegion3"
End Sub
Sub Mod1ChangeRegion3()
    Dim PivotSheet As Worksheet
    Dim Drop1 As DropDown
    Dim Pivot1 As PivotTable
    Dim RegionName As String
    Dim SQLString As String
    Set PivotSheet = Worksheets("PivotSheet")
    Set Pivot1 = PivotSheet.PivotTables("Pivot1")
    Set Drop1 = PivotSheet.DropDowns("Drop1")
    RegionName = Drop1.List(Drop1.Value)
    SQLString = "Select *, Profit / Revenue As Margin From " & _
                "Revenue WHERE Region Like '" & RegionName & "'"
    Application.ScreenUpdating = False
    PivotSheet.Select
    Pivot1.TableRange2.Select
    PivotSheet.PivotTableWizard _
            SourceType:=xlExternal, _
            SourceData:=Array("DSN=SaleData", SQLString), _
            SaveData:=False
End Sub

CreatePivot8 works fine as it is. However, if you display totals (either row or column totals) in the pivottable, you will notice immediately that the values displayed in the Margin field are incorrect for the totals. This occurs because the pivottable does not know to apply the calculation on the total or subtotal level—rather, the total displayed for Margin is the actual sum of all the individual percentage margin values. A similar result will occur if you attempt to group the pivotfields in the row or column headings in any way. As a general rule, you can only create calculated fields that employ calculations involving multiple pivotfields for data that is not grouped or not totaled in any way—data that is displayed at the record level. If such data is grouped or totaled in any way, then Excel will not perform the calculation on the totals or groups, but rather just some the results of the calculations for the individual records. This may present no problem for certain calculations—however for other calculations, it may present a problems (such as those used to calculate percentage differences between two columns of data).

The question is then, how can one implement a percentage calculation that involves two or more pivotfields so that such calculations are displayed correctly for grouped and totaled data? Such calculations cannot be done in the pivottable. You must therefore copy the data out of the pivottable into a worksheet range and then perform the calculation on the worksheet range. Macros CreatePivot9 and CopyPivot below accomplish the task. CreatePivot9 rebuilds the pivottable to remove the %Margin field that was created with CreatePivot8. CopyPivot then through use of worksheet formulas, links a range of data on the DataSheet worksheet to the pivottable range—CopyPivot then adds columns that contain worksheet formulas that perform the calculation.

Sub Mod1CreatePivot9()
    Dim Pivot1 As PivotTable
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Dim Drop1 As DropDown
    Dim x As Integer
    Mod1DeletePivotTables
    SQLString = "Select * From Revenue WHERE Region Like 'West'"
    Set PivotSheet = Worksheets("PivotSheet")
    Set Drop1 = PivotSheet.DropDowns("Drop1")
    Set Pivot1 = PivotSheet.PivotTableWizard _
        (SourceType:=xlExternal, _
        SourceData:=Array("DSN=SaleData", SQLString), _
        TableDestination:=PivotSheet.Range("B5"), _
        TableName:="Pivot1", _
        SaveData:=False)
    With Pivot1
        .PivotFields("Period").Orientation = xlRowField
        .PivotFields("Category").Orientation = xlColumnField
        With .PivotFields("Revenue")
            .Orientation = xlDataField
            .NumberFormat = "$#,##0_);($#,##0)"
            .Name = "Rev"
        End With
        With .PivotFields("Profit")
            .Orientation = xlDataField
            .NumberFormat = "$#,##0_);($#,##0)"
            .Name = "Prof"
        End With
        With .PivotFields("Data")
            .Orientation = xlColumnField
            .Position = 2
        End With
        .TableRange2.AutoFormat format:=xlClassic3
    End With
    For x = 1 To Drop1.ListCount
        If Drop1.List(x) = "West" Then
            Drop1.Value = x
            Exit For
        End If
    Next
    Mod1CopyPivot
End Sub
Sub Mod1CopyPivot()
    Dim RowNum As Integer
    Dim ColNum As Integer
    Dim TargetRange As Range
    Dim PivotSheet As Worksheet
    Dim DataSheet As Worksheet
    Dim Pivot1 As PivotTable
    Dim PivotRange As Range
    Dim x As Integer
    Dim y As Variant
    Set PivotSheet = Worksheets("PivotSheet")
    Set DataSheet = Worksheets("DataSheet")
    Set Pivot1 = PivotSheet.PivotTables("Pivot1")
    Application.ScreenUpdating = False
    DataSheet.Range("Top").CurrentRegion.Clear
    With Pivot1.TableRange1
        RowNum = .Rows.Count
        ColNum = .Columns.Count
        Set TargetRange = _
        DataSheet.Range("Top").Resize(RowNum, ColNum)
    End With
    Set PivotRange = Pivot1.TableRange1
    With DataSheet.Range("Top")
        .Formula = "=PivotSheet!" & _
            PivotRange.Resize(1, 1).Address(False, False)
        .Copy TargetRange
        .Formula = "=if(PivotSheet!" & _
            PivotRange.Resize(1, 1).Address(False, False) & _
            "=0,"""",PivotSheet!" & _
            PivotRange.Resize(1, 1).Address(False, False) & ")"
        .Copy TargetRange.Resize(3, ColNum)
    End With
    With TargetRange
        .NumberFormat = "$#,##0_);($#,##0)"
        For x = 9 To 3 Step -2
            With .Offset(3, x).Resize(RowNum - 3, 1)
                .EntireColumn.Insert xlToRight
                With .Offset(0, -1)
                    .Formula = _
                        "=IF(RC[-2]<>0,RC[-1]/RC[-2],""NA"")"
                    .NumberFormat = "0%"
                End With
                .Offset(-1, -1).Resize(1, 1).Value = "Margin"
            End With
        Next
    End With
    Set TargetRange = DataSheet.Range("Top").CurrentRegion
    With TargetRange
        .AutoFormat format:=xlClassic3
        RowNum = .Rows.Count
        ColNum = .Columns.Count
        With .Offset(RowNum - 1, 0).Resize(1, ColNum)
            .Interior.ColorIndex = 2
            With .Borders(xlTop)
                .LineStyle = xlContinuous
                .Weight = xlMedium
                .ColorIndex = 1
            End With
        End With
        For x = 3 To 12 Step 3
            With .Offset(2, x).Resize(RowNum - 2, 1)
                .Interior.ColorIndex = 16
                .Font.ColorIndex = 2
            End With
        Next
        .Columns.AutoFit
    End With
    DataSheet.Select
End Sub

After running CreatePivot9 and CopyPivot, the resulting range of data on the DataSheet worksheet appears as follows:

As can be seen in the diagram above, we have been able to take advantage of formulas, as well as formatting features, in the Excel worksheet to create a worksheet table that gives the impression of a pivottable—with percentage calculated fields (that even show correct values for totals).

However, with the worksheet range on the DataSheet worksheet as it currently is, we have no control over the regional data that is displayed in the worksheet table. If we add a dropdown to the worksheet, we can add a degree of control. Through use of the AddRegionDropDown2 and ChangeRegion4 macros below, we can add a dropdown to the DataSheet worksheet that allows the user to select one of four different regions. When the user selects a new region, a new SQL string is built and the pivottable is rebuilt based on the new SQL string—due to the formulas linking the DataSheet worksheet to the PivotSheet worksheet, the table on DataSheet is automatically updated when the pivottable is rebuilt.

Sub Mod1AddRegionDropDown2()
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Dim DataSheet As Worksheet
    Dim Drop1 As DropDown
    Dim Drop2 As DropDown
    Dim x As Integer
    Set DataSheet = Worksheets("DataSheet")
    Set PivotSheet = Worksheets("PivotSheet")
    Set Drop1 = PivotSheet.DropDowns("Drop1")
    SQLString = "Select Distinct Region " & _
                "From Revenue Order by Region"
    DataSheet.DropDowns.Add(49, 20, 90, 10).Name = "Drop2"
    Set Drop2 = DataSheet.DropDowns("Drop2")
    DataSheet.Select
    With Drop2
        .List = SQLRequest("DSN=SaleData", SQLString)
        .OnAction = "Mod1ChangeRegion4"
        .Placement = xlFreeFloating
        .Value = Drop1.Value
    End With
End Sub
Sub Mod1ChangeRegion4()
    Dim PivotSheet As Worksheet
    Dim DataSheet As Worksheet
    Dim Drop1 As DropDown
    Dim Drop2 As DropDown
    Dim Pivot1 As PivotTable
    Dim RegionName As String
    Dim SQLString As String
    Set PivotSheet = Worksheets("PivotSheet")
    Set DataSheet = Worksheets("DataSheet")
    Set Pivot1 = PivotSheet.PivotTables("Pivot1")
    Set Drop1 = PivotSheet.DropDowns("Drop1")
    Set Drop2 = DataSheet.DropDowns("Drop2")
    RegionName = Drop2.List(Drop2.Value)
    SQLString = "Select * From Revenue Where Region " & _
                "Like '" & RegionName & "'"
    Application.ScreenUpdating = False
    PivotSheet.Select
    Pivot1.TableRange2.Select
    PivotSheet.PivotTableWizard _
            SourceType:=xlExternal, _
            SourceData:=Array("DSN=SaleData", SQLString), _
            SaveData:=False
    DataSheet.Select
    Drop1.Value = Drop2.Value
End Sub

Using ChangeRegion4 above, we are still acting under the assumption that there is a large amount of data for any region in the pivottable—and that bringing in data for all regions will result in drains on performance. If we go back to bringing all data into the pivottable, in this case, we will actually see the data table on DataSheet update much more quickly.

Sub Mod1CreatePivot10()
    Dim Pivot1 As PivotTable
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Dim Drop1 As DropDown
    Dim DataSheet As Worksheet
    Dim x As Integer
    On Error Resume Next
    Mod1DeletePivotTables
    SQLString = "Select * From Revenue"
    Set PivotSheet = Worksheets("PivotSheet")
    Set Pivot1 = PivotSheet.PivotTableWizard _
        (SourceType:=xlExternal, _
        SourceData:=Array("DSN=SaleData", SQLString), _
        TableDestination:=PivotSheet.Range("B5"), _
        TableName:="Pivot1", _
        SaveData:=False)
    With Pivot1
        .PivotFields("Period").Orientation = xlRowField
        .PivotFields("Category").Orientation = xlColumnField
        .PivotFields("Region").Orientation = xlPageField
        With .PivotFields("Revenue")
            .Orientation = xlDataField
            .NumberFormat = "$#,##0_);($#,##0)"
            .Name = "Rev"
        End With
        With .PivotFields("Profit")
            .Orientation = xlDataField
            .NumberFormat = "$#,##0_);($#,##0)"
            .Name = "Prof"
        End With
        With .PivotFields("Data")
            .Orientation = xlColumnField
            .Position = 2
        End With
        .TableRange2.AutoFormat format:=xlClassic3
    End With
    Set DataSheet = Worksheets("DataSheet")
    Set Drop1 = DataSheet.DropDowns("Drop1")
    For x = 1 To Drop1.ListCount
        If Drop1.List(x) = _
            Pivot1.PivotFields("Region").CurrentPage Then
            Drop1.Value = x
            Exit For
        End If
    Next
    PivotSheet.DropDowns("Drop1").Delete
    Mod1CopyPivot
    Mod1InitalizeRegionDropDown3
End Sub
Sub Mod1InitalizeRegionDropDown3()
    Dim SQLString As String
    Dim DataSheet As Worksheet
    Dim Drop2 As DropDown
    Dim Pivot1 As PivotTable
    Dim PivotSheet As Worksheet
    Dim x As Integer
    Set PivotSheet = Worksheets("PivotSheet")
    Set Pivot1 = PivotSheet.PivotTables("Pivot1")
    Set DataSheet = Worksheets("DataSheet")
    SQLString = "Select Distinct Region " & _
                "From Revenue Order by Region"
    Set Drop2 = DataSheet.DropDowns("Drop2")
    With Drop2
        .List = SQLRequest("DSN=SaleData", SQLString)
        .AddItem Text:="(All)"
        .OnAction = "Mod1ChangeRegion5"
        For x = 1 To .ListCount
            If .List(x) = _
                Pivot1.PivotFields("Region").CurrentPage Then
                .Value = x
                Exit For
            End If
        Next
    End With
End Sub
Sub Mod1ChangeRegion5()
    Dim PivotSheet As Worksheet
    Dim DataSheet As Worksheet
    Dim Drop2 As DropDown
    Dim Pivot1 As PivotTable
    Dim RegionName As String
    Dim TargetRange As Range
    Set PivotSheet = Worksheets("PivotSheet")
    Set DataSheet = Worksheets("DataSheet")
    Set Pivot1 = PivotSheet.PivotTables("Pivot1")
    Set Drop2 = DataSheet.DropDowns("Drop2")
    Pivot1.PivotFields("Region").CurrentPage = _
        Drop2.List(Drop2.Value)
    Set TargetRange = DataSheet.Range("Top").CurrentRegion
    TargetRange.Columns.AutoFit
End Sub

With the table on the DataSheet worksheet, it is now possible to have calculated fields displayed for the pivottable data—no matter what grouping or totals are incorporated into the table. As an example, let's add a CheckBox to DataSheet to group the data in the Period pivotfield. Macro AddCheckBox1 below adds a checkbox to the DataSheet worksheet and assigns macro GroupPeriodField to the checkbox. Macro GroupPeriodField calls the Group method on the first cell in the DataRange of the Period pivotfield in the Row area of the pivottable—it then calls CopyPivot to copy the new pivottable structure to DataSheet.

Sub Mod1AddCheckBox1()
    Dim Check1 As CheckBox
    Dim DataSheet As Worksheet
    Set DataSheet = Worksheets("DataSheet")
    Set Check1 = DataSheet.CheckBoxes.Add(180, 20, 130, 10)
    With Check1
        .Name = "Check1"
        .Interior.ColorIndex = 15
        .OnAction = "Mod1GroupPeriodField"
        .Caption = "Display Quarterly Data"
        .Placement = xlFreeFloating
        With .Border
            .LineStyle = xlContinuous
            .ColorIndex = 1
            .Weight = xlMedium
        End With
    End With
    DataSheet.Select
End Sub
Sub Mod1GroupPeriodField()
    Dim DataSheet As Worksheet
    Dim Check1 As CheckBox
    Dim Pivot1 As PivotTable
    Dim PivotSheet As Worksheet
    Set PivotSheet = Worksheets("PivotSheet")
    Set Pivot1 = PivotSheet.PivotTables("Pivot1")
    Set DataSheet = Worksheets("DataSheet")
    Set Check1 = DataSheet.CheckBoxes("Check1")
    Application.ScreenUpdating = False
    PivotSheet.Select
    If Check1.Value = xlOn Then
        Pivot1.PivotFields("Period").DataRange.Select
        Selection.Resize(1, 1).Group _
            Start:=True, End:=True, _
            Periods:=Array(False, False, _
            False, False, False, True, False)
    Else
        Pivot1.PivotFields("Period").DataRange.Select
        Selection.Resize(1, 1).Ungroup
    End If
    Mod1CopyPivot
End Sub

After adding the CheckBox to DataSheet—you can see how you can now group pivottable data and still have calculated fields displayed in the table on the DataSheet worksheet—as is shown in the diagram below:

Using PivotTables to Enter Data

You can't really enter data using a pivottable—in fact, if you try to enter data into the body of a pivottable, Excel will generate an error message indicating that data entry in a pivottable is not permitted. However, it is possible to create an interface similar to that which we created for calculated fields above that would permit this—an interface where a pivottable lies hidden in the background and data is copied from the pivottable into a worksheet that is viewed by the user. With such an interface, the user can enter data in the worksheet which can then be exported to the external data source—the pivottable cache can then be refreshed to include the data that the user has entered in the worksheet. This is a round-about way of updating the cache—however, it can prove to be an effective means of allowing a user to enter data into an external data source.

Before we go into using pivottables in such a way, let's first address one minor topic. You will have noticed that in many of the macros above, much time was spent dimensioning and setting object variables. Moving forward, instead of dimensioning and setting such object variables in every macro, let's create a single routine that can be called from any macro that will set all object variables that we will be using. In order to do this, we must declare the object variables as module-level variables. The SetObjects macro below suits our needs in this regard:

Option Explicit
Option Base 1
Dim Pivot1 As PivotTable
Dim Pivot2 As PivotTable
Dim PivotSheet As Worksheet
Dim DataSheet As Worksheet
Dim DataEntrySheet As Worksheet
Dim Pivot2Sheet As Worksheet
Dim Drop1 As DropDown
Dim ValArray() As String
Dim Check1 As CheckBox
Dim UpdateDataNow As Boolean
Dim EntryHasBeenMade As Boolean
Dim DataHasNotBeenUpdated As Boolean
Dim ValNum As Integer
Dim Button1 As Button
Dim Button2 As Button
Sub Mod2SetObjects()
    On Error Resume Next
    Set PivotSheet = Worksheets("PivotSheet")
    Set Pivot1 = PivotSheet.PivotTables("Pivot1")
    Set Pivot2Sheet = Worksheets("Pivot2Sheet")
    Set Pivot2 = Pivot2Sheet.PivotTables("Pivot2")
    Set DataSheet = Worksheets("DataSheet")
    Set DataEntrySheet = Worksheets("DataEntrySheet")
    Set Drop1 = DataEntrySheet.DropDowns("Drop1")
    Set Check1 = DataEntrySheet.CheckBoxes("Check1")
    Set Button1 = DataEntrySheet.Buttons("Button1")
    Set Button2 = DataEntrySheet.Buttons("Button2")
End Sub

Now that our object variables are set, we will need a routine that will create a second pivottable for our purposes here. Macro Mod2CreatePivot2 below creates a second pivottable on the worksheet named Pivot2Sheet—the data source for this second pivottable is the same data source as that for Pivot1 on the worksheet PivotSheet—both pivottables will actually share a common RAM cache. By sharing the data source in this manner, we can actually reduce the amount of memory stored in RAM, and improve performance:

Sub Mod2CreatePivot2()
    On Error Resume Next
    Dim SQLString As String
    Dim x As Variant
    Mod2SetObjects
    PivotSheet.Select
    Pivot1.PivotFields("Period").DataRange.Select
    Selection.Resize(1, 1).Ungroup
    Set Pivot2 = Pivot2Sheet.PivotTableWizard _
        (SourceType:=xlPivotTable, _
        SourceData:="[" & ThisWorkbook.Name & _
            "]PivotSheet!Pivot1", _
        TableDestination:=Pivot2Sheet.Range("B5"), _
        TableName:="Pivot2")
    With Pivot2
        .PivotFields("Region").Orientation = xlPageField
        .PivotFields("Period").Orientation = xlRowField
        .PivotFields("Category").Orientation = xlColumnField
        With .PivotFields("Revenue")
            .Orientation = xlDataField
            .NumberFormat = "$#,##0_);($#,##0)"
            .Name = "Rev"
        End With
        With .TableRange2
            .AutoFormat format:=xlClassic3, Width:=False
            .Columns.AutoFit
        End With
    End With
End Sub

Next, we need a macro that can be used to copy the contents of Pivot2 to a worksheet. Macro Mod2CopyPivot below copies TableRange1 of Pivot2 to worksheet DataEntrySheet. Also notice that after the copy, the macro replaces the Total values in the last row and last column with formulas—and then protects the sheet (all but the cells in the data area of the resulting table are protected). You notice that now you can enter data into the body of the resulting table on DataEntrySheet and the totals in the last row and last column will update. Attempts to enter data into any area other than the body of the table fail—sheet protection allows us to control what the user can actually change on a worksheet.

Sub Mod2CopyPivot()
    Dim RowNum As Integer
    Dim ColNum As Integer
    Dim TargetRange As Range
    Dim PivotRange As Range
    Dim x As Integer
    Dim y As Variant
    Mod2SetObjects
    Application.ScreenUpdating = False
    DataEntrySheet.Unprotect
    DataEntrySheet.Range("Top2").CurrentRegion.Clear
    With Pivot2.TableRange1
        RowNum = .Rows.Count
        ColNum = .Columns.Count
        Set TargetRange = _
        DataEntrySheet.Range("Top2").Resize(RowNum, ColNum)
    End With
    Set PivotRange = Pivot2.TableRange1
    PivotRange.Copy TargetRange
    With TargetRange
        .Columns.AutoFit
        .Offset(2, 1).Resize(RowNum - 3, ColNum - 2).Locked = _
                False
        .Offset(RowNum - 1, 1).Resize(1, 4).Formula = _
                "=sum(R[-12]C:R[-1]C)"
        .Offset(2, ColNum - 1).Resize(12, 1).Formula = _
                "=sum(RC[-3]:RC[-1])"
    End With
    With DataEntrySheet
        .Protect
        .Select
    End With
End Sub

Now, we need some way for the user to look at data for different regions. We'll add a dropdown to the DataEntrySheet worksheet that will adjust the pivottable to reflect the user's choice and then copy the resulting data to DataEntrySheet. Macro Mod2AddRegionDropDown below adds a dropdown to DataEntrySheet and then assigns the macro Mod2ChangeData to it:

Sub Mod2AddRegionDropDown()
    Dim SQLString As String
    Mod2SetObjects
    DataEntrySheet.Unprotect
    SQLString = "Select Distinct Region " & _
                "From Revenue Order by Region"
    DataEntrySheet.DropDowns.Add(49, 20, 90, 10).Name = "Drop1"
    Set Drop1 = DataEntrySheet.DropDowns("Drop1")
    DataEntrySheet.Select
    With Drop1
        .List = SQLRequest("DSN=SaleData", SQLString)
        .OnAction = "Mod2ChangeData"
        .Placement = xlFreeFloating
    End With
    DataEntrySheet.Protect
End Sub
Sub Mod2ChangeData()
    On Error Resume Next
    Dim RegionVar As String
    Dim UpdateIt As Integer
    Dim x As Variant
    Dim Drop1Val As Integer
    Mod2SetObjects
    Application.ScreenUpdating = False
    Drop1Val = Drop1.Value
    If DataHasNotBeenUpdated Then
        For x = 1 To Drop1.ListCount
            If Drop1.List(x) = _
                Pivot2.PivotFields("Region").CurrentPage Then
                Drop1.Value = x
                Exit For
            End If
        Next
        UpdateIt = MsgBox("Update changes now?", vbYesNoCancel, _
            "Update Changes?")
        If UpdateIt = vbYes Then
            Mod2UpdateDatabase
        ElseIf UpdateIt = vbCancel Then
            Exit Sub
        End If
    End If
    Drop1.Value = Drop1Val
    If EntryHasBeenMade Then
        Pivot2.RefreshTable
        EntryHasBeenMade = False
    End If
    RegionVar = Drop1.List(Drop1.Value)
    Pivot2.PivotFields("Region").CurrentPage = RegionVar
    Mod2CopyPivot
    DataHasNotBeenUpdated = False
    With Button1
        .Enabled = False
        .Font.ColorIndex = 16
    End With
End Sub

Our goal is to give the user two options:

    Whenever an entry is made into the body of the table on DataEntrySheet, the data is immediately exported to the external database.
  1. Rather than exporting data with each entry, the user can choose to make several entries, and then export them all at once.

In order to achieve this goal, we must place a checkbox on DataEntrySheet that will allow the user to choose between exporting on entry or waiting until several entries are made. We must also set an OnEntry macro that will capture the addresses of all cells into which all entries are made by the user and store such addresses in an array (valarray).

The AddCheckBox1 macro below adds a checkbox to DataEntrySheet that allows the user to select between updating external database data on entry—or waiting until a number of files have been entered, and initiating database update through clicking the Update Now button (to be added with the next macro). This is accomplished through the SetOnEntry macro assigned to the CheckBox. SetOnEntry will assign the CallUpdateDatabase macro as an OnEntry macro for the DataEntrySheet—whenever the user makes an entry on DataEntrySheet, CallUpdateDatabase will execute. Note that for the Checkbox, the Placement property is set to xlFreeFloating—because the CopyPivot macro inserts columns on DataEntrySheet, we want to make sure the checkbox is floating on the sheet to keep it from moving with the cells underneath.

Sub Mod2AddCheckBox1()
    Mod2SetObjects
    DataEntrySheet.Unprotect
    Set Check1 = DataEntrySheet.CheckBoxes.Add(180, 20, 130, 10)
    With Check1
        .Name = "Check1"
        .Interior.ColorIndex = 15
        .OnAction = "Mod2SetOnEntry"
        .Caption = "Update On Entry"
        .Placement = xlFreeFloating
        With .Border
            .LineStyle = xlContinuous
            .ColorIndex = 1
            .Weight = xlMedium
        End With
    End With
    DataEntrySheet.OnSheetActivate = "Mod2SetOnEntry"
    With DataEntrySheet
        .Protect
        .Select
    End With
End Sub

As stated above, if the Update On Entry checkbox is checked, then as the users enters data into the body of the table, entries are automatically updated to the external database (this is accomplished through the CallUpdateDatabase and UpdateDatabase macros discussed below). However, if the user does not check the Update On Entry checkbox, then addresses of cells to which the user makes changes are stored in an array called ValArray. When the user is then done making changes to data, we need a way to allow the user to update all of the changes to the external database. AddUpdateButton adds a button to DataEntrySheet that will call the UpdateDatabase macro, which will in turn update all records that correspond to the entries in ValArray.

Sub Mod2AddUpdateButton()
    Mod2SetObjects
    DataEntrySheet.Unprotect
    Set Button1 = DataEntrySheet.Buttons.Add(370, 20, 70, 25)
    With Button1
        .Name = "Button1"
        .Caption = "Update Now"
        .OnAction = "Mod2UpdateDatabase"
        .Enabled = False
        .Font.ColorIndex = 16
        .LockedText = False
    End With
    With DataEntrySheet
        .Protect
        .Select
    End With
End Sub
    

AddRefreshButton adds a second button to DataEntrySheet that allows the user to refresh the data in the data entry table from the external data source. This is multi-step process. Consider how the data makes its way to the table on DataEntrySheet—from the external database, data flows into the cache for Pivot1 and then into Pivot2 and is then copied to DataEntrySheet. To refresh the data then, we must call the RefreshTable method on Pivot2 (or Pivot1), then call the CopyPivot macro to copy the data from Pivot2 to DataEntrySheet.

Sub Mod2AddRefreshButton()
    Mod2SetObjects
    DataEntrySheet.Unprotect
    Set Button2 = DataEntrySheet.Buttons.Add(370, 60, 70, 25)
    With Button2
        .Name = "Button2"
        .Caption = "Refresh"
        .OnAction = "Mod2RefreshPivot"
    End With
    With DataEntrySheet
        .Protect
        .Select
    End With
End Sub

SetOnEntry below is assigned to the Update On Entry checkbox on DataEntrySheet. Depending on the value of the checkbox, SetOnEntry will assign the CallUpdateDatabase macro to the OnEntry event for DataEntrySheet and then assign a value to a module level Boolean variable named UpdateDataNow which is used to keep track of the value of the checkbox.

Sub Mod2SetOnEntry()
    On Error Resume Next
    Application.ScreenUpdating = False
    Dim x As Integer
    Mod2SetObjects
    ValNum = 1
    Select Case Check1.Value
        Case xlOn
            ReDim ValArray(1)
            UpdateDataNow = True
            DataEntrySheet.OnEntry = "Mod2CallUpdateDatabase"
        Case xlOff
            Dim TargetRange As Range
            DataEntrySheet.Unprotect
            Set TargetRange = _
                DataEntrySheet.Range("Top2").CurrentRegion
            ReDim ValArray(TargetRange.Rows.Count * _
                TargetRange.Columns.Count)
            UpdateDataNow = False
            DataEntrySheet.OnEntry = "Mod2CallUpdateDatabase"
            DataEntrySheet.Protect
    End Select
    For x = 1 To Drop1.ListCount
        If Drop1.List(x) = _
            Pivot2.PivotFields("Region").CurrentPage Then
            Drop1.Value = x
            Exit For
        End If
    Next
End Sub

CallUpdateDatabase is assigned to the OnEntry event of DataEntrySheet. Each time a user enters a value on DataEntrySheet, CallUpdateDatabase executes. CallUpdateDatabase first adds the entry just made by the user to ValArray. Then, depending on the value of the UpdateDataNow Boolean variable (the value of the Update On Entry checkbox), CallUpdateDatabase will either call the UpdateDatabase macro to update data that the user has just entered or increment the counter for ValArray—this is done so that multiple values can be added to ValArray so that all values referenced by addresses in ValArray can be updated at a later time.

Sub Mod2CallUpdateDatabase()
    On Error Resume Next
    Mod2SetObjects
    ValArray(ValNum) = ActiveCell.Address
    If UpdateDataNow Then
        Mod2UpdateDatabase
        DataHasNotBeenUpdated = False
    Else
        ValNum = ValNum + 1
        With Button1
            .Enabled = True
            .Font.ColorIndex = 1
        End With
        DataHasNotBeenUpdated = True
    End If
    EntryHasBeenMade = True
End Sub

UpdateDataBase uses all addresses in ValArray to determine into which cells in the table on DataEntrySheet entries have been made. Then by using the column heading, row heading and current selection in the dropdown on DataEntrySheet, a SQL statement is used to build a query that will update the entered value to the appropriate record in the external database. A For-Each-Next loop is used to do this for each value referenced by ValArray. Once each value has been updated, ValArray is cleared so that the whole process can start over again.

Sub Mod2UpdateDatabase()
    On Error Resume Next
    Dim Range1 As Range
    Dim TargetRange As Range
    Dim RegionVar As String
    Dim CategoryVar As String
    Dim DateVar As String
    Dim RowNum As Integer
    Dim ColNum As Integer
    Dim DataSource As Variant
    Dim SQLString As String
    Dim x As Variant
    Dim y As Integer
    Mod2SetObjects
    DataEntrySheet.Unprotect
    RegionVar = Drop1.List(Drop1.Value)
    DataSource = SQLOpen("DSN=SaleData")
    For Each x In ValArray
        If x = "" Then
            Exit For
        Else
            Set Range1 = DataEntrySheet.Range(x)
            Set TargetRange = _
                DataEntrySheet.Range("Top2").CurrentRegion
            RowNum = Range1.Row - TargetRange.Row
            ColNum = Range1.Column - TargetRange.Column
            CategoryVar = _
                TargetRange.Offset(1, ColNum).Resize(1, 1).Value
            DateVar = _
                TargetRange.Offset(RowNum, 0).Resize(1, 1).Value
            SQLString = "Update Revenue " & _
                        "Set Revenue = " & _
                        DataEntrySheet.Range(x).Value & _
                        " Where Region = '" & RegionVar & _
                        "' And Category = '" & CategoryVar & _
                        "' And Period = " & _
                        Mod2MakeDateStamp(DateVar) & ""
            SQLExecQuery DataSource, SQLString
        End If
    Next
    SQLClose DataSource
    With Button1
        .Enabled = False
        .Font.ColorIndex = 16
    End With
    DataEntrySheet.Protect
    For y = 1 To UBound(ValArray)
        ValArray(y) = ""
    Next
    ValNum = 1
    DataHasNotBeenUpdated = False
End Sub

Function MakeDataStamp is called by UpdateDatabase to convert text string dates to date stamp dates, necessary for queries involving date values with Access 2 (and SQL Server) databases.

Function Mod2MakeDateStamp(ByVal DateString As String) As String
    Dim string1 As String
    Dim YearString As String
    Dim MonthString As String
    Dim DayString As String
    Dim TempMonth As String
    Dim TempDay As String
    Dim MonthVal As Boolean
    Dim DayVal As Boolean
    Dim x As Integer
    MonthVal = True
    DayVal = False
    YearString = Right(DateString, 2)
    For x = 1 To Len(DateString)
        If DayVal = True Then
            TempDay = TempDay & Mid(DateString, x, 1)
        End If
        If Mid(DateString, x, 1) = "/" And DayVal = False Then
            MonthVal = False
            DayVal = True
        ElseIf Mid(DateString, x + 1, 1) = "/" _
            And DayVal = True Then
            DayVal = False
            Exit For
        End If
        If MonthVal = True Then
            TempMonth = TempMonth & Mid(DateString, x, 1)
        End If
    Next
    If Len(TempMonth) = 2 Then
        MonthString = TempMonth
    Else
        MonthString = "0" & TempMonth
    End If
    If Len(TempDay) = 2 Then
        DayString = TempDay
    Else
        DayString = "0" & TempDay
    End If
    string1 = "{ts '19" & YearString & "-" & _
              MonthString & "-" & _
              DayString & " 00:00:00'}"
    Mod2MakeDateStamp = string1
End Function

RefreshPivot is called by the Refresh button on DataEntrySheet. RefreshPivot first calls the RefreshTable method on Pivot2—this will update the pivottable cache that is shared between Pivot1 and Pivot2. Then, the macro copies the data from pivot2 to DataEntrySheet by using the CopyPivot macro.

Sub Mod2RefreshPivot()
    Mod2SetObjects
    Dim UpdateIt As Integer
    Application.ScreenUpdating = False
    If DataHasNotBeenUpdated Then
        UpdateIt = MsgBox("Update changes now?", vbYesNoCancel, _
            "Update Changes?")
        If UpdateIt = vbYes Then
            Mod2UpdateDatabase
        ElseIf UpdateIt = vbCancel Then
            Exit Sub
        End If
    End If
    Pivot2.RefreshTable
    Mod2CopyPivot
    With Button1
        .Enabled = False
        .Font.ColorIndex = 16
    End With
    EntryHasBeenMade = False
    DataHasNotBeenUpdated = False
End Sub

Doing Multi-field Sorts with PivotTables

There are problems in sorting on multiple fields in the row area of a pivottable (note that no such problems exist in sorting records based on columns in the data area—this only pertains to sorting on pivotfields in the row area). Macro SecondTierSort below shows the best method to use to ensure a proper multi-tier sort. You should always sort row data in a pivottable according to the leftmost field in the row area (position 1). So to do multi-tier sorts, you must effectively, move pivotfields to position 1, perform a sort, then move the pivotfields back.

To demonstrate this, we are going to create a new pivottable. But first, we will delete the pivottable that currently exists on PivotSheet:

Sub Mod3DeletePivotTables()
    Dim x As Variant
    For Each x In Worksheets("PivotSheet").PivotTables
        x.TableRange2.Clear
    Next
End Sub

CreatePivot11 builds a new pivottable that contains two pivotfields in the row area:

Sub Mod3CreatePivot11()
    Dim Pivot1 As PivotTable
    Dim SQLString As String
    Dim PivotSheet As Worksheet
    Set PivotSheet = Worksheets("PivotSheet")
    Mod1DeletePivotTables
    SQLString = "Select * From Revenue"
    Set Pivot1 = PivotSheet.PivotTableWizard _
        (SourceType:=xlExternal, _
        SourceData:=Array("DSN=SaleData", SQLString), _
        TableDestination:=PivotSheet.Range("B5"), _
        TableName:="Pivot1", _
        SaveData:=False)
    With Pivot1
        .PivotFields("Period").Orientation = xlColumnField
        .PivotFields("Region").Orientation = xlRowField
        .PivotFields("Category").Orientation = xlRowField
        With .PivotFields("Revenue")
            .Orientation = xlDataField
            .NumberFormat = "$#,##0_);($#,##0)"
            .Name = "Rev"
        End With
        .TableRange2.AutoFormat format:=xlClassic3
    End With
End Sub

SecondTierSort sorts both pivotfields in the row area of the pivottable in a descending order. Notice how this is done however. In performing the sort, each pivotfield is moved to the first position, sorted and then moved back. In order to ensure a proper multi-tier sort of a pivottable, you must perform the sort in this manner—that is pivotfields must be sorted in the row area in position 1.

Sub Mod3SecondTierSort()
    Dim Pivot1 As PivotTable
    Dim SortRange As Range
    Set Pivot1 = Worksheets("PivotSheet").PivotTables("Pivot1")
    Worksheets("PivotSheet").Select
    Pivot1.PivotFields("Category").Position = 1
    Set SortRange = _
        Pivot1.PivotFields("Category").LabelRange.Offset(1, 0)        SortRange.Select
    SortRange.Sort order1:=xlDescending
    Pivot1.PivotFields("Region").Position = 1
    Set SortRange = _
        Pivot1.PivotFields("Region").LabelRange.Offset(1, 0)        SortRange.Select
    SortRange.Sort order1:=xlDescending
    Pivot1.PivotFields("Region").Position = 1
End Sub

Various other Concepts

Changing the Name of a Toolbar

The Name property of the Toolbar object in Excel is read-only—that is you cannot programmatically set the Name property. There is a way around this however. Macro ChangeToolbarName and function ChangeTBName below allow you to select a toolbar from a list of toolbars in a dropdown in a dialog box and then change that toolbars name. This is done by effectively building a second toolbar that matches the first in terms of buttons, shape and position, then deleting the original toolbar and assigning a new name to the second toolbar. Note that this routine only works for custom toolbars—you cannot change the name of a built-in toolbar.

Sub Mod4ChangeToolbarName()
    Dim Drop1 As DropDown
    Dim Edit1 As EditBox
    Dim Diag1 As DialogSheet
    Dim x As Variant
    Set Diag1 = DialogSheets("Dialog1")
    Set Drop1 = Diag1.DropDowns("Drop1")
    Set Edit1 = Diag1.EditBoxes("Edit1")
    Drop1.RemoveAllItems
    Edit1.Text = ""
    Edit1.SendToBack
    For Each x In Application.Toolbars
        Drop1.AddItem Text:=x.Name
        Drop1.Value = Drop1.ListCount
    Next
    Diag1.Show
    Mod4ChangeTBName Drop1.List(Drop1.Value), Edit1.Text
End Sub

Function Mod4ChangeTBName(ByVal Toolbar1 As String, ByVal NewName As String)
    Dim x As Variant
    Dim y As Variant
    Application.ScreenUpdating = False
    Application.Toolbars.Add NewName
    Application.Toolbars(NewName).Visible = True
    With Application
        For x = .Toolbars(Toolbar1).ToolbarButtons.Count _
                        To 1 Step -1
            .Toolbars(Toolbar1).ToolbarButtons(x).Copy _
                Toolbar:=.Toolbars(NewName), _
                before:=1
        Next
        y = .Toolbars(NewName).ToolbarButtons.Count
        For x = .Toolbars(Toolbar1).ToolbarButtons.Count _
                        To 1 Step -1
            If .Toolbars(Toolbar1).ToolbarButtons(x).IsGap Then
                .Toolbars(NewName).ToolbarButtons.Add _
                    before:=y + 1
                y = y + 1
            End If
            y = y - 1
        Next
        .Toolbars(NewName).Position = _
            .Toolbars(Toolbar1).Position
        .Toolbars(NewName).Left = .Toolbars(Toolbar1).Left
        .Toolbars(NewName).Top = .Toolbars(Toolbar1).Top
        If .Toolbars(Toolbar1).Position = xlFloating Then
            .Toolbars(NewName).Width = .Toolbars(Toolbar1).Width
        End If
        .Toolbars(Toolbar1).Delete
    End With
End Function

Selecting the Real UsedRange

The UsedRange property of the worksheet object is very powerful in that it returns a rectangular range that encompasses all entered data on the worksheet. However, in Excel 5, under certain circumstances, UsedRange will return a larger range than is desired. Excel keeps track of where data exists on a worksheet. Unfortunately, if a user deletes outlying cells on a worksheet, Excel will continue to believe that such cells contain values—until the file is saved and Excel's record of the UsedRange is updated. The macros below demonstrate this problem and how to work around it. Macro SelectUsedRange1 enters data into 4 cells on worksheet ExtraSheet and then selects the sheet and then selects Used Range.

Sub Mod4SelectUsedRange1()
    With Worksheets("ExtraSheet")
        .Range("B2,C3,D4,E5").Value = 1
        .Select
        .UsedRange.Select
    End With
End Sub

The resulting range selection appears as:

Macro SelectUsedRange2 deletes values from the two outlying cells—B2 and E5—and then selects UsedRange again:

Sub Mod4SelectUsedRange2()
    With Worksheets("ExtraSheet")
        .Range("B2,E5").Clear
        .Select
        .UsedRange.Select
    End With
End Sub

As can be seen from the diagram below, SelectUsedRange2 selects the wrong UsedRange—B2 and E5 make up the top left and bottom right corners of the range, even though they do not contain any data:

Macro SelectRealUsedRange below, combined with functions ReturnFirstCell and ReturnLastCell provide a solution to this problem. Function ReturnFirstCell obtains the top left cell in the worksheet that contains data by using UsedRange and then a series of For loops. Function ReturnLastCell uses the SpecialCells method to obtain the bottom right cell that contains data on the worksheet.

Sub Mod4SelectRealUsedRange()
    With Worksheets("ExtraSheet")
        .Select
        .Range(Mod4ReturnFirstCell(), _
            Mod4ReturnLastCell()).Select    
    End With
End Sub
Function Mod4ReturnFirstCell() As String
    Dim Range1 As Range
    Dim FirstCol As Integer
    Dim FirstRow As Integer
    Dim LastCol As Integer
    Dim LastRow As Integer
    Dim x As Integer
    Dim y As Integer
    Dim r1 As Integer
    Dim r2 As Integer
    Dim r3 As Integer
    Dim c1 As Integer
    Dim c2 As Integer
    Dim c3 As Integer
    r1 = 0
    r2 = 0
    c1 = 0
    c2 = 0
    Dim FirstRowAddress As String
    Set Range1 = ActiveSheet.UsedRange
    FirstCol = Range1.Column
    FirstRow = Range1.Row
    LastCol = FirstCol + Range1.Columns.Count - 1
    LastRow = FirstRow + Range1.Rows.Count - 1
    For x = FirstRow To LastRow
        For y = FirstCol To LastCol
            If ActiveSheet.Cells(x, y).Value <> "" Then
                r1 = x
                c1 = y
                Exit For
            End If
        Next
        If r1 <> 0 Then
            Exit For
        End If
    Next
    For y = FirstCol To LastCol
        For x = FirstRow To LastRow
            If ActiveSheet.Cells(x, y).Value <> "" Then
                r2 = x
                c2 = y
                Exit For
            End If
        Next
        If r2 <> 0 Then
            Exit For
        End If
    Next
    If r1 < r2 Then
        r3 = r1
    Else
        r3 = r2
    End If
    If c1 < c2 Then
        c3 = c1
    Else
        c3 = c2
    End If
    Mod4ReturnFirstCell = ActiveSheet.Cells(r3, c3).Address
End Function
Function Mod4ReturnLastCell() As String
    Dim CurrentAddress As String
    Dim LastCellAddress As String
    CurrentAddress = ActiveCell.Address
    If Range("A1").SpecialCells(xlLastCell).Value = "" Then
        Cells(Cells.Find("*", _
            ActiveCell.SpecialCells(xlLastCell), _
            , , xlByRows, xlPrevious).Row, _
            Cells.Find("*", ActiveCell. _
            SpecialCells(xlLastCell), , , _
            xlByColumns, xlPrevious). _
            Column).Select
    Else
        Range("A1").SpecialCells(xlLastCell).Select
    End If
    LastCellAddress = Selection.Address
    Range(CurrentAddress).Select
    Mod4ReturnLastCell = LastCellAddress
End Function

As can be seen in the resulting diagram below, SelectRealUsedRange does indeed select the real used range on the worksheet:

A Brief Summary of Names

Names are actually a rather complicated concept in Excel. I have provided several macros below to help explain some of the things that you can do with names.

First, to assign a name to a Range object, you assign a string to the Name property of the desired Range object. SetRange1 assigns the name "Range1" to Range("A1:B10") on the worksheet Sheet1—the macro then selects the sheet and uses the name to select the range:

Sub Mod5SetRange1()
    Worksheets("Sheet1").Range("A1:B10").Name = "Range1"
    Worksheets(1).Select
    Range("Range1").Select
End Sub

Note that a single Range object can have multiple names associated with it. SetRange2 below assigns the name "Range2" to the exact same range as used in the macro above. You can use the name Range2 or the name Range1 in referring to Range("A1:B10") on the worksheet Sheet1:

Sub Mod5SetRange2()
    Worksheets("Sheet1").Range("A1:B10").Name = "Range2"
    Worksheets("Sheet1").Select
    Range("Range1").Select
End Sub

To delete a name, you call the delete method on the Name object—you can reference a specific Name object by using the Names collection of the Workbook object. To index the Names collection, merely specify the name of the desired object using a string. DeleteRange1 below provides an example of deleting a name:

Sub Mod5DeleteRange1()
    ActiveWorkbook.Names("Range1").Delete
    Worksheets("Sheet1").Select
    Range("Range1").Select
End Sub

It is possible to assign a name to an offset of a range—this is accomplished by using the Offset method. The Offset method takes two arguments, a row offset and a column offset. The SetRange3 macro below assigns the name "Range3" to the cell that is 5 rows down and 5 columns to the right of Range ("A1"):

Sub Mod5SetRange3()
    Worksheets("Sheet1").Range("A1").Offset(5, 5).Name = "Range3"
    Worksheets("Sheet1").Select
    Range("Range3").Select
End Sub

In working with names, it is important that you understand the distinction between worksheet-level names and workbook-level names. Macro SetRange4 below attempts to assign the name "Range4" to two different ranges—Range("A1") on Sheet1 and Range("A1") on Sheet2. What you find happens is that once the assignment of the Range4 name on Sheet2 is made, the Range4 name on Sheet1 is deleted automatically—in this case, the name "Range4" can apply to only one range within the workbook. In this case, Range4 is a workbook-level name, in that only one instance of the "Range4" name can exist in the workbook. A call to MsgBox is made to display the address of the Range4 name by using the RefersTo property:

Sub Mod5SetRange4()
    Worksheets("Sheet1").Range("A1").Name = "Range4"
    Worksheets("Sheet2").Range("A1").Name = "Range4"
    MsgBox ActiveWorkbook.Names("Range4").RefersTo
End Sub

Using worksheet-level names, it is possible to have two identical names on two different worksheets. To accomplish this however, you must specify the worksheet name as a part of the name. Macro SetRange5 below assigns the name "Sheet1!Range5" to Range("A1") on Sheet1 and then assigns the name "Sheet2!Range5" to Range("A1") on Sheet2. The macro then uses a For-Each-Next loop to display all of the names in the workbook—you sill notice that both instances of the name Range5 exist in the workbook on separate sheets. The last two commands of the macro show how you can then use the name "Range5" in the context of the worksheet to select specific ranges:

Sub Mod5SetRange5()
    Worksheets("Sheet1").Range("A1").Name = "Sheet1!Range5"
    Worksheets("Sheet2").Range("A1").Name = "Sheet2!Range5"
    For Each x In ActiveWorkbook.Names
        MsgBox x.Name
    Next
    Worksheets("Sheet1").Select
    Range("Range5").Select
End Sub

Care should be taken not to use too many names in a workbook. You will find that using an excessive number of names (over 100) may have a negative impact on performance. This occurs because Excel registers all names in a workbook when the workbook is loaded into memory.

© 1995 Microsoft Corporation.

THESE MATERIALS ARE PROVIDED "AS-IS," FOR INFORMATIONAL PURPOSES ONLY.

NEITHER MICROSOFT NOR ITS SUPPLIERS MAKES ANY WARRANTY, EXPRESS OR IMPLIED WITH RESPECT TO THE CONTENT OF THESE MATERIALS OR THE ACCURACY OF ANY INFORMATION CONTAINED HEREIN, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. BECAUSE SOME STATES/JURISDICTIONS DO NOT ALLOW EXCLUSIONS OF IMPLIED WARRANTIES, THE ABOVE LIMITATION MAY NOT APPLY TO YOU.

NEITHER MICROSOFT NOR ITS SUPPLIERS SHALL HAVE ANY LIABILITY FOR ANY DAMAGES WHATSOEVER INCLUDING CONSEQUENTIAL INCIDENTAL, DIRECT, INDIRECT, SPECIAL, AND LOSS PROFITS. BECAUSE SOME STATES/JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF CONSEQUENTIAL OR INCIDENTAL DAMAGES THE ABOVE LIMITATION MAY NOT APPLY TO YOU. IN ANY EVENT, MICROSOFT'S AND ITS SUPPLIERS' ENTIRE LIABILITY IN ANY MANNER ARISING OUT OF THESE MATERIALS, WHETHER BY TORT, CONTRACT, OR OTHERWISE SHALL NOT EXCEED THE SUGGESTED RETAIL PRICE OF THESE MATERIALS.