Blog

All Blog Posts  |  Next Post  |  Previous Post

TMS WEB Core and More with Andrew:
TMS XData: An Extended Example Part 2 of 3

Bookmarks: 

Thursday, November 3, 2022

Photo of Andrew Simard
In Part 2 of this XData Extended Example, we focus mainly on the largest of the three apps, the Survey Admin Client.  But first, if you've not already filled out the survey, here it is again.



If you'd like to access the survey directly, rather than using the <iframe> embedded in this post, please use this link. If you encounter anything noteworthy or otherwise problematic at any point during the survey, please submit feedback using the icon in the top-right corner. Note that the survey should work equally well on a mobile device.

Part 1 of 3
  1. Introduction
  2. Bargain: Three Apps For The Price of One!
  3. Survey Client Initial Layout
  4. Survey Server Initial Setup
  5. Swagger
  6. First Contact - Getting Data from XData
Part 2 of 3
  1. Survey Admin Client Initial Setup
  2. Serviceability - Getting Logged In
  3. More Service Endpoints
  4. Survey Admin Client: Login
  5. Survey Admin Client: Managing Surveys
  6. Connecting SQL to Tabulator
  7. Global Save and Cancel
  8. Editing HTML
  9. QR Codes
  10. Custom Dropdown
Part 3 of 3
  1. Survey Client Questions
  2. Saving Responses
  3. Storing Data - JSON versus Structure
  4. Displaying Responses
  5. To-Do List
  6. Roadblocks Overcome
  7. All Done!

Survey Admin Client Initial Setup

The architecture of the Survey Admin Client needs to accommodate four basic lists to start with, which willevolve as we get our footing designing the interface.

  • A list of surveys.  A survey consists of a number of configuration items, along with a list of questions that can be arranged in a "graph" of some kind.
  • A list of questions.  Think of this as a "question bank" to help reduce the tedious nature of recreating the same responses to questions all the time.
  • A list of responses.  Once someone fills out a survey, we'll need to see the results.
  • A list of users.  Who can manage a survey?  Who can view the results?  Who can manage the users?

As far as the "graph" is concerned, this is referring to the idea that, based on the responses to individual questions, the progression through the list of questions isn't necessarily strictly linear.  Maybe there are optional sections of a survey, or maybe different paths through a survey are presented, depending on previous responses.  Ideally we'll be able to manage this just through lists without needing to display an actual graph of any kind. More complexity here likely means more testing on behalf of those creating the surveys, however.

We don't really want to spend a great deal of time covering all the intricacies of access control in this post, but we'll implement it all the same.  We'll obviously need a way to authenticate users and control what they access.  These topics are solid candidates for further refinement, customization, and so on based on your own particular interests/needs/preferences, so we'll not get too hung up on it at the moment.  Instead we'll try to compartmentalize it all as best we can to make these kinds of things easy to augment or replace if needed.

As with the first two applications (the Survey Client and the Survey Server), here we're interested in building a basic app to begin with.  We'll get back to the gritty details of XData authentication and the rest of it in just a moment.  For the UI, we're going to follow along in the same manner as we did with the recent Labels and Barcodes post, which you can check out here. The essence of which is that we'll use Tabulator tables whenever we need a list for something, and TWebPageControl components when we want to have pages, and something other than the TWebPageControl's tabs when we want a UI for switching pages.  Throw in a whole bunch of Bootstrap classes, Font Awesome icons, a smattering of obscure color choices, mix, and serve. Here's where we're starting from.


TMS Software Delphi  Components
Beginnings of the Survey Admin Client UI

Lots going on here in terms of UI, but actually not much different than what we've covered previously.  The row of buttons at the top left control a TWebPageControl, with the first tab showing a list of surveys on the left, and the details of the selected survey on the right.  The details include things like options, questions and a preview tool.  Those are setup within another TWebPageControl with another set of buttons.  The Options page is selected in the screenshot above, which contains a list of options on the left, and, yes, a third TWebPageControl showing the UI for that particular Option.  The Options list is built with Tabulator, and selecting a different option will change the page of this third TWebPageControl, with the "Basic Information" page shown above, where we can see the UI elements for changing the survey name, among other things. Think of it as a TWebPageControl with tabs on the left, but instead of tabs we've got a Tabulator list. Wiring up the code to manipulate the TWebPageControls isn't very difficult, and there are lots of ways to do it.  As we've got a few of them now, maybe it is worth having some functions to handle this.  For example, we can define an array of buttons as a form variable, and populate it with the buttons related to a certain control.  And then wire all those buttons up such that their individual Tag property corresponds to the TabIndex of the TWebPageControl, and the Click events all call the same function.  Here's an example for the main menu at the top. The Options list does something similar, but uses Tabulator data instead of a group of buttons to select the page.

procedure TForm1.btnSurveyMenuClick(Sender: TObject);
var
  i: Integer;
begin
  // Menu at the top of the survey page
  if (Sender is TWebButton) then
  begin

    for i := 0 to Length(SurveyMenuButtons) - 1 do
    begin
      if (Sender as TWebButton) = SurveyMenuButtons[i]
      then SurveyMenuButtons[i].ElementHandle.classList.replace('btn-dark','btn-primary')
      else SurveyMenuButtons[i].ElementHandle.classList.replace('btn-primary','btn-dark');
    end;

    pagecontrolSurveys.TabIndex := (Sender as TWebButton).Tag;

  end;
end;

The main idea I'd like to convey with the overall design is that you can use TMS WEB Core to design a web application in pretty much the same way that you might design a VCL application, complete with PageControls, Buttons, Edits, Labels, and the rest of the usual suspects.  In fact, there really isn't anything that limits what you can do, particularly when compared to a traditional VCL app.  Many familiar VCL components have a TWeb equivalent and work very well when it comes to quickly building a comprehensive and extensible user interface. And for those who are very particular about how things look or function in terms of UI/UX, when using the default components in TMS WEB Core, along with some CSS, it entirely possible to control literally every pixel on the page.  From rounded corners, to colors, to spacing, to icon and font sizes and placement, everything can be adjusted.  This aspect alone could help support the argument that the UI for a web app is more easily built, and more quickly built, and far more customizable, than an equivalent UI for a VCL app.  So if you have plenty of VCL UI experience but comparatively little Web UI experience, this should be a very smooth and empowering transition. Or it can be once you figure out how Bootstrap and CSS work.

Building an app by throwing a bunch of components on a page might seem haphazard, and indeed this approach has certain trade-offs. Particularly if you have a team where one group is responsible for the UI and another is responsible for the backend, or something along those lines.  In our project today, however, this is a quick way to discover or otherwise determine what we'll be needing from XData, and maybe even a bit informative as to how we might want to organize our XData service endpoint calls.  For example, we might want a service endpoint to retrieve a list of surveys containing enough information to populate the left-most table above, separate from everything else.  And we'll need a login function.  Doing this kind of design work ahead of time may help reduce the instances of flipping back and forth between changes in the Survey Server app and changes in the Survey Admin Client app.  Which isn't critically important for a single developer, but can be hugely impactful in a larger team.  With that in mind, let's get back to the Survey Server and XData and add a few more building blocks.


