The administrator of the Library system will want to see statistics and information at a glance, or run various reports that provide meaningful summary or detail views of system data. As a programmer, I could try to add every conceivable type of report that the user may need, but I have learned from experience that this is not possible. Users always want the moon, usually in the form of some weird esoteric report that I know they will use once and never look at again (although they will call once a year asking for the same report to be written again). I don't like recompiling and re-releasing the entire application every time a user needs a new report. Instead, I keep the reports outside the application, stored as separate programs. Then, from one form in the main application, I make all of those external reports available in a nice convenient list.
To implement this generic feature, I use a report configuration file, a simple XML file that contains information on the available reports, and how to run them. I want my selection list to have indented items, so that I can visibly group reports for convenience. To do this, I will make my XML file into an unlimited depth hierarchy, with each level representing a further level of displayed indent. For instance, let's say I wanted the following outline of reports (with report group titles in bold).
The XML configuration would follow this structure.
<Group name="Detail Reports"> <Item name="Daily Report"/> <Group name="Monthly Reports"> <Item name="Monthly Value"/> <Item name="Monthly Inventory"/> </Group> </Group> <Group name="SummaryReports"> <Item name="Inventory Summary"/> </Group>
Of course, this is greatly simplified (not to mention non-compliant) XML. In addition to the hierarchy, I also want to include support for a variety of reporting methods. To keep things simple, the Library project will include three methods of initiating reports.
The project activities in this chapter involve both coding and documentation of the new external resource (the XML file format).
Load the "Chapter 13 (Before) Code" project, either through the New Project templates, or by accessing the project directly from the installation directory. To see the code in its final form, load "Chapter 13 (After) Code" instead.
Update Technical Documentation
First, let's add clear documentation on the structure of the XML configuration file. There is no easy way to communicate the structure of an XML file to an ordinary user. Although such documentation is a requirement, hopefully the application will also include a tool to let an administrator build the configuration file. Such a program, sadly, is not included in this book's project. It is left as an exercise for the reader. (I always wanted to say that.)
This technical description appears in the Technical Resource Kit document, originally developed in Chapter 4, "Designing the Database."
Create Report Entry Class
With .NET's ability to store whole objects as ListBox items, we can create a custom class that contains all the information needed to select and run a report from the list of reports. This class is fairly simple, with nothing but basic public fields, plus an overridden ToString function, used by the ListBox control to properly display each list item.
In the Library Project, add a new class file named ReportItem.vb through the Project Add Class menu command. Add the following enumeration to the file, but add it outside the Class . . . End Class boundaries. This enumeration indicates what type of entry each list item represents.
Insert Chapter 13, Snippet Item 1.
Public Enum ReportItemEnum ' ----- The type of item in the report select list. GroupLabel = 0 BuiltInCheckedOut = 1 BuiltInOverdue = 2 BuiltInMissing = 3 BuiltInFinesOwed = 4 BuiltInStatistics = 5 ExeProgram = 6 UrlProgram = 7 End Enum
To this same file, add the members of the ReportItem class. This class contains all the information we need to run reports loaded from the configuration file.
Insert Chapter 13, Snippet Item 2.
' ----- Instance of report selection items used ' in the ReportSelect form. Public ItemType As ReportItemEnum Public Indent As Integer ' Indent level. Starts with 0. Public DisplayText As String Public ReportPath As String ' ExeProgram / UrlProgram only Public ReportArgs As String ' ExeProgram only Public Description As String Public Overrides Function ToString() As String ' ----- Display an indented string. Prepend with spaces. Return StrDup(Indent * 5, " ") & DisplayText End Function
Design the Report Form
Librarians and administrators use the Select Report form (see Figure 13-2) to view reports. The form includes a ListBox control that displays all reports and report groups, a Run button that starts a report, and a Close button that returns the user to the main form. A label displays the full description of a report, when available, just below the ListBox.
Figure 13-2. The Select Report form
Add a new form file named ReportSelect.vb through the Project Add Windows Form menu command. Add the controls and settings as listed here.
Adjust the tab order of the new controls by selecting the form, and then using the View Tab Order menu command.
Although the administrator has probably given useful names to each report, the terseness of each report name may still confuse the user. Each report includes an optional full description. As the user selects reports from the list, an event handler updates the FullDescription label just below the main list. Add this event handler member to the class.
Insert Chapter 13, Snippet Item 3.
Private Sub AllReports_SelectedIndexChanged( _ ByVal sender As Object, ByVal e As System.EventArgs) _ Handles AllReports.SelectedIndexChanged ' ----- Display a description of the report, if available. Dim reportEntry As Library.ReportItem ' ----- Clear any previous description. FullDescription.Text = "No report selected." If (AllReports.SelectedIndex <> -1) Then ' ----- Locate the content and display it. reportEntry = CType(AllReports.SelectedItem, _ Library.ReportItem) FullDescription.Text = reportEntry.Description End If End Sub
Populate Reports from Configuration File
The RefreshReportList method loads the data from the report configuration file, and processes the results. Eventually, the location of this file will be recorded in the application's configuration file, but we won't be adding that until a later chapter. For now, let's put in a hard-coded test file location, and mark it for later update.
Insert Chapter 13, Snippet Item 4.
Private Sub RefreshReportList() ' ----- Load in the list of available reports. Dim configFile As String Dim configData As Xml.XmlDocument Dim reportEntry As ReportItem Dim counter As Integer On Error GoTo ErrorHandler ' ----- Clear the existing list. AllReports.Items.Clear() ' ----- Get the location of the configuration file. ' TODO: Load this from the application's configuration. ' For now, just hard-code the value. configFile = "c:\ReportConfig.txt" ' ----- Load the configuration file. If (configFile <> "") Then If (System.IO.File.Exists(configFile)) Then ' ----- Load in the file. configData = New Xml.XmlDocument configData.Load(configFile) ' ----- Process the configuration file. LoadReportGroup(configData.DocumentElement, 0) End If End If ' ----- If the configuration file resulted in no reports ' appearing in the list, add the default reports. If (AllReports.Items.Count = 0) Then For counter = 1 To _ CInt(ReportItemEnum.BuiltInStatistics) ' ----- Build the report entry. reportEntry = New ReportItem reportEntry.Indent = 0 reportEntry.ItemType = CType(counter, ReportItemEnum) Select Case reportEntry.ItemType Case ReportItemEnum.BuiltInCheckedOut reportEntry.DisplayText = "Items Checked Out" Case ReportItemEnum.BuiltInOverdue reportEntry.DisplayText = "Items Overdue" Case ReportItemEnum.BuiltInMissing reportEntry.DisplayText = "Items Missing" Case ReportItemEnum.BuiltInFinesOwed reportEntry.DisplayText = "Patron Fines Owed" Case ReportItemEnum.BuiltInStatistics reportEntry.DisplayText = "Database Statistics" End Select ' ----- Add the report entry to the list. AllReports.Items.Add(reportEntry) Next counter End If Return ErrorHandler: GeneralError("ReportSelect.RefreshReportList", _ Err.GetException()) Resume Next End Sub
Because the report configuration file allows nested report groups to any level, we need to use a recursive routine to repeatedly descend to each successive level. The LoadReportGroup routine, called by RefreshReportList, adds all report items and report groups within a starting report group. It's initially called from the reference point of the root <reportList> element. Each time that it finds a child <reportGroup> element, it calls itself again, but this time starting from the reference point of the child <reportGroup> element.
Insert Chapter 13, Snippet Item 5.
Private Sub LoadReportGroup(ByVal groupNode As Xml.XmlNode, _ ByVal indentLevel As Integer) ' ----- Add the groups and items at this level, ' and recurse as needed. Dim scanNode As Xml.XmlNode Dim detailNode As Xml.XmlNode Dim reportEntry As ReportItem ' ----- Process each item or group. For Each scanNode In groupNode.ChildNodes ' ----- Build a content item for the list. reportEntry = New ReportItem reportEntry.Indent = indentLevel ' ----- Get the display name. detailNode = scanNode.SelectSingleNode("displayText") If (detailNode Is Nothing) Then Continue For reportEntry.DisplayText = Trim(detailNode.InnerText) If (scanNode.Name = "reportGroup") Then ' ----- Start a new display group. reportEntry.ItemType = ReportItemEnum.GroupLabel AllReports.Items.Add(reportEntry) ' ----- Recurse to child items. LoadReportGroup(scanNode, indentLevel + 1) ElseIf (scanNode.Name = "reportItem") Then ' ----- This is an item. Record its location. detailNode = scanNode.SelectSingleNode("reportPath") If Not (detailNode Is Nothing) Then _ reportEntry.ReportPath = _ Trim(detailNode.InnerText) ' ----- Get any command-line arguments. detailNode = scanNode.SelectSingleNode("reportArgs") If Not (detailNode Is Nothing) Then _ reportEntry.ReportArgs = _ Trim(detailNode.InnerText) ' ----- Get any item-specific flags. detailNode = scanNode.SelectSingleNode("reportFlags") If Not (detailNode Is Nothing) Then ' ---- "U" adds "-u loginid" to the command. If (InStr(UCase(detailNode.InnerText), "U") > 0) _ And (LoggedInUserName <> "") Then _ reportEntry.ReportArgs = _ Trim(reportEntry.ReportArgs & " -u " & _ LoggedInUserName) End If ' ----- Store the full description. detailNode = scanNode.SelectSingleNode("description") If Not (detailNode Is Nothing) Then _ reportEntry.Description = _ Trim(detailNode.InnerText) ' ----- So, what type of entry is it? If (scanNode.Attributes("type").Value = _ "built-in") Then ' ----- Built-in program. Check for valid ID. If (IsNumeric(reportEntry.ReportPath) = False) Or _ (Val(reportEntry.ReportPath) < 1) Or _ (Val(reportEntry.ReportPath) > _ CInt(ReportItemEnum.BuiltInStatistics)) Then _ Continue For reportEntry.ItemType = CType(CInt( _ reportEntry.ReportPath), ReportItemEnum) AllReports.Items.Add(reportEntry) ElseIf (scanNode.Attributes("type").Value = _ "program") Then ' ----- EXE program-based report. If (reportEntry.ReportPath = "") Then Continue For reportEntry.ItemType = ReportItemEnum.ExeProgram AllReports.Items.Add(reportEntry) ElseIf (scanNode.Attributes("type").Value = _ "url") Then ' ----- URL-based report. If (reportEntry.ReportPath = "") Then Continue For reportEntry.ItemType = ReportItemEnum.UrlProgram AllReports.Items.Add(reportEntry) End If End If Next scanNode Return ErrorHandler: GeneralError("ReportSelect.LoadReportGroup", _ Err.GetException()) Resume Next End Sub
Add the form's Load event, which loads in the content from the configuration file.
Insert Chapter 13, Snippet Item 6.
Private Sub ReportSelect_Load(ByVal sender As Object, _ ByVal e As System.EventArgs) Handles MyBase.Load ' ----- Display the list of reports. RefreshReportList() End Sub
Running the Reports
Now that all of the groups and items appear in the list, we have to run the actual reports. The ActRun button's Click event handles this duty. For now, we will just add the framework to support the calling of each report. The built-in reports will be added in Chapter 20, "Reporting."
Insert Chapter 13, Snippet Item 7.
Private Sub ActRun_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles ActRun.Click ' ----- Run the selected report. Dim reportEntry As Library.ReportItem On Error GoTo ErrorHandler ' ----- Make sure a report is selected. If (AllReports.SelectedIndex = -1) Then MsgBox("Please select a report from the list.", _ MsgBoxStyle.OKOnly Or MsgBoxStyle.Exclamation, _ ProgramTitle) Return End If ' ----- Different code for each type of entry. reportEntry = CType(AllReports.SelectedItem, _ Library.ReportItem) Me.Cursor = Windows.Forms.Cursors.WaitCursor Select Case reportEntry.ItemType Case ReportItemEnum.GroupLabel ' ----- No report for group entries. MsgBox("Please select a report from the list.", _ MsgBoxStyle.OKOnly Or MsgBoxStyle.Exclamation, _ ProgramTitle) Case ReportItemEnum.BuiltInCheckedOut ' ----- Items Checked Out ' TODO: Write BasicReportCheckedOut() Case ReportItemEnum.BuiltInOverdue ' ----- Items Overdue ' TODO: Write BasicReportOverdue() Case ReportItemEnum.BuiltInMissing ' ----- Items Missing ' TODO: Write BasicReportMissing() Case ReportItemEnum.BuiltInFinesOwed ' ----- Fines Owed by Patrons ' TODO: Write BasicReportFines() Case ReportItemEnum.BuiltInStatistics ' ----- Library Database Statistics ' TODO: Write BasicReportStatistics() Case ReportItemEnum.ExeProgram ' ----- Start a program. Process.Start("""" & reportEntry.ReportPath & _ """ " & reportEntry.ReportArgs) Case ReportItemEnum.UrlProgram ' ----- Start a URL. Process.Start(reportEntry.ReportPath) End Select Me.Cursor = Windows.Forms.Cursors.Default Return ErrorHandler: GeneralError("ReportSelect.ActRun_Click", _ Err.GetException()) Resume Next End Sub
For external reports, the event handler calls the Process.Start method. This amazing method accepts either a standard command-line expression, or any valid URL or web page address.
Connecting the Select Report Form
To make the reports available to the user, we must enable a link to the report form from the main form. We included a distinct panel on that form just for printing reports. The ActDoReports button on that panel triggers a call to the new report selection form. Create a new event handler for the ActDoReports button and add the following code.
Insert Chapter 13, Snippet Item 8.
' ----- Show the reports form. ReportSelect.ShowDialog()
Now that we have a firm grasp on the world of XML, we'll let Visual Basic do all the hard work of manipulating it for application configuration purposes.