Developing data-aware components
Part III - A data-aware Treeview front-end component.

This article describes a data-aware component that provides a treeview front-end for a dataset. It behaves like a standard treeview component available in the Delphi component palette but its structure and other data is located in a dataset.  In addition, it provides a tutorial on how to develop a datalink and a property editor.

During the development of a database-driven personal information manager, I had to develop a data-aware treeview control to be used as an interface to one of the dataset of the database. The intent was to allow the user to exhibit the content of the dataset in a hierarchical interface that could be modified at will. It provides a hierarchical interface and stores its structure and other data in a dataset and requires the development of genuine datalink and property editor.

The component

Figure 1 - Snapshot of the
GtroDBTreeView component

From the surface, the new component behaves exactly as the TTreeview component of the VCL with the added feature that it stores its structure as well as other fields in a dataset. It is  derived from TCustomTreeView, the parent of the TTreeView component of the Delphi's visual component library and as such, is a window  that displays a contracting/expanding hierarchical list of nodes, such as the headings in a document, the entries in an index, or the files and directories on a disk. Each node in the control consists of a label and a number of optional bitmap images and each node can have a list of subnodes associated with it. Figure 1 shows a snapshot of the component.

Nodes can be created, deleted or moved (through drag-and-drop) during which the dataset is automatically kept in synchronization with the display. Selecting a node allows the display of the other fields of the dataset. Figure I shows a snapshot of the component.

The dataset

The dataset underlying the component must be specifically designed to contain the structure of the Treeview component. It is comprised of four essential fields:

The other fields of the dataset are defined by the user and they may be displayed when a node of the treeview is selected. 

How to use it

The source code of this component can be downloaded here. It contains three data-aware components contained in a package called gtrodblib6.dpk. Once the gtrodblib6 package is compiled and installed on Delphi 6, the component  becomes available in the GTRO pane of the component palette. From there, is can be dropped on a form or on a frame. It is then displayed and you give it the label that you want. The object inspector displays the published properties and event handlers. The most important are:

Once these have been initialized, the component is ready for use on a form or on a frame. There is no need to generate OnEnter and OnExit event handlers to connect and disconnect the external navigator as these actions are performed under the hood of the component when the component is entered or exited.

How it works

The component appears as a standard TreeView component on a form. Its nodes can be created, deleted and moved as in the case for the TreeView component. Its singularity lies in the fact that it stores its data in a dataset. Creating, deleting and moving nodes update this dataset as they occur.

Loading nodes in the component

Figure 2 - View of the TItem objects in the TListOfItems object

When using an ordinary TreeView component, loading the component  is performed using the LoadFromFile() method that loads the component from a text file formatted specifically for this purpose. Our component is loaded in a similar fashion but its data is located in a dataset rather than in a text file. In addition, the code used to load the component is a bit more tricky. In fact, the LoadFromTable() method uses two special classes TItems and TListOfItems to perform its work (see Figure 2 and this annex). It is a three step process:

  1. The records of the dataset are read sequentially (only once) and the content of their KeyField, ParentField, Title and IsFolder  fields are transfered in a TItem objects created for each record. Each such TItem object is then inserted in a list of items.
  2. This list is then read sequeltially, TItem by TItem, in order to find its parent TItem. If it is found, a pointer to that parent object is inserted in the ParentField variable of the TItem. If it is not found, the ParentItem pointer is set to nil meaning that the corresponding node has the root node as parent;
  3. The nodes of the treeview are created from the TItems objects of the TListOfItems.

as shown in the code that follows:

procedure TGtroDBTreeView.LoadFromTable
var
  ...
begin
  FListOfItems:= TListOfItems.Create; // creates the list containing the TItems
  OnChange:= nil;  // no reaction to the OnChange event
  Items.BeginUpdate;
  try
    Items.Clear;
    AddRootNode;
    FDatalink.DataSource.DataSet.Open; // opens the table
    FDatalink.DataSource.DataSet.First; // puts the pointer on the first record
    while not FDatalink.DataSource.DataSet.Eof do
    begin
      R:= FDatalink.Fields[0].AsInteger; // Key field
      P:= FDatalink.Fields[1].AsInteger; // Parent field
      F:= FDatalink.Fields[2].AsBoolean; // IsFolder field
      T:= FDatalink.Fields[3].AsString;  // Title field
      Item:= TItem.Create(F, R, P, T); // creates an initialized TItem
      FListOfItems.Add(Item); // Adds the TItem object in the list
      FDatalink.DataSource.DataSet.Next; // Next record
    end; // ...for
    for i:= 0 to FListOfItems.Count - 1 do
    begin // Updates parent pointers
      P:= TItem(FListOfItems.Items[i]).ParentID;
      if P <> 0 then
      begin
        Ptr:= FListOfItems.FindItem(P);
        TItem(FListOfItems.Items[i]).ParentItem:= Ptr;
      end; // ...if
    end; // ...for
    // Builds the hierarchical view
    for i:= 0 to FListOfItems.Count - 1 do
      AddANode(TItem(FListOfItems.Items[i]));
    CustomSort(@MyCustomSortProc, 0); // sorts the nodes
    FullCollapse;
    Items.Item[0].Expand(False);
  finally
    OnChange:= TreeViewChange; // reassign the OnChange event handler
    FListOfItems.Free; // frees the list of TItems
    Items.EndUpdate;
  end; // try...finally