SurveyAdminService - Getting Logged In

Previously, we had setup the SurveyClientService, focused on providing an interface for the Survey Client to communicate with the Survey Server.  Here, we're going to do the same for the Survey Admin Client, providing a list of service endpoints (functions) that the Survey Admin Client will use when going about its business, kept separate from those service endpoints used by the Survey Client.  Let's be boring and call it the SurveyAdminService.  The main feature separating the two groups of service endpoints is that very nearly all of the SurveyAdminService endpoints will require authentication of some kind, whereas none of the SurveyClientService endpoints will require any authentication. But in order to provide for an authentication mechanism, we'll need something to authenticate against. Note also that for certain projects, you might split these functions out into separate XData applications entirely, with perhaps the SurveyAdminService running somewhere on a private network, not accessible to interlopers at all. Lots of options here, but as we're ultimately interested in outsiders (aka potential clients) designing their own surveys, we'll keep everything here under the same umbrella.

Typically, users are authenticated by comparing a set of login credentials against a set of known credentials stored in a protected database somewhere.  And XData is readily capable of connecting to any database in the same way that any other VCL application can (it is a 100% VCL app, after all), which means that virtually any database can be used for this kind of thing.  To try and make this as simple as possible, we're going to use a SQLite database, with the idea that it is simple and doesn't require much of anything to get started.  In fact, we'll set it up so that when the Survey Server starts, it will try and connect to its database, and if it doesn't exist, it will just create one on the fly.  Not normally how these things are done, but workable all the same.  

Note that this project was developed using Delphi 10.3. When Delphi 10.4 was released, it contained many updates that changed how SQLite databases are handled.  Nothing that should impact what we're up to today, but something to bear in mind.  We'll also be using FireDAC for our example, which should make it trivial to switch between databases if your preferred database also happens to be supported by FireDAC. If you're using something else, it should be easy enough to follow what we're doing with FireDAC.  Nothing too adventurous database-wise. But a good reminder of one of the key reasons why we're using XData - a REST API server - in the first place:  to hide whatever we're doing in terms of database access.  We can even change the database technology entirely without changing a single line of code in our two connecting applications.  Here's what our initial test database setup looks like.  This can be found in Unit1.pas (ServerContainer).

procedure TServerContainer.DataModuleCreate(Sender: TObject);
var
  sha2: TSHA2Hash;
  password: string;
begin

  // Setup SwaggerUI Content
  TXDataModelBuilder.LoadXMLDoc(XDataServer.Model);
  XDataServer.Model.Title := 'Survey API';
  XDataServer.Model.Version := '1.0';
  XDataServer.Model.Description :=
    '### Overview'#13#10 +
    'This is the REST API for interacting with the Survey project.';


  // FDConnection component dropped on form
  // FDPhysSQLiteDriverLink component droppoed on form
  // FDQuery component dropped on form


  // This creates the database if it doesn't already exist
  FDManager.Open;
  FDConnection1.Params.Clear;
  FDConnection1.Params.DriverID := 'SQLite';
  FDConnection1.Params.Database := 'SurveyData.sqlite';
  FDConnection1.Params.Add('Synchronous=Full');
  FDConnection1.Params.Add('LockingMode=Normal');
  FDConnection1.Params.Add('SharedCache=False');
  FDConnection1.Params.Add('UpdateOptions.LockWait=True');
  FDConnection1.Params.Add('BusyTimeout=10000');
  FDConnection1.Params.Add('SQLiteAdvanced=page_size=4096');


  // Create the tables if they don't already exist
  FDQuery1.Connection := FDConnection1;
  with FDQuery1 do
  begin
    SQL.Clear;

    SQL.Add('create table if not exists accounts ('+
              'account_id char(38),'+
              'email varchar(50),'+
              'first_name varchar(50),'+
              'last_name varchar(50),'+
              'password_hash text);');

    SQL.Add('create table if not exists surveys ('+
              'survey_id char(38),'+
              'name varchar(100),'+
              'survey text);');

    SQL.Add('create table if not exists permissions ('+
              'survey_id char(38),'+
              'account_id char(38),'+
              'permissions char(4));');

  end;
  FDQuery1.ExecSQL;


  // if there are no accounts, create a default account
  // Default Username: setup
  // Default Password: password1234
  with FDQuery1 do
  begin
    SQL.Clear;
    SQL.Add('select count(*) usercount from accounts;');
  end;
  FDQuery1.Open;

  if FDQuery1.FieldByName('usercount').AsInteger = 0 then
  begin

    // Generate password hash
    sha2 := TSHA2Hash.Create;
    sha2.HashSizeBits := 256;
    sha2.OutputFormat := hexa;
    sha2.Unicode := noUni;
    password := sha2.Hash('password1234');
    sha2.Free;

    with FDQuery1 do
    begin
      SQL.Clear;
      SQL.Add('insert into accounts values("{D10C19DE-7253-42FB-85CB-A1E0F378BFC0}","setup","Default","User","'+password+'");');
    end;
    FDQuery1.ExecSQL;
  end;

end;


A few notes here about the password.  It is stored as an SHA-256 hash which is generated using the TMS Cryptography Pack.  Supporting units from there were added to the uses clause for this unit (MiscObj, HashObj).  Certainly not the only way to generate an SHA-256 hash.  But after using TMS WEB Core for a while, it feels strange when using the VCL, not being able to pop over to JavaScript for some obscure function whenever you want.  The idea here is that the password is stored in a one-way encoded format, so nobody can guess what the password is, nor can anyone get a list of passwords - they're not stored anywhere.  Instead, to validate a password, we get a password to check, and then generate the same one-way encoded value (a hash) and see if it matches the hashed value we have on hand.  Where this hashing takes place can be important, but less so if you're using HTTPS on your production server. 

A few notes here about SQLite.  This is actually my first time using SQLite in any meaningful way.  It isn't a very complex database engine, to be fair, but I did encounter a few hiccups.  One is that, as we'll see, our XData application is multi-threaded. And accessing SQLite using FireDAC by default is not at all compatible with that arrangement. This is bad news as it is entirely possible (and in fact highly likely) that the database will end up corrupted very quickly.  Even the slightest hint of that possibility is a solid reason to select a diffrent database engine.  The good news is that we can change how FireDAC works, and adjust how we're doing things in XData, to compensate. In the code above, several parameters have been added to help with this.  I suspect some of those parameters are superfluous but things are working well at this stage so I'm hesitant to make any changes. There's a lot to like about SQLite (mostly its simplicity) and a lot to not like (also, mostly its simplicity).  Great for testing but longer-term perhaps not the best choice for anything more substantial.  But did I mention it's simplicity?  I don't know if there is another database engine that just works without installing or configuring anything else.  Handy for sure.

