Generate Cross Platform Dynamic Forms At Runtime From JSON In Delphi 10.2.1 Tokyo

by Sep 20, 2017

The Hospitality Survey Client project is part of the Hospitality Survey App template for Delphi 10.2.1 Tokyo that Embarcadero has released through their GetIt platform. The Hospitality Survey App consists of four different projects. In this blog post I will cover the dynamic form generator that is built into the Hospitality Survey Client project. Also keep in mind that the client can be deployed to Android, iOS, macOS, and Windows with a single code base and a single responsive UI.

Basically how it works is on the server there is a database table which contains the list of survey questions to be asked to patrons from each tenant (in this case restaurant). The /survey/ end point in RAD Server is called from TBackendEndpoint (which is basically a TRESTClient) in the Survey App Client. The RAD Server end point returns the contents of the Questions table as the FireDAC JSON format. You can customize the questions using the Hospitality Survey Editor. The Client saves out the FireDAC JSON to a surveys.json file which is then loaded into an TFDMemTable. The GenerateSurvey() function (see below) loops through the records in the TFDMemTable and creates a TFrame for each record. Each record corresponds to a question in the database and you can see the different fields in a record below:

  • ID – An ID for the question.
  • name – A short name for the question with no spaces.
  • title – The text of the question as it will appear in the survey.
  • type – The type of question controls which question template is loaded on the client. The existing types are: rating, yesno, edit, options
  • options – If the type of the question is set to options this field is used to populate the options. It's value is a JSON array of options.
  • value – The value is where the user submitted data is stored. It can be left blank but could be used to provide a default answer.
  • category – The category of the question. This field is provided for expandability.
  • tenant_id – The tenant ID of the question. If the tenant_id field is blank all tenants will get the question. If it is set to a tenant only that tenant will get the question.

The Type column determines which TFrame is loaded for that record. The built in types are: rating, yesno, edit, options. You can add your own row types as well by modifying the GenerateSurvey() procedure. You can see the units below for each of the dynamic TFrames including a header frame and a complete button frame for submitting the form.

  • uSurveyHeaderFrame.pas – Contains the header for the top of the survey.
  • uRatingBarFrame.pas – Contains the star rating track bar survey question type.
  • uYesNoFrame.pas – Contains the Yes/No survey question type.
  • uEditFrame.pas – Contains the edit survey question type.
  • uComboBoxFrame.pas – Contains the combo box survey question type.
  • uCompleteFrame.pas – Contains the complete button for the survey.

The GenerateSurvey() procedure itself is pretty simple. It loops through the TFDMemTable dataset and checks the type field to see which TFrame to load and populate for that record. The options field is a JSON array that is used to populate the values for the yesno type and options type. The ID field is used to mark the TFrame with the specific question it was created from (FrameItem.Tag := BindSourceDBForm.DataSet.FieldByName('ID').AsInteger;) so that the value field can be filled out with the answer from the user.

//
//
procedure TMainForm.GenerateSurvey(Sender: TObject);
var
  FrameItem: TFrame;
  FieldType: String;
  FieldCategory: Integer;
  JSONArray: TJSONArray;
  I: Integer;