end;

Adding, deleting and moving nodes

In this component, adding and deleting nodes must be synchronized with updates of the associated dataset. These operations are performed inside the component and they are triggered by an internal navigator which receives commands from the outside of the component. Moving nodes is performed by a drag-and-drop operation and the updating of the dataset is performed by internal code.

Internal Navigator

The control of additions and deletions of nodes is performed by an internal TDBNavigator component controlled by code in the TGtroDBTreeView component. Such navigator is declared as a property of the component but is not created in the component. It belongs to the host application to add an external navigator component and this external navigator  must be connected to the internal navigator through the published Navigator property either at design or at execution time. This property is set as follows:

procedure TGtroDBTreeView.SetNavigator(Value: TDBNavigator);
  begin
  if Assigned(Value) then // new value <> nil => Initialization of FNavigator
  begin
    // Stores the event handler of the external navigator in order to call it later
        FOldOnClick:= Value.OnClick;
    FNavigator:= Value;
    FNavigator.VisibleButtons:= [nbInsert, nbDelete]; // two buttons
    FNavigator.DataSource:= DataSource; // sets the data source
    FNavigator.OnClick:= NavigatorClick; // sets the internal event handler
  end
  else // new value = nil => deletes all links with FNavigaror
  begin
    if Assigned(FNavigator) then  // unless FNavigator already at nil.
    begin
      FNavigator.OnClick:= nil;
      FNavigator.DataSource:= nil;
      FNavigator:= nil;
    end;
  end;
end;

Once connected, the external navigator becomes the controller of the internal navigator. It exhibits two buttons: one (+) to add new nodes, the other (-) to delete  an existing one The OnClick event handler that was previously assigned to the external navigator (if any) is now replaced by the internal OnClick event handler called NavigatorClick():

procedure TGtroDBTreeView.NavigatorClick(Sender: TObject;
  Button: TNavigateBtn)
  begin
  // execute the event handler associated with the external navigator
  if Assigned(FOldOnClick) then FOldOnClick(Self, Button);
  if Button = nbDelete then DeleteSelectedNode; // deletes the selected node
  if Button = nbInsert then AddNewNode(NodeType); // add a child node
end;

Adding nodes

As shown in the code above, clicking on the + button of the external navigator generates a new node through the AddNewNode() method. The argument NodeType  of AddNewNode() defines the type of the new node. It must be defined by the host application either as ntFolder (creation of a container node) or as ntFile (creation of a standard node) before the + button of the navigator is clicked.

procedure TGtroDBTreeView.AddNewNode(NodeType: eNodeType);
var
  RecordID, ParentID: Integer;
  NewNode: TTreeNode;
begin
  if NodeType = ntRoot then Exit; // do nothing if NodeType is ntRoot
  if Selected = nil then Selected:= Items.GetFirstNode;
  if not IsNodeAllowed(Selected) then Exit;
  RecordID:= NextRecordID;
  ParentID:= Integer(Selected.Data); // RefNo du noeud parent
  NewNode:= Items.AddChildObjectFirst(Selected, '     ', Pointer(RecordID));
  with NewNode do
    begin
     case NodeType of // folder or file node?
        ntFolder: // assign folder icon to node
        begin
          ImageIndex:= IMG_FOLDER_CLOSED;
          SelectedIndex:= IMG_FOLDER_OPEN;
        end;
        ntFile: // assign file icon to node
        begin
          ImageIndex:= IMG_FILE_CLOSED;
          SelectedIndex:= IMG_FILE_OPEN;
        end;
        end; // ...case
      MakeVisible;
    end; // ...with
    Selected:= NewNode;
    NewNode.Focused:= true;
    NewNode.EditText;
  // Inscription dans la table
  with FDataLink.DataSource.DataSet do
  begin
    Append;
    FDatalink.Fields[0].Value:= RecordID;
    FDatalink.Fields[1].Value:= ParentID;
    FDatalink.Fields[2].Value:= NodeType = ntFolder;
    FDatalink.Fields[3].Value:= NewNode.Text;
    Post;
    GetNextRecordID;
  end; // ...with