The tables and their columns are going to change and evolve as we add new functionality, but here we can see how tables are created automatically if they didn't exist previously.  All we're really storing is identifiers (GUIDs, often) and then JSON stored in TEXT columns, which can in theory be quite large (GB-range, typically).  As long as your database of choice can deal with that (highly probable), you're not likely to run into any problems.  There are of course a million ways to deal with the database side of things, but this will be enough for now. Note that we're not doing anything with foreign keys, fancy data types, triggers, or anything like that.  In fact, as we'll see, we're going to be storing a lot of structured data as JSON in TEXT columns.  This is a bit odd to me, as a long-time SQL fan, but as we'll see, this certainly simplifies things that don't really need to be complex most of the time. 

With that out of the way, it is time to create a login function as part of a new service , the SurveyAdminService.  The parameters are going to simply be username and password.  We're not going to worry about them being passed "in the clear" because this is ultimately going to be accessed over SSL, so the whole communication aspect will be encrypted anyway.  The return value is going to be either an error message (invalid password, invalid user, or something along those lines) or a valid JWT.  

What's a JWT, you ask?  In short, it is a token that will be used to simplify passing credentials and related information for the rest of the service endpoints.  There is more information on JWT and XData in the XData Developer's Guide section on Authorization and Authentication.  This token will be automatically included in subsequent requests, in effect implementing a chunk of the "state" that we were discussing earlier.  It will be added as part of the HTTP header when making subsequent requests.  We'll see some examples of how JWTs are used in other service endpoints, but in the Login service endpoint, we're generating it.  We added the JWT middleware to the XDataServer component earlier in anticipation of this.  Here's what the implementation of our Login service endpoint looks like.

function TSurveyAdminService.Login(Email, Password: string): string;
var
  qry: TFDQuery;
  JWT: TJWT;
  sha2: TSHA2Hash;
  password_hash: String;

begin

  // Query to validate the password
  qry := TFDQuery.Create(nil);
  qry.Connection := ServerContainer.FDConnection1;
  with qry do
  begin
    SQL.Clear;
    SQL.Add('select password_hash,account_id from accounts where email=:EMAIL;');
    ParamByName('EMAIL').AsString := EMail;
  end;

  qry.Open;

  if (qry.RecordCount = 1) then
  begin
    // Get SHA-256 hash of password supplied by the user
    sha2 := TSHA2Hash.Create;
    sha2.HashSizeBits := 256;
    sha2.OutputFormat := hexa;
    sha2.Unicode := noUni;
    password_hash := sha2.Hash(Password);
    sha2.Free;

    // Check if it matches the hash stored in the database
    if (qry.FieldByName('password_hash').AsString = password_hash) then
    begin
      // It does! So create a JWT
      JWT := TJWT.Create;
      try
        // Setup some Claims
        JWT.Claims.Issuer := 'XData Survey Server';
        JWT.Claims.IssuedAt := Now;
        JWT.Claims.Expiration := Now + 1;
        JWT.Claims.SetClaimOfType<string>( 'eml', EMail );
        JWT.Claims.SetClaimOfType<string>( 'acc', qry.FieldByName('account_id').asString );
        JWT.Claims.SetClaimOfType<string>( 'now', FormatDateTime('yyyy-mm-dd hh:nn:ss.zzz', Now));

        // Generate the actual JWT
        Result := 'Bearer '+TJOSE.SHA256CompactToken(ServerContainer.XDataServerJWT.Secret, JWT);
      finally
        JWT.Free;
      end;
    end
    else
    begin
      // Passwords didn't match
      Result := 'Incorrect E-Mail / Password';
      raise EXDataHttpUnauthorized.Create('Incorrect E-Mail / Password')
    end;
  end
  else
  begin
    // Account not found
    // Lots of good reasons to NOT distinguish this error from an incorrect
    // password error but the option is here either way.
    Result := 'Incorrect E-Mail / Password';
    raise EXDataHttpUnauthorized.Create('Incorrect E-Mail / Password')
  end;

  qry.Close;
  qry.Free;
end;


Note that the JWT is configured with various 'claims'.  These can contain whatever information you like and is a good place to put information about the client (some of its "state") for future use.  For example, if the same user has access to different databases, perhaps the login mechanism will include a database selection, and the selected database would be stored as a JWT claim as well.  If you're producing time-stamped reports in your XData server, and you want to put the local time on the report, perhaps the Login function could pass in the local timezone as well, which would then be stored as a JWT claim, and used by the report generation service endpoint.  Lots of options. The only other item to be mindful of is that there is a handful of reserved (registered) claims that should be avoided - just choose a different name for the claim.  The list can be found here. A list of extended claims can also be found via the same link.

Note also that we're using user input values to generate SQL queries, so we must be mindful of SQL Injection attacks.  Here, we're using a parameterized query to address that issue.

With that in place, the service endpoint can be tested with the SwaggerUI we set up earlier.  entering a username/password, you will either get an authorization error message, or you'll end up with a proper JWT. This is roughly equivalent to having "logged In" as once the client has access to a valid JWT, they can then access the rest of the protected functions without having to authenticate again. Note that with a bit more work we could configure our XData server with endpoints to renew or revoke JWTs, but by default, they will already have an expiration mechanism. We're setting it to 24 hours here.  Here's what a JWT looks like.


TMS Software Delphi  Components
Successfully Logged In


You can even copy & paste the code (leaving out the "Bearer " prefix) to a website like JWT.io and it will decode it for you if you were wanting to check that it contained what was expected.  Keep in mind this isn't a security risk, so long as you don't put anything particularly sensitive into the JWT.  We didn't, for example, include the original password or password_hash values.  Instead, we'll rely on the account value (account) for subsequent data access, and we'll include the e-mail address (email) just to save having to look it up if we want to send notification emails later.  And we can be confident that nobody else can fabricate a JWT, even knowing what is in one, because we're using our "secret" from the XDataServer's JWT middleware to create the JWT in the first place.  Here's what we can see if we decode a JWT (not the same as the one above).


TMS Software Delphi  Components

Note that the first three claims (iss, iat and exp) and generated using specific properties when we created the JWT.  The "issued at" and "expiry" claims are stored using a Unix timestamp (int64) value.  We've added an "issued" claim which has the iat value stored as a human-readable value, and we're issuing JWTs that expire after one day (the difference between iat and exp above is 86400 - the number of seconds in one day).  A JWT could be issued with a considerably shorter validity period, like 15 minutes, and then renewed continually as long as the user is active.  This is how we could implement a global application time-out for example - once the JWT expires, a new one has to be issued to continue working.  This could happen without user interaction, if the application detects that the user is still present based on some minimal interaction, just like in a traditional VCL application.


More Service Endpoints

