此文摘自:http://www.thedelphimagazine.com/samples/1355/article.htm
This article first appeared in The Delphi Magazine Issue 77 (January 2002)
End-User Form Design
by Guy Smith-Ferrier
Way back in the 1980s there was an approach to application development called data- driven programming. The idea was that to some extent the application could be configured from data. That is, a database could hold a database schema, screen layouts, report designs, etc. This was well suited to the era, because many of the languages back then were either interpreted (eg dBase) or p-code compilers with built-in runtime expression parsers (eg Clipper). One of the benefits of this approach was that you could leave the design of the application open-ended. You could design the application for the majority of users, but allow more sophisticated users to bolt new fields onto the database and redesign their forms, adding the new fields. If you write an application which would benefit from the end-user being able to modify the application after it has been written, then this article is for you.
Goals
In this article we will look at the solutions to some of the basic requirements of end-user form design. The solutions offered will not be right for all circumstances, but the basic techniques can easily be modified to suit your specific needs. The goals which we need to achieve are:
- Allow the user to move and resize components on a form.
- Allow the new form design to be saved and later restored.
- Allow the user to add new components and/or fields.
- Allow the user to specify validation for the new fields.
We will look at each goal in turn.
Moving And Resizing Components
The basic idea behind the solution to allow the user to move and resize components can be easily demonstrated. Create a new application and add a TPanel. Add an OnMouseDown event to the TPanel with the following code:
ReleaseCapture;
SendMessage(Panel1.Handle, WM_SYSCOMMAND, $F012, 0);
Run the program, click on the panel and drag it. The panel will move. What’s happening is that we are sending it a WM_SYSCOMMAND message where the first message parameter is $F012 (see Issue 34, The Delphi Clinic). This value means move the control and it is the simplest solution to allow the user to move controls.
The resizing part is also simple. If you change $F012 to $F001 and rerun the test, you will see that the left-hand side of the panel moves left and right when you click on the panel and move the mouse. The control is being resized. There is a value to resize each of the Left, TopLeft, Top, TopRight, Right, BottomRight, Bottom and BottomLeft parts of the control.
Our first problem is to decide which of these values to use. Delphi’s own solution to this problem in the IDE is to use grab handles. Delphi’s IDE has a concept of identifying one or more controls as being selected. This is a useful approach for the IDE, but is not necessarily useful for an end-user. The features which we offer to an end-user do not need to have the same complexity and flexibility as those offered to a Delphi developer. So, for various reasons, our solution does not use grab handles. Instead we will decide what the user’s intention is by determining the proximity of the mouse to the edge of the control. For example, if the user places the mouse in the control within, say, 8 pixels of the right-hand side of the control and clicks, then they are resizing the right-hand side.
Having made this decision, we need to implement our solution. What we really want is a nice component to drop onto a form which we can switch on and off to let the user either design the form or use it as normal. To this end we will create the TEndUserFormDesigner component shown in Listing 1.
? Listing 1
TEndUserFormDesigner = class(TComponent)
private
FActive: boolean;
FApplicationOnMessage: TMessageEvent;
procedure ApplicationOnMessage(var Msg: TMsg; var Handled: boolean);
procedure SetActive(const Value: boolean);
published
property Active: boolean read FActive write SetActive;
end;
The Active property determines whether the user is designing the form or if it is being used as normal. The ApplicationOnMessage procedure represents a rather cunning plan. In order for our first example to work we had to set the OnMouseDown event of the TPanel. Our design component would be very intrusive if it cycled through all of the components on our form changing their OnMouseDown event to one of our own (not to mention the grief of having to save the old OnMouseDown events and restore them afterwards). Instead we use the TApplication.OnMessage event. This event is fired whenever an application receives a message, such as a left mouse button click. So the SetActive procedure looks like Listing 2.
? Listing 2
procedure TEndUserFormDesigner.SetActive(const Value: boolean);
begin
FActive := Value;
if not (csDesigning in ComponentState) then begin
if FActive then begin
FApplicationOnMessage:=Application.OnMessage;
Application.OnMessage:=ApplicationOnMessage;
end else if Assigned(FApplicationOnMessage) then begin
Application.OnMessage:=FApplicationOnMessage;
FApplicationOnMessage:=nil;
end;
end;
end;
In the procedure we save the old TApplication.OnMessage handler and assign our own. When Active is set to False we restore the old OnMessage handler. Our own OnMessage handler is structured as in Listing 3 (we’ll add the body of the code in a moment).
? Listing 3
procedure TEndUserFormDesigner.ApplicationOnMessage(var Msg: TMsg;
var Handled: boolean);
var
WinControl: TControl;
ControlPoint: TPoint;
begin
if Msg.message = WM_LBUTTONDOWN then
else if Msg.message = WM_MOUSEMOVE then
;
end;
Our procedure determines if the message is a left mouse click (WM_LBUTTONDOWN) or whether the mouse is simply moving over a control (WM_MOUSEMOVE). We will start with the latter. As we are not using grab handles, and therefore do not have a concept of a selected control, we need to provide visual feedback to the user as to what clicking will do at any moment in time. We will provide this feedback by changing the mouse cursor. For example, when the mouse is in proximity to the right-hand side of the control, we will change the mouse cursor to a horizontal left/right arrow (that is, crSizeWE). When the mouse is over the control, but not in proximity to a side of the control, we will change it to a move cursor (that is, crSizeAll). The code to implement this in ApplicationOnMessage is shown in Listing 4.
? Listing 4
...
else if Msg.message = WM_MOUSEMOVE then begin
WinControl:=FindDragTarget(Msg.pt, True);
if Assigned(WinControl) and (WinControl.Owner = Owner)
and not (WinControl is TForm) then
if (WinControl is TWinControl) then begin
ControlPoint:=WinControl.ScreenToClient(Msg.pt);
WinControl.Cursor:=ActionToCursor(CoordinatesToAction(
WinControl, ControlPoint.X, ControlPoint.Y));
end;
end;
We use FindDragTarget to find the control under the mouse cursor. We check to see that we found a control and that the control is owned by the same owner as our TEndUserFormDesigner component and that the control is not a form. Next we check that the control is a TWinControl. This is important. We have to handle TWinControl and TGraphicControl differently and our solution, at the moment, will only work with TWinControl. Now we use Coord inatesToAction to determine the action which the user could take by left-clicking at this cursor position. CoordinatesToAction is a long, rather dull, routine which is included on this month’s disk, but essentially it performs the proximity test which I talked of earlier to convert mouse coordinates into an action. We then use ActionToCursor (Listing 5) to convert the action to a cursor and assign that cursor to the control.
? Listing 5
function ActionToCursor(intAction: integer): TCursor;
begin
case intAction of
ResizeLeft , ResizeRight : Result:=crSizeWE;
ResizeTop , ResizeBottom : Result:=crSizeNS;
ResizeTopLeft , ResizeBottomRight: Result:=crSizeNWSE;
ResizeTopRight, ResizeBottomLeft : Result:=crSizeNESW;
else
Result:=crSizeAll;
end;
end;
So at this point the user can move the mouse over a control and the mouse cursor will reflect what action could be taken at any moment. The next step is to cope with the user clicking with the mouse. The relevant part of ApplicationOnMessage looks like Listing 6.
? Listing 6
if Msg.message = WM_LBUTTONDOWN then
begin
WinControl:=FindDragTarget(Msg.pt, True);
if Assigned(WinControl) and (WinControl.Owner = Owner)
and (WinControl is TWinControl) and not (WinControl is TForm) then begin
ReleaseCapture;
ControlPoint:=WinControl.ScreenToClient(Msg.pt);
PostMessage(Msg.hWnd, WM_SYSCOMMAND,
CoordinatesToAction(WinControl, ControlPoint.X, ControlPoint.Y), 0);
Handled:=True;
end;
end
This part of the process follows the same steps as before. The difference is that, instead of assigning a cursor to the control, a message is posted to the control using PostMessage. The correct message is determined using the Coord inatesToAction function.
At this point the user can now move TWinControl components around the form and resize them. The problem now is to handle TGraphicControls such as TLabel. These controls represent a bit of a problem for us. We cannot use the solution of sending a message to them to move or resize the controls because they don’t have a window handle. Nor can we send them such a message using Perform (which doesn’t require a window handle) because the moving and resizing messages are handled by Windows and not by Delphi. Instead we will base our solution on traditional drag and drop. In this implementation we will also decide that the user can only move the control and that it cannot be resized. Primarily this is because it is awkward to implement, but it is also because, in the case of a TLabel, the control is automatically sized according to its contents and therefore resizing is meaningless. The first part of this process is, once again, to provide visual feedback to the user when the mouse hovers over a TGraphicControl. The code in Listing 7 shows a new else part of the ApplicationOnMessage handler.
? Listing 7
...
else if Msg.message = WM_MOUSEMOVE then begin
WinControl:=FindDragTarget(Msg.pt, True);
if Assigned(WinControl) and (WinControl.Owner = Owner)
and not (WinControl is TForm) then
if (WinControl is TWinControl) then
(* the code here is the same as before *)
else if TControlCracker(WinControl).DragMode=dmAutomatic then
// the control is a TLabel or similar
WinControl.Cursor:=ActionToCursor(MoveComponent);
end;
The all-important line is the one with the TControlCracker. Our solution for TGraphicControl is based on drag and drop, so we need to access the DragMode property. In TGraphicControlDragMode is protected so we cannot access it. However, thanks to what is sometimes referred to as ‘friend classes’, we can. TControlCracker is as follows:
TControlCracker = class(Controls.TControl)
end;
So the essence of this new snippet of code is that if the control’s DragMode is dmAutomatic then the cursor is set to the move cursor (that is, crSizeAll). The remainder of this solution uses OnDragOver and OnDragDrop and it will have to wait for our palette which is coming up later.
Saving And Restoring The Form
Once the user has redesigned the form they will want to save it and have it restored next time. This is a relatively simple problem to solve. The approach here will be to save and restore the form using almost exactly the same process which Delphi uses itself. To save the redesigned form use WriteComponentRes like this:
WriteComponentResFile(‘Form1.frm’, self);
The first parameter is the name (and path) of the form file. The file is saved in Delphi’s binary format (ie not text format). I have chosen to give the file the extension ‘frm’ so that it does not overwrite the corresponding ‘dfm’ on my development machine. The second parameter is the form which is to be saved. The use of self assumes that this code is part of a method of the form itself.
This brings us on to an implementation issue. It would make our lives very pleasant if we could simply drop a SaveRestoreForm type component onto our form and encapsulate the problem of saving and restoring the form, so that we could just forget about it. Sadly this is difficult to achieve without changing the TForm class from which all forms inherit, and this would be a very intrusive solution. The problem that we have is that if forms are auto-created then they are created by Application. CreateForm and if they are not auto-created then they are created manually using something like TForm.Create. Neither solution allows us to say that we want to load our new FRM file instead of the DFM embedded in the EXE as a resource. Instead we have to write our own solution (Listing 8).
? Listing 8
function EndUserFormCreate(AFormClass: TFormClass; AOwner: TComponent;
strFormFileName: string): TForm;
begin
if FileExists(strFormFileName) then begin
Result:=(AFormClass).CreateNew(AOwner);
ReadComponentResFile(strFormFileName, Result);
end else
Result:=(AFormClass).Create(AOwner);
end;
This function accepts the name of a form class, the component which owns it (probably Application) and the form file to search for as a replacement for the one in the EXE. It can be used like this:
Form1:= EndUserFormCreate(TForm1, Application,‘Form1.frm’);
Form1.Show;
The all-important line in EndUserFormCreate is the line which calls CreateNew. This line creates the new form but does not load the form from the EXE. The next line calls ReadComponentResFile to read the FRM file.
One of the problems with this solution is that it cannot be used on the application’s main form. TApplication must call its CreateForm method on at least one form in order to assign a value to its read-only MainForm property. If MainForm is nil (because CreateForm has not been called at all) then Application.Run does nothing and the application closes immediately.
Adding New Components And Fields: The Theory
So at this stage the user can redesign the form, save it and restore it. What we need now is the ability to allow the user to add new components to the form and to add fields to the form. This requires a little thought about what this actually means. Your first thought might be to implement Delphi’s Component Palette and its Object Inspector. I am not sure that this is wise. The needs of the developer outweigh the needs of the users (or something like that). What I mean is that developers have needs that users do not. The Palette and the Object Inspector are just perfect for the developer, but not for the user. Consider the Palette. Do you want to allow users to add a TCheckBox to the form? Contrary to what you might think, I feel that the answer is no. To the user a checkbox would have to refer to a field in a database table. In this case they would not need a TCheckBox but a TDBCheckBox. Would the user need to add a TButton to a form? Again the answer is no, because a TButton would have to have code which performs an action. The goal is to allow users to redesign forms, not to give them a mirror of Delphi. If you follow this process through each of the components in the Palette then you can reduce the controls which the user genuinely needs to passive controls like TGroupBox, TPanel and TScrollBox.
We also need to add to this support for fields. I will assume that you have some facility for allowing the user to add their own fields on to the end of your tables, or some similar arrangement. In my experience, such user-defined fields seem to end up storing really important data like the user’s cat’s shoe size, or the number of flower pots outside the office, but at least it keeps the users happy. However, it does mean that we have to consider how to implement support for user-defined fields.
In my opinion we do not want to implement a solution which offers the same flexibility as Delphi does. As far as the user is concerned they want to add fields to their form: they do not want to add data-aware controls to their form and then connect those controls up to a data source and select which field in the database table they refer to. Furthermore, the user should not be able to choose which control to use for a given field. I think it is fair to assume that we will make that decision on their behalf. So if the user wants to add a ContactName field to a form then they will be automatically assigned a TDBEdit (or whatever control you use) and the TDBEdit will already have its DataSource and DataField set. In addition to this, the process of dropping a ‘field’ onto a form will create not only the data-aware control but also a TLabel control with the correctly assigned Caption for that field.
This approach makes life very simple for the user, but it also makes life very simple for us. Think this through and you will see that there is no longer any need for an Object Inspector. Ask yourself this: would you want the user to go setting the DockSite, DragMode, ParentBiDiMode or TabStop properties of your controls? If we conclude that we don’t need an Object Inspector then we can also conclude that we don’t need the concept of a ‘selected’ component, and therefore grab handles are not only unnecessary but also unwanted. This reasoning isn’t good for all situations, but it is suitable for many, and it is the approach which this article takes.
Adding New Components And Fields: The Practice
The support for this feature starts with a ‘palette’ with two pages shown in Figures 1 and 2.
? Figure 1
? Figure 2
The first page shows the fields of the associated TDataSet and the second page shows the ‘standard’ controls. The TEndUserFormDesign class shown earlier will be used as the container of the palette. The Listing 9 definition of TEndUserFormDesigner shows only the necessary changes to the previous TEndUserFormDesigner.
? Listing 9
TEndUserFormDesigner = class(TComponent)
private
FPalette: TPaletteForm;
FDataSource: TDataSource;
procedure SetDataSource(const Value: TDataSource);
protected
procedure Loaded; override;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
published
property DataSource: TDataSource
read FDataSource write SetDataSource;
end;
The constructor, destructor, Loaded and SetDataSource methods are shown in Listing 10.
Notice that the palette’s TargetForm property is set to the owner of the TEndUserFormDesigner. In the palette’s form the setting of the DataSource property simply adds the fields of the associated dataset to the listbox containing fields (Listing 11).
? Listing 10
constructor TEndUserFormDesigner.Create(AOwner: TComponent);
begin
inherited;
FPalette:=TPaletteForm.Create(nil);
FPalette.TargetForm:=(AOwner as TForm);
FPalette.Show;
end;
destructor TEndUserFormDesigner.Destroy;
begin
FPalette.Free;
inherited;
end;
procedure TEndUserFormDesigner.Loaded;
begin
inherited;
if Assigned(FDataSource) then
DataSource:=FDataSource;
end;
procedure TEndUserFormDesigner.SetDataSource(const Value: TDataSource);
begin
FDataSource := Value;
FPalette.DataSource:=Value;
end;
? Listing 11
procedure TPaletteForm.SetDataSource(const Value: TDataSource);
var
intField: integer;
begin
FDataSource := Value;
lbxFields.Clear;
if Assigned(FDataSource) and Assigned(FDataSource.DataSet) and
FDataSource.DataSet.Active then
for intField:=0 to FDataSource.DataSet.FieldCount - 1 do
lbxFields.Items.Add(FDataSource.DataSet.Fields[intField].DisplayName);
end;
The palette works by drag and drop: the user drags an item from the palette and drops it onto the form. For this reason we assign our own OnDragOver and OnDragDrop events to the form when the TargetForm property is set (see Listing 12).
? Listing 12
procedure TPaletteForm.SetTargetForm(const Value: TForm);
begin
FTargetForm := Value;
if Assigned(FTargetForm) then begin
FTargetForm.OnDragDrop:=FormDragDrop;
FTargetForm.OnDragOver:=FormDragOver;
end;
end;
The FormDragOver procedure is straightforward (Listing 13).
? Listing 13
procedure TPaletteForm.FormDragOver(Sender, Source: TObject; X, Y: Integer;
State: TDragState; var Accept: Boolean);
begin
Accept:=(Source is TComponent) and ((TComponent(Source).Owner is TPaletteForm)
or (TComponent(Source).Owner = sender));
end;
The FormDragDrop event is where it all happens. Listing 14 shows the overall structure of this event.
? Listing 14
procedure TPaletteForm.FormDragDrop(Sender, Source: TObject; X, Y: Integer);
var
Palette: TPaletteForm;
strComponentName: string;
strFieldName: string;
Control: TControl;
ListBox: TListBox;
begin
if Sender is TWinControl then begin
if (Source is TComponent) and
(TComponent(Source).Owner is TPaletteForm) then begin
Palette:=TPaletteForm(TComponent(Source).Owner);
if (Source is TListBox) then begin
ListBox:=TListBox(Source);
if (ListBox = Palette.lbxControls) then begin
(* Code Fragment 1 *)
end else if (ListBox = Palette.lbxFields)
and (ListBox.ItemIndex >= 0) then begin
(* Code Fragment 2 *)
end;
end;
end else if (Source is TControl) and
(TControl(Source).Owner = sender) then begin
TControl(Source).Top :=Y;
TControl(Source).Left:=X;
end;
end;
end;
Essentially this procedure boils down to identifying one of three conditions:
1. The item being dragged is from the Controls listbox on the palette (see Code Fragment 1).
2. The item being dragged is from the Fields listbox on the palette (see Code Fragment 2)
3. The item being dragged is a control on the same form (eg a TLabel).
When the item being dragged is from the Controls page of the palette the following code is executed:
strComponentName:= ListBox.Items[ListBox.ItemIndex];
CreateControl(strComponentName, TWinControl(Sender), X, Y);
The Controls TListBox is a crude mechanism which simply lists the names of the classes. I am sure everyone reading this can find a more elegant solution so I have left it simple to aid the explanation. CreateControl is a function which creates a control of the given type (strComponentName) at the given coordinates (X, Y) with the given owner (TWinControl(Sender)). The full code for CreateControl is on the disk but the essential parts are shown in Listing 15.
? Listing 15
var
Component: TComponent;
AComponentClass: TComponentClass;
AClass: TPersistentClass;
begin
AClass:=GetClass(strComponentName);
AComponentClass:=TComponentClass(AClass);
Component:=(AComponentClass).Create(AOwner);
Among other details CreateControl is also responsible for allocating a unique name to the component.
Now let’s move on to the fields. When the item being dragged is from the Fields page of the palette the code in Listing 16 is executed.
? Listing 16
...
else if (ListBox = Palette.lbxFields) and (ListBox.ItemIndex >= 0) then begin
strFieldName:=Palette.FieldName(ListBox.ItemIndex);
strComponentName:= Palette.FieldNameToComponentName(strFieldName);
Control:=CreateControl(strComponentName, TWinControl(Sender), X, Y + 20);
SetDataFieldProperty(Control, strFieldName);
SetDataSourceProperty(Control, Palette.DataSource);
Control:= CreateControl('TLabel', TWinControl(Sender), X, Y);
TLabel(Control).Caption:=
ListBox.Items[ListBox.ItemIndex];
TLabel(Control).DragMode:=dmAutomatic;
end;
There is more going on in this section than the last. Firstly, the name of the field is identified (strFieldName). Then FieldNameToComponentName returns the name of the data-aware control for this field. Again the code is on the disk but this is essentially a lookup based on the data type of the field. For example, a string field returns a TDBEdit, a memo field returns a TDBMemo, and so on. Then the appropriate control is created 20 pixels lower than the drop point. This provides room for the label which will be placed above the data-aware control. The next two lines call SetDataFieldProperty and SetDataSourceProperty (Listing 17) to set the data-aware control’s DataField and DataSource properties.
? Listing 17
procedure SetDataFieldProperty(Component: TComponent; DataField: string);
begin
if IsPublishedProp(Component, 'DataField') then begin
SetStrProp(Component, 'DataField', DataField);
end;
end;
procedure SetDataSourceProperty(Component: TComponent; DataSource: TDataSource);
begin
if IsPublishedProp(Component, 'DataSource') then begin
SetObjectProp(Component, 'DataSource', DataSource);
end;
end;
With this done, we create a TLabel at the drop point and set its Caption. We also set its DragMode so that it can be moved.
The final part of the FormDragDrop event handles controls which are being dragged from the form and dropped onto the same form. Typically this case is for TLabel but it would also include controls like TShape. The code simply reassigns the Top and Left properties to the coordinates of the drop point:
TControl(Source).Top :=Y;
TControl(Source).Left:=X;
At this point the user can move and resize components, add new components and fields to the form, and save and restore the form.
User-Defined Validation
It stands to reason that if users are allowed to add their own fields to a table and then to a form then they are going to want to provide their own validation for them in some way. You could spend days and weeks working out some sort of sophisticated visual design tool for allowing the user to specify the validation for their fields, but no matter how much effort you put into it I believe the solution will always be inadequate. There is no escaping it: the users will have to write some sort of code to validate the field.
Fortunately the technology for this solution is already available to us in the form of ActiveScript. Malcolm Groves provided a very thorough introduction to ActiveScript last month (see Dynamic Delphi Applications with ActiveScript, Issue 76) so I won’t be repeating his material here. Instead, this section of my article follows on from Malcolm’s article, so you might want to refer back to this for a few details.
If you import the Microsoft Script Control type library (Project | Import Type Library, select Microsoft Script Control 1.0, click Install) you will get a TScriptControl component which handles the whole business of scripting. Our needs demand that the script control be capable of validating fields so we will create a descendant of TScriptControl called TDataSetScriptControl (see Listing 18).
? Listing 18
TDataSetScriptControl = class(TScriptControl)
private
FDataSet: TDataSet;
procedure SetDataSet(const Value: TDataSet);
protected
function ProcedureExists(strProcName: widestring): boolean; virtual;
public
constructor Create(AOwner: TComponent); override;
function Validate(strField: string): boolean; virtual;
procedure OnValidate(Sender: TField); virtual;
published
property DataSet: TDataSet read FDataSet write SetDataSet;
end;
We are going to build this component in two stages: the easy part and the less easy part. We will start with the easy part, which represents simply validating a single field which is not related to any other fields. For example, the user might want to perform a modulus 10 check on a number string to ensure that it is valid. Such a check does not relate to any other field so the validation process is simple. The constructor is shown in Listing 19.
? Listing 19
constructor TDataSetScriptControl.Create(AOwner: TComponent);
begin
inherited;
Language:='VBScript';
if Assigned(AOwner) and (AOwner is TWinControl) then
SitehWnd:=TWinControl(AOwner).Handle;
end;
This constructor starts by making a massive, potentially incorrect, assumption that the scripting language is VBScript. This is simply for convenience of the example and you might not want to make such an assumption in practice. Do bear in mind that, whereas you can easily assign JScript to Language later, it is the assignment to Language which loads the scripting engine so the assignment of VBScript is not without a penalty. The next line attempts to assign the SitehWnd property to help initialise the component.
The action all happens in the Validate function (Listing 20).
? Listing 20
function TDataSetScriptControl.Validate(strField: string): boolean;
var
strProcName: widestring;
aParams : PSafeArray;
vParams : Variant;
begin
Result:=True;
strProcName:='OnValidate'+strField;
if Assigned(FDataSet) and ProcedureExists(strProcName) then begin
vParams :=VarArrayCreate([0, 0], varVariant);
vParams[0]:=FDataSet.FieldByName(strField).Value;
aParams :=PSafeArray(TVarData(vParams).VArray);
Result := Run(strProcName, aParams);
end;
end;
Validate is called with the name of a field to validate and it must return True or False to indicate whether the field is valid or not. Validate assumes that the field is validated by executing a script function called OnValidate<fieldname> where <fieldname> is the name of the field. Validate passes the value of the field to the OnValidate function. ProcedureExists is a simple routine to determine whether a procedure exists or not.
So, for example, the user might define the validation of the CustNo field as in Listing 21.
? Listing 21
function OnValidateCustNo(Value)
if Value = 9999 then
OnValidateCustNo = false
else
OnValidateCustNo = true
end if
end function
This function prevents the user from entering customer numbers equal to 9,999. The simplest way of performing the validation is to use the ready-made OnValidate event in the TDataSetScriptControl component (Listing 22).
? Listing 22
procedure TDataSetScriptControl.OnValidate(Sender: TField);
begin
if not Validate(Sender.FieldName) then
raise Exception.Create('Invalid value');
end;
Simply assign this OnValidate event to the field’s OnValidate property. Of course, the error reporting mechanism leaves something to be desired here. If you were to do this for real, then it might be an idea to return strings from the scripting validation functions instead of Boolean values. A null string would indicate that the data is valid and a string longer than 0 bytes would indicate that the data is invalid and the string itself is the error message.
So far so good. We can now validate a field. However, this solution does not allow for validation which depends on other fields. We need to make all of the fields in the dataset available to the script, not just the field being validated. This represents the ‘less easy’ part.
You might want to refer back to Malcolm Groves’ article for a fuller explanation of this next section. To make all of the fields available to the scripting language, we are going to add an automation object to the scripting engine. Consequently we will need to start by creating an ActiveX library.
Now add an Automation Object and call it DataSetScriptControlAutoObject. Using the Type Library editor, add a procedure called SetDataSet which accepts an integer parameter, and another function called FieldByName which accepts a WideString parameter and returns an OleVariant. Click on Refresh Implementation. Add a private field for the dataset, and the implementation class declaration should look like Listing 23.
? Listing 23
TDataSetScriptControlAutoObject = class(TAutoObject,
IDataSetScriptControlAutoObject)
private
FDataSet: TDataSet;
protected
function FieldByName(const Field: WideString): OleVariant; safecall;
procedure SetDataSet(DataSet: Integer); safecall;
end;
Unlike its Delphi counterpart, the FieldByName method returns the value of the field and not the TField object. It is implemented as shown in Listing 24.
? Listing 24
function TDataSetScriptControlAutoObject.FieldByName(const Field: WideString):
OleVariant;
var
oField: TField;
begin
Result:='';
if Assigned(FDataSet) then begin
oField:=FDataSet.FindField(Field);
if Assigned(oField) then
Result:=oField.Value;
end;
end;
The trick to making this work lies in SetDataSet. In order for FieldByName to get the values of the fields, it needs access to the Delphi TDataSet object. This is a bit of a problem, because the object which needs the TDataSet is an automation object and consequently its properties must all be automation datatypes. These datatypes exclude Delphi classes like TDataSet. The solution to the problem lies in a little typecasting. Delphi objects are just pointers and pointers are just integers and an integer is an automation data type. So we pass the pointer of the TDataSet as an integer to the automation object. The automation object then typecasts it back into a TDataSet. The automation object resides in an ActiveX Library, which is a DLL that is loaded into the same address space as the host program, so we are accessing a pointer which is in the same address space (see Listing 25). Success!
? Listing 25
procedure TDataSetScriptControlAutoObject.SetDataSet(DataSet: Integer);
begin
FDataSet:=Pointer(DataSet);
end;
To make use of the new automation object, add the lines from Listing 26 to the bottom of the TDataSetScriptControl.Create.
? Listing 26
if not (csDesigning in ComponentState) then begin
FDataSetScriptControlAutoObject:=
CreateCOMObject(Class_DataSetScriptControlAutoObject)
as IDataSetScriptControlAutoObject;
AddObject('DataSet', FDataSetScriptControlAutoObject, True);
end;
The CreateCOMObject line creates the automation object. The AddObject line makes the automation object available to the script language using the name DataSet. The third parameter indicates that the object is a top-level entity and so the methods of the object can be called without having to refer to the object first (in other words, the user can write FieldByName instead of DataSet.FieldByName). The final piece of the jigsaw is to update the TDataSetScriptControl.SetDataSet method to assign the DataSet to the automation object (see Listing 27).
? Listing 27
procedure TDataSetScriptControl.SetDataSet(const Value: TDataSet);
begin
FDataSet := Value;
if not (csDesigning in ComponentState) then
FDataSetScriptControlAutoObject.SetDataSet(Integer(Value));
end;
With all of this in place, your user can now write validation routines which refer to fields other than the one being validated (Listing 28).
? Listing 28
function OnValidateCity(Value)
if FieldByName("PostCode") = "BS1" and not (Value = "Birmingham") then
OnValidateCompany = false
else
OnValidateCompany = true
end if
end function
Of course, there is more to such a control than this, but this provides the basic idea and the basic building blocks. Beyond this you would need a mechanism to allow the user to enter and edit the validation for a field, a way of storing the validation somewhere, and a means of ensuring that the fields use this validation. The example included on this month’s companion disk provides all of this functionality.
Conclusion
Well, if you’ve made it this far then all I can say is that you’re at least 10 minutes older. Well done. We’ve travelled a long road in this article and covered many subjects. I would be surprised it you felt like you would use every one of these solutions exactly as-is, but you can probably find enough here to turn your thoughts into practice.