end;

Deleting nodes

Deleting an existing node is performed by clicking on the - on the external navigator. As shown in the code of the NavigatorClick() event handler, when doing that,  the user triggers the DeleteSelectedNode() method which finds the record associated with the selected node and deletes it.

procedure TGtroDBTreeView.DeleteSelectedNode; // public
begin
  NodeToRecord(Selected);  // find the record associated with the node 
  Selected.Delete;
end;

NodeToRecord() is a utility member function that returns true when the record associated with a node is found but at the same time, since it uses the Locate() method of the dataset, it makes such a record active. Its code follows: 

function TGtroDBTreeView.NodeToRecord(Node: TTreeNode): Boolean;
begin
  FDataLink.DataSet.DisableControls;
  try
    Result:= FDataLink.DataSource.DataSet.Locate(FDatalink.FFieldNames[0],
      Integer(Node.Data), []);
  finally
    FDataLink.DataSet.EnableControls;
  end; // try...finally
end;

Moving nodes

Moving nodes in the treeview component is performed by a drag-and-drop operation. In Delphi, such operation is a three-step process: starting the drag-and-drop, dragging over the nodes and dropping on a node as shown in the listings that follow:

procedure TGtroDBTreeView.TVDragOver(Sender, Source: TObject; X, Y: Integer;
State: TDragState; var Accept: Boolean);
const
  EDGE = 50;
var
  TargetNode, SourceNode: TTreeNode;
begin
  inherited;
  TargetNode:= GetNodeAt(X, Y);
  if (Source = Sender) and (TargetNode <> nil) then
  begin
    Accept:= IsNodeAllowed(TargetNode);
    if Accept then
    begin
      SourceNode:= Selected;
      while (TargetNode.Parent <> nil) and (TargetNode <> SourceNode) do
        TargetNode:= TargetNode.Parent;
      if TargetNode = SourceNode then Accept:= False;
      end; // ...if
  end // ...if
  else Accept:= False;
  // Scrolling
  if (Y < EDGE) and Assigned(TopItem.GetPrevVisible()) then
    TopItem:= TopItem.GetPrevVisible;
  if (Y > Height - EDGE)
  and Assigned(TopItem.GetNextVisible()) then
  TopItem:= TopItem.GetNextVisible;
end;

and

procedure TGtroDBTreeView.TVDragDrop(Sender, Source: TObject; X, Y: Integer);
var
  TargetNode, SourceNode: TTreeNode;
  RecordID: Integer;
  Found: Boolean;
begin
  inherited;
  TargetNode:= GetNodeAt(X, Y);
  if TargetNode <> nil then
  begin
    FDataLink.DataSource.DataSet.DisableControls;
    SourceNode:= Selected;
    try
      SourceNode.MoveTo(TargetNode, naAddChildFirst);
      TargetNode.Expand(False);
      Selected:= TargetNode;
      { Éditof the dataset }
      // Trouver record correspondant au noeud source et le rendre courant
      Found:= NodeToRecord(TargetNode);
      if Found then
        RecordID:= FDataLink.Fields[0].Value
      else RecordID:= 0;
      // Trouver record correspondant au noeud cible et le rendre courant
      NodeToRecord(SourceNode);
      FDataLink.DataSource.DataSet.Edit;
      FDataLink.Fields[1].Value:= RecordID;  // change le record Parent
      FDataLink.DataSource.DataSet.Post;
      CustomSort(@MyCustomSortProc, 0);
    finally
      Selected:= SourceNode;
      FDataLink.DataSource.DataSet.EnableControls;
    end; // try...finally
  end; // ...if
end;

Technicalities