With all that out of the way, it is now open season on other service endpoints.  They'll all be setup largely the same way.  We'll start by getting the account value from the JWT and combine that with the parameters of a given service endpoint to craft a query to perform whatever function is needed.  And we'll add a service endpoint for each type of interaction that the Survey Admin Client will need in order to perform its functions.  If you have a project with an existing database with hundreds of tables and thousands of functions, there may be other tools that can be used here to help lighten the workload.  Or service endpoints can be created that function more like generic SQL queries themselves, or CRUD endpoints, that kind of thing.  And there are entirely separate ways of addressing these challenges using products like TMS Aurelius - an object management framework that works with XData to do just this kind of thing.  Our application today is simple enough that we can just deal with the manual SQL approach easily enough.  

One common function that we'll need involves returning the results of an SQL query as JSON, and in a structure that is suitable for use directly in the client application.  A complex topic to be sure. The native version of FireDAC JSON contains a lot of information, particularly datatypes and other field definition information, that can be quite useful if the client doesn't know what kind of data is coming in.  This is what you get when using something like qry.SaveToStream(Result,sfJSON), for example.  What we're after is a simplified version, just a JSON array of rows, with a set of key:value pairs for each column in each row.  We can get the JSON in that format by using some additional FireDAC features.  Here's a procedure that we'll use to take care of this, with the added bonus that it is already a TStream, ready to be passed back to the client.

procedure TSurveyAdminService.FireDACtoSimpleJSON(qry: TFDQuery; JSON: TStream);
var
  bm: TFDBatchMove;
  bw: TFDBatchMoveJSONWriter;
  br: TFDBatchMoveDataSetReader;
begin
  bm := TFDBatchMove.Create(nil);
  bw := TFDBatchMoveJSONWriter.Create(nil);
  br := TFDBatchMoveDataSetReader.Create(nil);
  try
    br.Dataset := qry;
    bw.Stream := JSON;
    bm.Reader := br;
    bm.Writer := bw;
    bm.Execute;
  finally
    br.Free;
    bw.Free;
    bm.Free;
  end;
end;

With that, we can then implement a GetSurveys function that will return a list of surveys that the user has access to, based on what we find in the JWT.  We don't even need any other parameters for the service endpoint, as we won't allow access without a valid JWT, and the JWT can provide us the "account" value that might otherwise have been a parameter.  To restrict access based on having a valid JWT, we'll need to add the [Authorize] attribute to the method declaration in the service endpoint interface, as well as add XData.Security.Attributes to the uses clause. Note that we don't do this for the Login function as we'll be calling that to get the JWT in the first place. Our SurveyAdminService.pas interface now looks like this.

unit SurveyAdminService;

interface

uses
  System.Classes,
  XData.Security.Attributes,
  XData.Service.Common;

type
  [ServiceContract]
  ISurveyAdminService = interface(IInvokable)
    ['{18375DB7-2E25-4688-B286-270FA7417C89}']

    function Login(Email: string; Password: string):string;
    [Authorize] function GetSurveys: TStream;

  end;

implementation

initialization
  RegisterServiceType(TypeInfo(ISurveyAdminService));

end.

The implementation of GetSurveys just needs to get the account value from the JWT and run a query to get all of the surveys that are linked to that account, returning a result set that is then converted to JSON and passed back as a TStream.  Naturally, we can do whatever we like with the SQL here without concern about things like SQL injection, but using parameters is still a great practice.  We'll need to add another unit, Sparkle.Security, to our uses clause to be able to access the JWT contents.  Here's what the GetSurveys implementation looks like.

function TSurveyAdminService.GetSurveys: TStream;
var
  usr: IUserIdentity;
  qry: TFDQuery;
begin
  // Returning JSON
  TXDataOperationContext.Current.Response.Headers.SetValue('content-type', 'application/json');
  Result := TMemoryStream.Create;

  // Got a usable JWT?
  usr := TXDataOperationContext.Current.Request.User;
  if (usr = nil) then raise EXDataHttpUnauthorized.Create('Failed authentication');
  if not(usr.Claims.Exists('account')) then raise EXDataHttpUnauthorized.Create('Missing account');

  // Create a query
  qry := TFDQuery.Create(nil);
  qry.Connection := ServerContainer.FDConnection1;

  // Populate query
  with qry do
  begin
    SQL.Clear;
    SQL.Add('select surveys.survey_id, surveys.name, permissions.permissions');
    SQL.Add('  from surveys, permissions');
    SQL.Add(' where surveys.survey_id = permissions.survey_id');
    SQL.Add('   and permissions.account_id = :ACCOUNT;');
    ParamByName('ACCOUNT').AsString := usr.Claims.Find('account').AsString;
  end;
  qry.Open;

  // Return query as JSON stream
  FireDACtoSimpleJSON(qry, Result);

  // Cleanup
  qry.Close;
  qry.Free;
end;

In order to test this, we can use Swagger.  But as we've set this up as a service endpoint that requires authorization, we'll need to simulate a "login".  We can also do this entirely within Swagger.  First, find the Login service endpoint, fill in the parameters for username and password, and execute it.  You'll get a JWT that has a "Bearer" prefix, just as when we tested it earlier.  Each time you execute the login, you should get a slightly different JWT because the contents are slightly different each time due to the included timestamps.  Copy the JWT to the clipboard manually (including the "Bearer" at the beginning, all the way to the end, but leaving off the outermost quotation marks).  Then, click one of the lock icons to the right of any of the service endpoints, or click the Authorize button at the top of the Swagger page, and paste in the JWT token and click "login".  Now try out the GetSurveys service endpoint.  If you didn't perform this Swagger login, it should have returned with "Forbidden".  Once you login, the GetSurveys function executes as expected and you get..... an empty JSON result (which looks like an open and closed square bracket: []).  This is because we've not added any permissions, or surveys for that matter, in the default database initialization.  How anticlimactic!  But not to worry, we'll get to that soon enough.

A few more points before moving on.  First, this is how we'll structure virtually all of the other SurveyAdminClient service endpoints - we expect a JWT with a valid account value, and from there we just run whatever SQL we need to implement the function in question.  Often we'll pass in a few parameters.  And usually we'll return a value, either JSON or a string value, typically.   For such a simple application, this works fine.  More complex applications, with more complex data, can certainly pass more complex data types back and forth.  Any traditional Delphi objects work pretty well.  If you find that you have many parameters (more than a handful at most) it might also be an idea to combine them into a JSON representation and pass that back and forth, with a bit of extra steps to validate the JSON and ensure it has what you're expecting (at either end).  Generally, there are many ways to pass data back and forth from XData in this fashion.  Just be mindful that any data is being passed back and forth between a remote web server and a potentially under-powered browser with a  slow network connection, so limiting the amount of data traveling back and forth is generally good advice.

The second thing to touch on is that we've not really covered what the XData server is doing internally, and it is pretty remarkable.  Essentially, whenever someone connects to an XData service endpoint, a separate thread is launched to process the request.  You don't generally have to think much about that - the thread is launched when the request comes in, and is disposed of when the request is completed.  On the client end, something similar happens, where the request is generated and remains active until the XData server sends its response. This could be millseconds later, or several minutes, depending on what the service endpoint is actually doing.  And all of this happens in a VCL very small app.  The end result is a very performant application server that can easily scale to a very large number of users, with limitations usually arising from the underlying database technology rather than XData itself.