begin
  FrameItem := TSurveyHeaderFrame.Create(TFMXObject(Sender));
  FrameItem.Parent := TFMXObject(Sender);
  FrameItem.Name := 'FSurveyHeader';
  FrameItem.Align := TAlignLayout.Top;
  FrameItem.Position.Y := 0;

  BindSourceDBForm.DataSet.First;
  while not BindSourceDBForm.DataSet.Eof do
  begin
    FieldCategory := BindSourceDBForm.DataSet.FieldByName('category').AsInteger;

      FieldType := BindSourceDBForm.DataSet.FieldByName('type').AsString;
      if FieldType = 'edit' then
      begin
        FrameItem := TEditFrame.Create(TFMXObject(Sender));
        FrameItem.Parent := TFMXObject(Sender);
        TEditFrame(FrameItem).QuestionText.Text :=
          BindSourceDBForm.DataSet.FieldByName('title').AsString;
      end;
      if FieldType = 'yesno' then
      begin
        FrameItem := TYesNoFrame.Create(TFMXObject(Sender));
        FrameItem.Parent := TFMXObject(Sender);
        TYesNoFrame(FrameItem).QuestionText.Text := BindSourceDBForm.DataSet.FieldByName('title').AsString;
        JSONArray := TJSONObject.ParseJSONValue(BindSourceDBForm.DataSet.FieldByName('options').AsString) as TJSONArray;
        for I := 0 to JSONArray.Count - 1 do
        begin
          case I of
            0:
              begin
                TYesNoFrame(FrameItem).ValueSpeedButton1.Text := JSONArray.Items[I].Value;
                TYesNoFrame(FrameItem).ValueSpeedButton1.GroupName := BindSourceDBForm.DataSet.FieldByName('name').AsString;
              end;
            1:
              begin
                TYesNoFrame(FrameItem).ValueSpeedButton2.Text := JSONArray.Items[I].Value;
                TYesNoFrame(FrameItem).ValueSpeedButton2.GroupName := BindSourceDBForm.DataSet.FieldByName('name').AsString;
              end;
          end;
        end;
        JSONArray.Free;
      end;
      if FieldType = 'rating' then
      begin
        FrameItem := TRatingBarFrame.Create(TFMXObject(Sender));
        FrameItem.Parent := TFMXObject(Sender);
        TRatingBarFrame(FrameItem).QuestionText.Text := BindSourceDBForm.DataSet.FieldByName('title').AsString;
      end;
      if FieldType = 'options' then
      begin
        FrameItem := TOptionsFrame.Create(TFMXObject(Sender));
        FrameItem.Parent := TFMXObject(Sender);
        TOptionsFrame(FrameItem).QuestionText.Text := BindSourceDBForm.DataSet.FieldByName('title').AsString;
        JSONArray := TJSONObject.ParseJSONValue(BindSourceDBForm.DataSet.FieldByName('options').AsString) as TJSONArray;
        TOptionsFrame(FrameItem).ValueComboBox.Items.BeginUpdate;
        for I := 0 to JSONArray.Count - 1 do
        begin
          TOptionsFrame(FrameItem).ValueComboBox.Items.Add(JSONArray.Items[I].Value);
        end;
        TOptionsFrame(FrameItem).ValueComboBox.Items.EndUpdate;
        JSONArray.Free;
      end;
      FrameItem.Name := 'F' + BindSourceDBForm.DataSet.FieldByName('ID').AsString;
      FrameItem.Align := TAlignLayout.Top;
      FrameItem.Tag := BindSourceDBForm.DataSet.FieldByName('ID').AsInteger;
      FrameItem.Position.Y := BindSourceDBForm.DataSet.FieldByName('ID').AsInteger * 100;

    BindSourceDBForm.DataSet.Next;
    Application.ProcessMessages;
  end;

  FrameItem := TCompleteFrame.Create(TFMXObject(Sender));
  FrameItem.Parent := TFMXObject(Sender);
  FrameItem.Name := 'FComplete';
  FrameItem.Align := TAlignLayout.Top;
  FrameItem.Position.Y := 1000000;
  TCompleteFrame(FrameItem).CompleteButton.OnClick := CompleteClick;

end;

The selected option in each TFrame gets sent back to the TFDMemTable via the UpdateValueByID() procedure as you can see below. In the below code the Self.Tag field corresponds to the ID field of the question.

//
//
procedure TOptionsFrame.ValueComboBoxChange(Sender: TObject);
begin
  MainForm.UpdateValueByID(Self.Tag,ValueComboBox.Items[ValueComboBox.ItemIndex]);
end;

The UpdateValueByID() procedure uses the Locate() procedure on the DataSet to find the correct record and then update the value field.

//
//
procedure TMainForm.UpdateValueByID(ID: Integer; const Value: string);
begin
  if BindSourceDBForm.DataSet.Locate('ID', VarArrayOf([ID]), []) = True then
  begin
    BindSourceDBForm.DataSet.Edit;
    BindSourceDBForm.DataSet.FieldByName('value').AsString := Value;
    BindSourceDBForm.DataSet.Post;
  end;
end;

Once the survey has been completed by the user then the entire contents of the TFDMemTable are saved out to the FireDAC JSON format and uploaded back to the server via a POST from a TBackendEndpoint component to the /survey/complete endpoint. In the case of the Hospitality Survey App all of the uploaded records are saved for each collected survey. This allows the survey questions to be created, removed, and changed without affecting any of the existing surveys that have already been collected.

Want to know more? Check out the full Deep Dive into the Hospitality Survey App template for Delphi 10.2.1 Tokyo.