Some necessary concepts have to be added here in order to complete this article. Data-aware components often require the development of special ways to access the data in the datasets  as well as special means to allow the Object Inspector to edit the properties correctly. It is the case here as the component uses four fields of the dataset  (need for a genuine Datalink0  some of which require new property editors.

Datalink

Generally, database programs connect to some data-aware control through a DataSource component, and then connect the DataSource component to a data set, usually a TTable or a TQuery. The connection between a data-aware control and the TDataSource is called a data link, and is represented pro grammatically by an object of class TDataLink declared and implemented in DB.pas. Though TDataLink is not technically an abstract class, it is seldom used directly. Either you use one of the Delphi-provided data link classes derived from it or you derive a new one yourself.

In cases where only one field is published, the TFieldDatalink class provided by Delphi is used. It publishes a DataSource and a FieldName property through which the user can connect the control to a dataset. In the case of this component, a DataSource and four field properties  must be published by the component, thus requiring genuine datalinks for each of them. These fields are the following: KeyFieldName, ParentFieldName, IsFolder and LabelFieldName

The TGtroDBTreeView class is derived from the TCustomTreeView class, the ancestor of the TTreeView component as shown below.

  TGtroDBTreeView = class(TCustomTreeView)
  private
    FDataLink: TTreeViewDataLink;
    ...
  published
    property DataSource: TDataSource read GetDataSource write SetDataSource;
    property KeyFieldName: string
      read GetKeyFieldName write SetKeyFieldName;
      property ParentFieldName: string
      read GetParentFieldName write SetParentFieldName;
      property IsFolderFieldName: string
      read GetIsFolderFieldName write SetIsFolderFieldName
      property LabelFieldName: string
      read GetLabelFieldName write SetLabelFieldName;
      ...
      property Navigator: TDBNavigator read FNavigator write SetNavigator;
      ... publication of the TCustomTreeView properties
  end;

This truncated listing of the declaration of the component shows that a TTreeViewDataLink is declared as a private member variable and that the component publicizes the DataSource and the four fields properties (through the Fields and FieldNames properties) mentionned above.This datalink is declared as follows:

  TTreeViewDataLink = class(TDataLink)
  private
    FTreeView: TCustomTreeView;
    FFieldNames: array [0..3] of string; // four fields
    FFields: array [0..3] of TField;
    FOnActiveChange: TNotifyEvent;
    function GetFields(i: Integer): TField;
    function GetFieldNames(i: Integer): string;
    procedure SetFields(i: Integer; Value: TField);
    procedure SetFieldNames(I: Integer; const Value: string);
    
    
    procedure UpdateField(i: Integer);
    protected
    procedure ActiveChanged; override;
    public
    constructor Create(ATreeView: TCustomTreeView);
    property FieldNames[i: Integer]: string
      read GetFieldNames write SetFieldNames;
      property TreeView: TCustomTreeView read FTreeView write FTreeView;
      property Fields[i: Integer]: TField read GetFields;
      property OnActiveChange: TNotifyEvent read FOnActiveChange write FOnActiveChange;
    end;

The read and write methods of the Fields and the FieldNames properties simply connect the fields to the dataset. In addition, the SetFieldNames() method calls the UpdateFields() method that call the field editors of these fields.

procedure TTreeViewDataLink.UpdateField(i: Integer);
begin
  if Active and (FFieldNames[i] <> '') then
  begin
    if Assigned(FTreeView) then
      SetFields(i, GetFieldProperty(DataSource.DataSet, FTreeView, FFieldNames[i]))
    else
      SetFields(i, DataSource.DataSet.FieldByName(FFieldNames[i]));
  end
  else SetFields(i, nil);
  end;

Property editors

The Object Inspector provides default editing for most types of properties but, as a designer, your ability to create custom property editors is one of the reasons that Delphi is so good. Basically, you have the ability to create a dialog box to edit one or more properties in any way you want. In Delphi 5 Pro, the basic property editors (TPropertyEditor, TStringProperty and the like) are defined in C:\Program Files\Borland\Delphi5\Source\Toolsapi\dsgnintf.pas whereas in Delphi 6, they are in C:\Program Files\Borland\Delphi6\Source\ToolsAPI\DesignEditors.pas.

Now that the DataSource and the FieldNames properties appear in the object inspector, the fields that they retrieve must obey certain rules. These rules follow:

As an example of property editor, I will show how 

  TGtroDBTreeViewKeyDataFieldEditor = class(TStringProperty)
  public
  function GetAttributes: TPropertyAttributes; override;
  procedure GetValues(Proc: TGetStrProc); override;
  end;

Whereas the first method provides the attributes of the property 

function TGtroDBTreeViewKeyDataFieldEditor.GetAttributes: TPropertyAttributes;
begin
  Result:= [paAutoUpdate, paMultiSelect, paValueList, paSortList];
end;

the second method restricts the selection of the fields of the dataset to certain types, here: ftSmallInt, ftInteger and ftAutoInc fields as shown by the highlighted portions of the code that follows.

procedure TGtroDBTreeViewKeyDataFieldEditor.GetValues(Proc: TGetStrProc);
var
  SList: TStringList;
  TView: TGtroDBTreeView;
  i: Integer;
  Field: TField;
begin
  SList:= TStringList.Create;
  try
    TView:= GetComponent(0) as TGtroDBTreeView; // fetch TGtroDBTreeView component
    if Assigned(TView.DataSource) and
      Assigned(TView.DataSource.DataSet) then
    begin // compile a list of fields only if component is connected correctly
      TView.DataSource.DataSet.GetFieldNames(SList);
      for i:= 0 to SList.Count - 1 do
      begin
        if TView.DataSource.DataSet.Active then
          Field:= TView.DataSource.DataSet.Fields[i]
    else
     Field:= TView.DataSource.DataSet.FieldDefs.Items[i].CreateField(nil);
     if ((Field.DataType = ftSmallint)
     or  (Field.DataType = ftInteger
     or  (Field.DataType = ftAutoInc))
     and (Field.Index = 0) then
            Proc(SList.Strings[i]);
    if not TView.DataSource.DataSet.Active then Field.Free;
    end;
    end;
    finally
    SList.Free;
  end;
end;

Registering the property editor

The registration of the property editor is performed in the Register procedure as follows:

procedure Register;
begin
  RegisterPropertyEditor(TypeInfo(string), TGtroDBDateTimePicker, 'DataField',
  TGtroDBDateTimePickerDataFieldEditor);
  RegisterComponentEditor(TGtroDBDateTimePicker, TGtroDBDateTimePickerComponentEditor);
  end;

The first procedure called in the Register procedure is the RegisterPropertyEditor() procedure. Its declaration is as follows:  

procedure RegisterPropertyEditor(
  PropertyType: PTypeInfo; ComponentClass: TClass;
  const PropertyName: string; EditorClass: TPropertyEditorClass);

and it associates the property editor class specified by the EditorClass parameter with the property type specified by the PropertyType parameter. The PropertyName parameter is set to restrict the property editor to properties with a specific name whereas setting it to an empty string will associates the property editor with any property of the specified type. The ComponentClass parameter is set to restrict the property editor to a component class and its descendants. Setting ComponentClass to nil associates the property editor with the property type for any component.

In the case above, it assigns the TGtroDBDateTimePickerDataFieldEditor to the DataField property of the TGtroDBDateTimePicker component.

In order to separate the run-time and design-time code, the property editors have been developed in a separate unit called DBReg.pas which is included in the gtrodblib6.dpk package.

Conclusion

The development of three data-aware components gave the occasion of initiating ourselves with the gist of data-aware controls: datalinks and property editors. Datalinks are objects that allow the publication of the DataSource property as well as surfacing the fileds of the database that need to be linked. Once this is done, property editors are developed to restrict the selection of fields in the object inspector to specific types.

The GtroDBTreeView component itself was described in the following sections. The actions of loading the nodes from the dataset, creating new nodes, deleting existing nodes and moving nodes around were described in details. Three components were developed but only the GtroDBTreeView component was described in the article (the other two are described in the separate articles entitled "A data-aware Pushbutton Calendar component" and "A data-aware DateTimePicker component"). The code of these components can be downloaded here. The components can be installed in the component palette by installing the gtrodblib7 package. They will install in the GTRO pane of the component palette.

A demo showing an application of the component has been added and can be downloaded here. The file contains an executable of the program (it can be executed as is), the code of the program, an Access database filled with "somewhat ridiculous geographical data" as well as the HowTo.html file containing the instruction for its use.

Annexes

TItem and TListOfItems objects

The TItem and TListOfItems discussed in this article are special objects used in loading the content of the dataset into the treeview component. They are declared as follows:

TItem = class
  private
    RecordID, ParentID: Integer;
    Text: string;
    FParentItem: TItem;
    Flag: Boolean;
    public
    constructor Create(F: Boolean; R, P: Integer; T: string);
    property ParentItem: TItem read FParentItem write FParentItem;
    end;
and
TListOfItems = class(TList)
    destructor Destroy; override;
    function FindItem(X: Integer): TItem;
  end;

When a TItem is created, the Flag (IsFolder), RecordID, ParentID and the Title member variables of the object are set to the values of the parameters of the Create() constructor whereas the FParentItem member variable is set to nil.

Warning!
This code was developed for the pleasure of it. Anyone who decides to use it does so at its own risk and agrees not to hold the author responsible for its failure.


Questions or comments?
E-Mail
Last modified: September 4th 2014 10:39:11. []