And finally, just a note about our project organization.  I've set up our three apps as entirely separate projects with nothing shared between them.  This was deliberate.  There are other ways to organize things. For example, sharing a few units that might allow you to reference the service endpoints directly from client apps.  Particularly if your clients are VCL apps themselves, like XData.  Or sharing CSS files.  Or any number of other things.  I find this to be the cleanest approach, but there are examples provided with XData and TMS WEB Core where there is no such separation - Delphi "projects" that contain multiple applications.

Now that we're clear on how to create service endpoints, we'll do a little hand-waving here and assume that we'll just create what we need as we continue along,  with the understanding that they're all very similar, just running different SQL queries with different parameters as needed. We'll also assume that any adjustments to the underlying SQL tables, columns and so on will be made to accommodate our needs as well.  Just to save some time here.  The code is available, so you can readily check that our hand-waving here is just a way to quickly get us to the next topic - back to the Survey Admin Client.


Survey Admin Client: Login

Alright.  Back in the Survey Admin Client, we can start implementing more useful features, rather than just fiddling with UI elements, despite how much fun that can be.  Continuing on with the idea of creating service endpoints as we need them, we'll make some assumptions here about functionality.  But we'll start with a few to give an idea of how this will work. First up?  We'll need the ability to log in.

In the first Labels post (mentioned previously), we created a separate popup form.  We're going to do the same here, but it will pop up immediately when the app starts.  This allows us to put all of that UI stuff into a separate unit, so it doesn't have to clutter up our main project. The login function will also be responsible for initializing the connection to the Survey Server (XData) and when it's done, we'll have what we need to proceed - a valid JWT.  A little extra effort on the UI here goes a long way toward setting the bar for the rest of the experience.  Here's what we've got for our Survey Admin Client's login page.


TMS Software Delphi  Components

Typical Login Interface.


Pretty standard fare here.  I have a habit of adding buttons that I think we'll use later, for better or for worse.  Here we've got a "Forgot" button and a "Register" button that we very likely won't get to today, but good to sort out where they'll go and what they look like while we're setting up everything else.  As one would expect, we've got a username/password field of some kind, with the password obscured.  Nothing special here, all implemented with the usual TMS WEB Core components with a dash of Bootstrap and a pinch of Font Awesome, as well as InteractJS just for fun.  And that's supposed to be a goldfish - short memory, right?? Hard to come up with icon ideas.  Don't get me started on the silliness of using a floppy disk icon to represent "save" for example...

The code for logging in is similar to the way we retrieved data in the Survey Client a while back. In this case, we're doing a bit more once we've got a valid JWT.  One of the more interesting things is that we're going to pull out a value from the list of JWT Claims - a new one that just came to mind - "security".  We'll use this to determine which of the main tabs is initially visible.  A JWT is composed of a set of three text blocks delimited by periods.  The middle text block contains Base64-encoded JSON which holds the claims. By extracting that text block and decoding the Base64 text, we get a JSON object that we can then extract the individual claims from. 

Typically you don't need to do this at the client, but we're doing it here to save the trouble of asking for the values in the claims separately.  We'll do the same for the email, first, and last name while we're at it, more new claims. This is also where you could extract the expiration timestamp from the list of claims and set a timer to automatically renew the JWT before it expires.  To renew a JWT, one approach is to just have another endpoint that takes the existing (valid) JWT, copies over the relevant claims and returns a new JWT.  No user intervention is required at all.

For our initial "setup" user, we'll set the "security" field to indicate that all tabs are available (aka a superuser) so that we can hit the ground running.  As soon as the tabs are set, we can also refresh the list of available surveys before setting the user loose into the rest of the app.  Here's what the Login function looks like.

procedure TLoginForm.btnLoginClick(Sender: TObject);
var
  Response: TXDataClientResponse;
  ClientConn:   TXDataWebClient;
  Data: JSValue;
  Blob: JSValue;
  JWTClaims: TJSONObject;
begin

  // Development server or Production server?
  if GetQueryParam('Dev') <> '' then
  begin
    Form1.ServerName := 'http://localhost:2001/tms/xdata';
    Form1.LogActivity('Development Mode Specified');
  end
  else
  begin
    Form1.ServerName := 'https://carnival.500foods.com:10101/tms/xdata';
  end;
  Form1.LogActivity('Connecting to '+Form1.ServerName);
  labelLoginProgress.Caption := 'Connecting to '+Form1.ServerName;


  // Try and establish a connection to the server
  if not(Form1.ServerConn.Connected) then
  begin
    Form1.ServerConn.URL := Form1.ServerName;
    try
      await(Form1.ServerConn.OpenAsync);
      Form1.LogActivity('Connected to '+Form1.ServerName);
      labelLoginProgress.Caption := 'Connected to '+Form1.ServerName;

    except on E: Exception do
      begin
        Form1.LogActivity('Connnection Error: ['+E.ClassName+'] '+E.Message);
        console.log('Connnection Error: ['+E.ClassName+'] '+E.Message);
        labelLoginProgress.Caption := 'Connection Failed. Please Try Again.';
      end;
    end;
  end;


  // We've got a connection, let's make the request
  Form1.LogActivity('Authorizing');
  labelLoginProgress.Caption := 'Authorizing';
  if (Form1.ServerConn.Connected) then
  begin
    try
      ClientConn := TXDataWebClient.Create(nil);
      ClientConn.Connection := Form1.ServerConn;
      Form1.JWT := '';
      Response := await(ClientConn.RawInvokeAsync('ISurveyAdminService.Login', [
        edtLoginEmail.Text,
        edtLoginPassword.Text
      ]));
      Blob := Response.Result;
      asm
        Data = Blob.value;
      end;
    except on E: Exception do
      begin
        Form1.LogActivity('Authorization Error: ['+E.ClassName+'] '+E.Message);
        console.log('Authorization Error: ['+E.ClassName+'] '+E.Message);
        labelLoginProgress.Caption := 'Authorization Error. Please Try Again.';
      end;
    end;
  end;


  // Do we have any data?
  if (Length(String(Data)) > 0) then
  begin
    // Yes we do!
    if Copy(String(Data),1,7) = 'Bearer ' then
    begin
      Form1.JWT := String(Data);
      Form1.LogActivity('Login Successful.');
      labelLoginProgress.Caption := 'Login Successful';

      // Get various claims values from the JWT
      JWTClaims := TJSONObject.ParseJSONValue(Window.atob(Copy(Form1.JWT, Pos('.',Form1.JWT)+1, LastDelimiter('.',Form1.JWT)-Pos('.',Form1.JWT)-1))) as TJSONObject;
      Form1.Account_ID := (JWTClaims.Get('account').JSONValue as TJSONString).Value;
      Form1.Account_EMail := (JWTClaims.Get('email').JSONValue as TJSONString).Value;
      Form1.Account_First := (JWTClaims.Get('first').JSONValue as TJSONString).Value;
      Form1.Account_Last := (JWTClaims.Get('last').JSONValue as TJSONString).Value;
      Form1.Account_Security := (JWTClaims.Get('security').JSONValue as TJSONString).Value;
      JWTClaims.Free;

      // Five elements in security field
      // 1st - Access to Surveys tab
      // 2rd - Accdss to Responses tab
      // 3nd - Access to Questions tab
      // 4th - Access to Administration tab
      // 5th - Access to Logging Tab

      if Copy(Form1.Account_Security,1,1) = 'N' then Form1.btnSurveys.ElementHandle.remove;
      if Copy(Form1.Account_Security,2,1) = 'N' then Form1.btnResponses.ElementHandle.remove;
      if Copy(Form1.Account_Security,3,1) = 'N' then Form1.btnQuestions.ElementHandle.remove;
      if Copy(Form1.Account_Security,4,1) = 'N' then Form1.btnAccounts.ElementHandle.remove;
      if Copy(Form1.Account_Security,5,1) = 'N' then Form1.btnLogging.ElementHandle.remove;

      if      Copy(Form1.Account_Security,1,1) <> 'N' then Form1.btnMainMenuClick(Form1.btnSurveys)
      else if Copy(Form1.Account_Security,2,1) <> 'N' then Form1.btnMainMenuClick(Form1.btnResponses)
      else if Copy(Form1.Account_Security,3,1) <> 'N' then Form1.btnMainMenuClick(Form1.btnQuestions)
      else if Copy(Form1.Account_Security,4,1) <> 'N' then Form1.btnMainMenuClick(Form1.btnAccounts)
      else if Copy(Form1.Account_Security,5,1) <> 'N' then Form1.btnMainMenuClick(Form1.btnLogging);

      // Refresh list of surveys
      Form1.BtnSurveyReloadClick(Sender);

      // Close Login Form and continue in main application
      ModalResult := mrOk;
      Form1.divBlocker.Visible := False;
    end
    else
    begin
      Form1.LogActivity('Incorrect E-Mail / Password. Please Try Again.');
      labelLoginProgress.Caption := 'Incorrect E-Mail or Password. Please Try Again.';
      edtLoginPassword.SetFocus;
      edtLoginPassword.SelectAll;
    end;
  end;

