End-User Form Design

此文摘自: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

FormDesignFig1

? Figure 2

FormDesignFig2

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.

Error: error:0308010C:digital envelope routines::unsupported at new Hash (node:internal/crypto/hash:71:19) at Object.createHash (node:crypto:133:10) at module.exports (D:\Microsoft VS Code workplace\bosssoft-train-user-permission-centre-front-end-full\node_modules\webpack\lib\util\createHash.js:135:53) at NormalModule._initBuildHash (D:\Microsoft VS Code workplace\bosssoft-train-user-permission-centre-front-end-full\node_modules\webpack\lib\NormalModule.js:417:16) at handleParseError (D:\Microsoft VS Code workplace\bosssoft-train-user-permission-centre-front-end-full\node_modules\webpack\lib\NormalModule.js:471:10) at D:\Microsoft VS Code workplace\bosssoft-train-user-permission-centre-front-end-full\node_modules\webpack\lib\NormalModule.js:503:5 at D:\Microsoft VS Code workplace\bosssoft-train-user-permission-centre-front-end-full\node_modules\webpack\lib\NormalModule.js:358:12 at D:\Microsoft VS Code workplace\bosssoft-train-user-permission-centre-front-end-full\node_modules\loader-runner\lib\LoaderRunner.js:373:3 at iterateNormalLoaders (D:\Microsoft VS Code workplace\bosssoft-train-user-permission-centre-front-end-full\node_modules\loader-runner\lib\LoaderRunner.js:214:10) at Array.<anonymous> (D:\Microsoft VS Code workplace\bosssoft-train-user-permission-centre-front-end-full\node_modules\loader-runner\lib\LoaderRunner.js:205:4) at Storage.finished (D:\Microsoft VS Code workplace\bosssoft-train-user-permission-centre-front-end-full\node_modules\enhanced-resolve\lib\CachedInputFileSystem.js:55:16) at D:\Microsoft VS Code workplace\bosssoft-train-user-permission-centre-front-end-full\node_modules\enhanced-resolve\lib\CachedInputFileSystem.js:91:9 at D:\Microsoft VS Code workplace\bosssoft-train-user-permission-centre-front-end-full\node_modules\graceful-fs\graceful-fs.js:123:16 at FSReqCallback.readFileAfterClose [as oncomplete] (node:internal/fs/read_file_context:68:3) { opensslErrorStack: [ 'error:03000086:digital envelope routines::initialization error' ], library: 'digital envelope routines', reason: 'unsupported', code: 'ERR_OSSL_EVP_UNSUPPORTED'
07-21
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值