end;

That's a lot of code, but mostly due to being thorough and it is a bit repetitive in places. It should be readily apparent how easily the call to the Survey Server fits in.  If we get back a JWT (a string starting with 'Bearer") then we're off to the races.  Otherwise, we ask the user to try again.  There are plenty of other little tweaks that we're not going to go into detail about here, related to handling the enter key, having the background fade in via CSS and that sort of thing, so be sure to have a look at the project source code if you're interested.  One tip though - to "hide" a Bootstrap button from a button group and still have the rounding work properly, the button needs to be removed from the DOM completely.  Which is what we're doing here.

Once the user has logged in, we leave th popup (form), never to return.  One way to "logout" of a web app, after all, is to just reload the page.  Which is exactly what we do in this case, so no need to come back to the form when we're done logging in.  Which also means we don't have to figure out how to put back the buttons that were removed. If you'd like a logout function that doesn't reload the page, be sure to reset the JWT to a blank string.  This essentially logs the user out of the Survey Server, as without a valid JWT nothing can be accessed.  So resetting the JWT and clearing all the various Tabulator tables, entry fields and so on might be a workable approach, particularly if the app is usable in a logged-out state. We'll look at this in more detail in Part 2 of Labels and Barcodes, coming soon to a blog near you.  But for now, let's just keep adding features.


Survey Admin Client: Managing Surveys

We've already written the Survey Server code for returning a list of surveys that a user has access to.  Implementing the other end in the Survey Admin Client is just a matter of making the call and displaying the results.  There are at least a couple of ways we might want to trigger this action, such as after logging in or when clicking the "reload" button at the top of the survey list.  Let's use the reload button as the place to implement this.

procedure TForm1.btnSurveyReloadClick(Sender: TObject);
var
  Response: TXDataClientResponse;
  ClientConn:   TXDataWebClient;
  Data: JSValue;
  Blob: JSValue;
begin
  if (ServerConn.Connected) then
  begin
    try
      ClientConn := TXDataWebClient.Create(nil);
      ClientConn.Connection := ServerConn;
      Response := await(ClientConn.RawInvokeAsync('ISurveyAdminService.GetSurveys', []));
      Blob := Response.Result;
      asm
        Data = await Blob.text();
      end;
    except on E: Exception do
      begin
        Form1.LogActivity('GetSurveys Error: ['+E.ClassName+'] '+E.Message);
        console.log('GetSurveys Error: ['+E.ClassName+'] '+E.Message);
      end;
    end;
  end;

  // Do we have any data?
  if (Length(String(Data)) > 0) then
  begin
    // Load up tabSurveyList Tabulator with the new data
    asm
      this.tabSurveys.setData(Data);
    end;
  end;
end;

Nothing complicated about that, really.  Just retrieve the JSON and hand it off to Tabulator with the setData() call.  Don't even have to pass parameters to get our result set back, and all of the logic related to JWTs and the rest of it is hidden away out of sight.  We could even simplify this further by making a generic function to deal with the XData calls and conversion back to JSON.  Here we're calling a function with an open array of JSValue elements, roughly equivalent to an open variant array.  And all we're going to do with that is pass it along to the call to RawInvokeAsync, which has this same open array of JSValue elements as its second parameter.  This means that we can call various endpoints with whatever parameters we like, so long as it returns JSON destined for one of our Tabulator tables.  Which happens quite a lot.  Here's what our reload function looks like with this new function in place.

procedure TForm1.UpdateTable(Endpoint: String; Params: Array of JSValue; Table: JSValue);
var
  Response: TXDataClientResponse;
  ClientConn:   TXDataWebClient;
  Blob: JSValue;
begin
  if (ServerConn.Connected) then
  begin
    try
      ClientConn := TXDataWebClient.Create(nil);
      ClientConn.Connection := ServerConn;
      Response := await(ClientConn.RawInvokeAsync(Endpoint, Params));
      Blob := Response.Result;
      asm
        Table.setData(await Blob.text());
      end;
    except on E: Exception do
      begin
        Form1.LogActivity(Endpoint+' Error: ['+E.ClassName+'] '+E.Message);
        console.log(Endpoint+' Error: ['+E.ClassName+'] '+E.Message);
      end;
    end;
  end;
end;

procedure TForm1.btnSurveyReloadClick(Sender: TObject);
begin
  UpdateTable('ISurveyAdminService.GetSurveys', [], tabSurveys);
end;

That should simplify things quite a bit!  Much of the time, we'll send data to the server that ultimately translates into a CRUD operation of some kind against the database (Create, Read, Update, Delete).  Once the operation is complete, we'll want to refresh the UI with the new contents that reflect the impact of the CRUD operation.  Sometimes we can do it all in one call.  Or sometimes the result of such an operation results in changes to more than one UI element.  But either way, we're well-positioned in terms of keeping the UI and the underlying (remote) database consistent.

We've seen what a "read" operation looks like, but we can also quickly put together a pair of "create" functions and a "delete" function.  All we need to provide for a new survey is a SurveyID, a Name, a Group, and some JSON that defines the rest of the survey information.  We'll have the Survey Server return a fresh copy of the survey list, hopefully with our new survey included, and update the same table, just as we did with the reload function above.

The permissions in this case will be set automatically on the server, granting full permissions for this particular survey to the user that created it.  We don't really even need to pass much in the JSON for this part to work, though later we'll set some usable defaults, or in the case of the clone function, set the data to be the same as the currently selected survey.  The functions are largely the same.


procedure TForm1.btnSurveyNewClick(Sender: TObject);
var
  NewSurveyID: String;

begin
  NewSurveyID := TGUID.NewGUID.ToString;
  UpdateTable('ISurveyAdminService.NewSurvey', [NewSurveyID, 'New Survey', CurrentSurveyGroup, '[]'], tabSurveys);
end;

procedure TForm1.btnSurveyCloneClick(Sender: TObject);
var
  NewSurveyID: String;
begin
  NewSurveyID := TGUID.NewGUID.ToString;
  UpdateTable('ISurveyAdminService.CloneSurvey', [NewSurveyID, 'Clone of '+CurrentSurveyName, CurrentSurveyGroup, CurrentSurveyData], tabSurveys);
end;

procedure TForm1.btnSurveyDeleteClick(Sender: TObject);
begin
  UpdateTable('ISurveyAdminService.DeleteSurvey', [CurrentSurveyID], tabSurveys);
end;

With those in place, the main list of surveys is editable.  The "Current" variables shown in the functions above are kind of a lazy way of getting the Tabulator values from the table.  Whenever the row selection changes in Tabulator, these values are set so we don't have to look them up in Tabulator as that's a bit of a nuisance.  And when we're dealing with that first table, we're not really too worried about the underlying JSON that defines the table - in fact, we don't even retrieve it when updating the list of surveys.  This is because if there were hundreds of surveys that would be a huge amount of data.  Instead, we go and retrieve the survey only when a row is selected, using the SurveyID value we got originally.  Just trying to be more efficient.  And we're doing things the hard way when it comes to handling that JSON.  Basically, we're kind of using the JSON as a dataset, and populating components based on its contents.  And when we want to save changes, we'll read those component values and create a new JSON value to be written back.  Sort of like a clientdataset arrangement, but without using any db-aware components that we might use in the VCL.  Those exist in TMS WEB Core as well, if we wanted to approach it that way.

To finish up our "CRUD" operations, we'll then need to write back this JSON.  We keep a CurrentSurveyData and OriginalSurveyData just for this purpose, and there's a button in the UI that gets enabled/disabled based on whether these values are the same.  Connecting the data to the UI, we have an UpdateUIData function and a RetrieveUIData function.  These just parse the JSON and set the component values, or get the component values and build the JSON, respectively.


Connecting SQL to Tabulator

For much of the rest of the interface, what we're ultimately doing is connecting a grid of some kind (Tabulator in our case) to the contents of a query of some kind (which is ultimately a SQL query run against the SQLite database on the Survey Server).   We're using XData and JSON as a conduit between the grid and the query.  And we've already done the hard work on that conduit, so now it is just a matter of making the call and directing the JSON to the table.  Not so bad. 

Note that this is very similar (conceptually) to connecting a TQuery component to a TDBGrid using a TDataSource/TDataset as the conduit in a traditional VCL app, but is implemented very differently.  The TDataset approach is possible in TMS WEB Core, but as FireDAC is not this is a bit more work.  And as Tabulator doesn't know about anything but JSON, it is a bit more work again.  Ultimately bypassing all that and just streaming JSON everywhere seems to work really, really well.

For those not that familiar (or comfortable) with Tabulator, this might be a bridge too far.  But Tabulator does simplify a lot of things.  For example, the Survey list is created by adding a TWebHTMLDiv to a page and then calling Tabulator to populate it with a table.  Here's the code for the first cut of the Survey table.

  // Main Survey List
  asm
    this.tabSurveys = new Tabulator("#divSurveysSurveyList",{
      index: "survey_id",
      initialSort: [{column:"survey_name", dir:"asc"},{column:"survey_group", dir:"asc"}],
      groupBy: 'survey_group',
      headerVisible: false,
      columns: [
        { title: "ID", field: "survey_id", visible: false },
        { title: "Group", field: "survey_group", visible: false },
        { title: "Survey", field: "survey_name", bottomCalc: "count" },
        }
      ]
    });
  end;

And we can load the data coming from the Survey Server with a simple setData(JSON) call.  Makes things pretty simple.   As we get into more complex tables, we can add other columns with custom formatting and other niceties, but that's the basics of what we're up to here.  More tables.  More queries.  But the same basic principles.  As of this writing, there are more than a dozen Tabulator tables in the Survey Admin Client, all working the same way.  Just with different columns or other options set.

Now that we can display data from the Survey Server in a Tabulator table, much of the UI can be taken care of.  Tedious sometimes, but not particularly difficult, particularly once we set up the UpdateTable function as described.  We also have to deal with other UI elements, not just tables.  So while it would've been handy to have a TDataSource component and use TDBWeb controls, we don't have that many of them to worry about.  When a TWebEdit component is changed, for example, we can use the OnChange event handler to write back changes to the underlying JSON.   And when the underlying JSON changes, we update the relevant TWebEdit controls.  Sort of a manual approach, but works well enough.


Global Save and Cancel

One way to think about this approach is that we're essentially using JSON as the equivalent of a TClientDataset.  This means we're making changes to it, and we can tell whether it has changed from what it was originally.  For example, when editing the survey name (a TWebEdit component), the OnChange event will fire.  We then go and update the JSON with the new name.  And we compare it to the JSON that we had gotten originally.  If it is different, then we enable the big "Save" and "Cancel" buttons in the UI. If it is still the same, then we disable those same buttons.  This also means that if we change the name back to what it was originally, the JSON will then match the original JSON, and the buttons become disabled.  Not really anything remarkable about that.  Well, except that this works for every bit of data that is in the UI.  Every TWebEdit field, date field, and anything else, including other Tabulator tables that are included in the JSON that makes up the full survey. So that's kind of fun.

To elaborate a bit more on that, there are lots of things that are part of the JSON that describes a survey.  For example, the survey name and survey group.  But also the entire set of records that describe the availability for the survey. It is just unceremoniously included as another JSON object in the larger survey JSON.  So when you make a change to the date in the availability, that JSON is changed, which means the survey JSON changes, which means the Save button is enabled.  If you change the date back, the reverse happens.  Easy to conceptualize I suppose but it turned out to be a good test that things were wired up properly.  If the Save button didn't change, then there was some JSON somewhere that wasn't being updated.  Certainly a little bit more fun now!  In fact, we generate a change history log automatically just by making a note of what is different between sets of JSON. If the survey name has changed, we can make a note of that, and even log what the new name is. 

Some content is not included in the survey JSON, however.   In fact, there are all kinds of examples in this project related to how tables are accessed, whether they are related or not, whether they are included with calls to one endpoint, or whether you have to do extra work to get at them.  For the survey itself, the survey data only encapsulates what is needed to run the survey in the Survey Client application.  Availability is important for this, so it is included.  The change history that is created automatically is not included, so an extra call needs to be made to populate that table in the UI.  Similarly, there is a "Notes" table just for people to put in notes about what is happening with a particular survey. So, not included.  Same for permissions - these are handled outside of the survey JSON.  And, perhaps unexpectedly, so are the questions.  Because sometimes a survey is not yet available, so we want to keep the questions to ourselves.  We do include the questions JSON in the big "Save" and "Cancel" buttons, and add entries to the change log, just to try and keep the UI simple and consistent.


Editing HTML

When it comes to designing the pages of the survey, all we're really doing is presenting HTML pages.  We even display the radio buttons or checkmark buttons using HTML <input> tags in the HTML page.  Loading and saving these pages involves just converting them to text and storing them in JSON as usual.  Even uploaded images are stored directly in the page if needed, rather than referencing an image URL elsewhere.  Which is also an option.  In this project, we used SunEditor.  We previously covered Summernote vs. SunEditor.  These are both being updated continually, so either would be a solid choice.  I just happen to like SunEditor more (this week, anyway). 

When selecting an option from the Tabulator list, the TWebPageControl switches between the different pages, many of which have their own SunEditor control embedded.  It was structured this way in part because it makes it easier to copy/paste between different editors without having to find your place again - each editor just remains where it was as you flip between the different pages.  Here's what it looks like with an image loaded up.

TMS Software Delphi  Components
HTML Editor 

The underlying HTML code can also be directly edited and endlessly tweaked to get the final result desired.  And because HTML and CSS really need to be tested, a Preview function is available that makes it easy to see various page sizes and test that everything fits or scrolls properly.  

TMS Software Delphi  Components
Previewing Content

The preview tab also includes buttons for overriding the current state of the survey, so you can see what it looks like before, during, or after its availability windows.


QR Codes

If you're going to be publishing a survey, having a QR Code that provides a URL link to the survey is a handy thing to have.  Not particularly difficult either.  Here we use a component from the TMS FNC WX Pack to display the QR code.  A little fiddling with the colors and size, and we're done with it.  Might be an idea to expand the options to include a logo or customize the colors, but the basic function is there.  This is likely something that would just be copied and pasted into some advertising or another website, so not really a need for anything fancy either way. Scanning the code with a mobile device will drop you into the survey.  That's all we're after here.

TMS Software Delphi  Components
QR Code Ready for Scanning


TMS Software Delphi  Components
After QR Scan on iPhone


Custom Dropdown

Editing text fields?  Check.  Editing HTML content? Check.  That covers the bulk of what we need to do.  But sometimes you need something else.  In this case, there's a need to define a question type.  Maybe it's a question that just displays a static page of content, like a welcome page or a thank you page.  Or maybe it displays three choices and needs one of them to be selected.  Nothing mysterious, really.  But it would be helpful to have a list of these question types along with maybe an icon and a brief description of what the type does.  Maybe even a not-brief description. TMS WEB Core has a TWebDropDown component that allows us to display another component when it is selected.  So let's make a fancy combobox.  We'll use a Tabulator table for the list, and display it when someone wants to change the question type.

For the Tabulator part, we have something like this.  We define the contents explicitly (and we can re-use this elsewhere in the app).  Then load up a Tabulator with it.  I've left out most of the types for brevity here.


   asm
    var caret = '<i class="fa-solid fa-caret-right fa-fw mx-1"></i>';
    var border = 'style="border-radius:4px; padding:2px 1px; margin-left:8px !important; border-color:#000;"';
    var typedata = [
      { "ID": 2,
        "HINT": "Info",
        "ICON": "<i class='fa-solid fa-scroll ms-2 fa-fw'></i>",
        "DESC": "Static page",
        "INFO": caret+"<em>Prev:</em> Defaults to <em>Prev</em><br />"+
                caret+"<em>Next:</em> Defaults to <em>Next</em>"
      },
      { "ID": 9,
        "HINT": "Pick One",
        "ICON": "<i class='fa-solid fa-circle-check ms-2 fa-fw'></i>",
        "DESC": "Select exactly one <em>Item</em> from a list",
        "INFO": caret+"<em>Prev:</em> Defaults to <em>Prev</em><br />"+
                caret+"<em>Next:</em> Defaults to <em>Next</em>"
      }
    ];
    window.typedata = typedata;
  end;


  asm
    this.tabQuestionTypes = new Tabulator("#panelQuestionTypes",{
      data: typedata,
      headerVisible: false,
      columns: [
        { title: "ID", field: "ID", visible: false },
        { title: "ICON", field: "ICON", visible: true, formatter: "html", width: 43, minWidth: 43, maxWidth: 43 },
        { title: "HINT", field: "HINT", visible: true, width:100 },
        { title: "DESC", field: "DESC", visible: true, formatter: "html" },
        { title: "INFO", field: "INFO", visible: false }
      ]
    });
    this.tabQuestionTypes.on("rowClick", function(e,row){
      row.select();
      var id = row.getCell("ID").getValue();
      var icon = row.getCell("ICON").getValue();
      var hint = row.getCell("HINT").getValue().slice();
      var desc = row.getCell("DESC").getValue().slice();
      var info = row.getCell("INFO").getValue().slice();
      var qtable = pas.Unit1.Form1.tabQuestions;
      var qrows = qtable.getSelectedRows();
      if (qrows.length > 0) {
        var qid = qrows[0].getCell('question_id').getValue();
        pas.Unit1.Form1.NewQuestionTypeSelected(qid, id, icon, hint, desc, info, true);
      }
    });
  end;

With that in place, we can wire up the TWebDropDown component.  And it looks like this.  Pretty fancy!

TMS Software Delphi  Components
Custom DropDown for Question Type

The full description of the selected question type appears just above the HTML editor, which is defined in the table.  That description adjusts to fit whatever is selected.  An extra resize event had to be added to get it to resize when the number of lines changed, but that's the only instance of that being an issue.  Here, for example, the description for the "closing" page has an extra detail that a survey writer might want to know about.

TMS Software Delphi  Components
Extra Detail for Custom DropDown

And with that, we've got the pieces in place for creating everything that goes into the survey.  Sure, we skipped some of the work but not really any of the relevant parts.  Lots of copy & paste though.  The next task, then, is to actually work on the survey presentation.


Continue on to Part 3 of 3



Andrew Simard


Bookmarks: 

This blog post has not received any comments yet.



Add a new comment

You will receive a confirmation mail with a link to validate your comment, please use a valid email address.
All fields are required.



All Blog Posts  |  Next Post  |  Previous Post