Tina4

Chapter 9: Building a CRUD Application#

A Contact Manager From Scratch#

No theory in this chapter. No isolated snippets. We are building a complete, working contact management application that uses every Tina4 Delphi component you have learned so far.

By the end, you will have:

  • A SQLite database storing contacts
  • A REST API backend (described, so you know what to build with Tina4 Python/PHP/Node.js/Ruby)
  • A contact list displayed in a StringGrid
  • A detail view rendered with HTML and Twig templates
  • Create, update, and delete operations via HTML forms
  • Search and filtering
  • Loading states and error handling

Every line of code in this chapter is part of the final application. Read it top to bottom and you will have a working app.


1. What We Are Building#

A contact management application with these features:

FeatureComponents Used
List all contactsTTina4RESTRequest, FDMemTable, StringGrid
View contact detailTTina4HTMLRender, TTina4Twig
Create new contactTTina4HTMLRender (form), OnFormSubmit, TTina4REST.Post
Edit existing contactTTina4HTMLRender (form), OnFormSubmit, TTina4REST.Patch
Delete contactTTina4REST.Delete with confirmation
Search/filterMemTable filtering or REST query params
Status messagesTTina4HTMLRender for toast-style feedback

2. The REST API#

Before building the Delphi client, here is the API it consumes. Build this with Tina4 Python, PHP, Node.js, or Ruby -- any backend that returns this JSON shape works.

Endpoints#

MethodEndpointDescription
GET/api/contactsList all contacts
GET/api/contacts?search=querySearch contacts
GET/api/contacts/{id}Get single contact
POST/api/contactsCreate contact
PATCH/api/contacts/{id}Update contact
DELETE/api/contacts/{id}Delete contact

Response Format#

List response:

json
{  "records": [    {      "id": "1",      "firstName": "Andre",      "lastName": "Van Zuydam",      "email": "andre@example.com",      "phone": "+27 21 555 0100",      "company": "Tina4 Stack",      "notes": "Framework creator",      "createdAt": "2026-01-15T10:30:00.000Z"    }  ],  "total": 42}

Single record response:

json
{  "id": "1",  "firstName": "Andre",  "lastName": "Van Zuydam",  "email": "andre@example.com",  "phone": "+27 21 555 0100",  "company": "Tina4 Stack",  "notes": "Framework creator",  "createdAt": "2026-01-15T10:30:00.000Z"}

Error response:

json
{  "error": "Contact not found",  "statusCode": 404}

3. Project Setup#

New FMX Project#

Create a new Multi-Device Application in Delphi. Save the project as ContactManager.dpr with the main form unit as MainForm.pas.

Form Components#

Drop these components on the form:

Data components (non-visual):

ComponentNamePurpose
TTina4RESTTina4REST1Base REST configuration
TTina4RESTRequestRESTRequestListFetch contact list
TTina4RESTRequestRESTRequestDetailFetch single contact
TFDMemTableMemTableContactsContact list data
TDataSourceDataSourceContactsBinds MemTable to grid

Visual components:

ComponentNamePurpose
TLayoutLayoutMainMain container
TLayoutLayoutLeftLeft panel (list)
TLayoutLayoutRightRight panel (detail/form)
TSplitterSplitter1Resizable divider
TStringGridGridContactsContact list
TEditEditSearchSearch input
TButtonButtonSearchSearch button
TButtonButtonNewNew contact button
TButtonButtonRefreshRefresh list button
TTina4HTMLRenderHTMLRenderDetailContact detail / form display
TLabelLabelStatusStatus bar

Layout Structure#

Form  LayoutMain (Align=Client)    LayoutLeft (Align=Left, Width=400)      EditSearch (Align=Top)      ButtonSearch (Align=Top)      ButtonNew (Align=Top)      ButtonRefresh (Align=Top)      GridContacts (Align=Client)    Splitter1 (Align=Left)    LayoutRight (Align=Client)      HTMLRenderDetail (Align=Client)    LabelStatus (Align=Bottom)

4. REST Configuration#

pascal
procedure TFormMain.FormCreate(Sender: TObject);begin  // Configure REST client  Tina4REST1.BaseUrl := 'https://api.example.com/v1';  // Tina4REST1.SetBearer('your-token-here');  // If auth requiredโ€‹  // Configure list request  RESTRequestList.Tina4REST := Tina4REST1;  RESTRequestList.EndPoint := '/api/contacts';  RESTRequestList.RequestType := TTina4RequestType.Get;  RESTRequestList.DataKey := 'records';  RESTRequestList.MemTable := MemTableContacts;  RESTRequestList.SyncMode := TTina4RestSyncMode.Clear;โ€‹  // Configure detail request  RESTRequestDetail.Tina4REST := Tina4REST1;  RESTRequestDetail.RequestType := TTina4RequestType.Get;  RESTRequestDetail.DataKey := '';โ€‹  // Configure grid columns  SetupGrid;โ€‹  // Configure HTML renderer  HTMLRenderDetail.TwigTemplatePath := ExtractFilePath(ParamStr(0)) + 'templates';  HTMLRenderDetail.OnFormSubmit := HandleFormSubmit;  HTMLRenderDetail.OnElementClick := HandleElementClick;  HTMLRenderDetail.RegisterObject('App', Self);โ€‹  // Load contacts  RefreshContacts;end;

5. Grid Setup#

pascal
procedure TFormMain.SetupGrid;begin  GridContacts.ColumnCount := 4;โ€‹  GridContacts.Columns[0].Header := 'Name';  GridContacts.Columns[0].Width := 150;โ€‹  GridContacts.Columns[1].Header := 'Email';  GridContacts.Columns[1].Width := 150;โ€‹  GridContacts.Columns[2].Header := 'Phone';  GridContacts.Columns[2].Width := 100;โ€‹  GridContacts.Columns[3].Header := 'Company';  GridContacts.Columns[3].Width := 100;โ€‹  GridContacts.OnCellClick := GridCellClick;end;

6. List Contacts#

Fetching and Displaying#

pascal
procedure TFormMain.RefreshContacts;begin  SetStatus('Loading contacts...');โ€‹  RESTRequestList.OnExecuteDone := procedure(Sender: TObject)  begin    TThread.Synchronize(nil, procedure    begin      PopulateGrid;      SetStatus(Format('Loaded %d contacts', [MemTableContacts.RecordCount]));    end);  end;โ€‹  RESTRequestList.ExecuteRESTCallAsync;end;โ€‹procedure TFormMain.PopulateGrid;begin  GridContacts.RowCount := MemTableContacts.RecordCount;โ€‹  MemTableContacts.First;  var Row := 0;โ€‹  while not MemTableContacts.Eof do  begin    var FirstName := MemTableContacts.FieldByName('first_name').AsString;    var LastName := MemTableContacts.FieldByName('last_name').AsString;โ€‹    GridContacts.Cells[0, Row] := FirstName + ' ' + LastName;    GridContacts.Cells[1, Row] := MemTableContacts.FieldByName('email').AsString;    GridContacts.Cells[2, Row] := MemTableContacts.FieldByName('phone').AsString;    GridContacts.Cells[3, Row] := MemTableContacts.FieldByName('company').AsString;โ€‹    MemTableContacts.Next;    Inc(Row);  end;end;โ€‹procedure TFormMain.ButtonRefreshClick(Sender: TObject);begin  RefreshContacts;end;

7. View Contact Detail#

When the user clicks a row in the grid, show the contact detail in the HTML renderer.

Grid Click Handler#

pascal
procedure TFormMain.GridCellClick(const Column: TColumn; const Row: Integer);begin  // Navigate to the selected record in MemTable  MemTableContacts.First;  MemTableContacts.MoveBy(Row);โ€‹  var ContactID := MemTableContacts.FieldByName('id').AsString;  ShowContactDetail(ContactID);end;

Contact Detail Template (templates/contact-detail.html)#

html
<div style="font-family: Arial, sans-serif; padding: 20px;">  <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">    <h2 style="color: #2c3e50; margin: 0;">{{ firstName }} {{ lastName }}</h2>    <div>      <button onclick="App:EditContact('{{ id }}')"              style="background: #3498db; color: white; border: none; padding: 8px 16px; border-radius: 4px; margin-right: 5px; cursor: pointer;">        Edit      </button>      <button onclick="App:DeleteContact('{{ id }}')"              style="background: #e74c3c; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">        Delete      </button>    </div>  </div>   <div style="background: #f9f9f9; border-radius: 8px; padding: 20px;">    <table style="width: 100%;">      <tr>        <td style="padding: 8px; color: #666; width: 120px;"><strong>Email</strong></td>        <td style="padding: 8px;">          {% if email %}            <a href="mailto:{{ email }}">{{ email }}</a>          {% else %}            <span style="color: #ccc;">Not set</span>          {% endif %}        </td>      </tr>      <tr>        <td style="padding: 8px; color: #666;"><strong>Phone</strong></td>        <td style="padding: 8px;">{{ phone|default('Not set') }}</td>      </tr>      <tr>        <td style="padding: 8px; color: #666;"><strong>Company</strong></td>        <td style="padding: 8px;">{{ company|default('Not set') }}</td>      </tr>      <tr>        <td style="padding: 8px; color: #666;"><strong>Notes</strong></td>        <td style="padding: 8px;">{{ notes|default('No notes')|nl2br }}</td>      </tr>      <tr>        <td style="padding: 8px; color: #666;"><strong>Created</strong></td>        <td style="padding: 8px;">{{ createdAt|date('F j, Y') }}</td>      </tr>    </table>  </div></div>

Show Detail#

pascal
procedure TFormMain.ShowContactDetail(const AContactID: string);var  StatusCode: Integer;  Response: TJSONObject;begin  SetStatus('Loading contact...');โ€‹  Response := Tina4REST1.Get(StatusCode, '/api/contacts/' + AContactID);  try    if StatusCode <> 200 then    begin      SetStatus('Error loading contact: HTTP ' + StatusCode.ToString);      Exit;    end;โ€‹    if not Assigned(Response) then    begin      SetStatus('Invalid response');      Exit;    end;โ€‹    // Set Twig variables from the JSON response    HTMLRenderDetail.SetTwigVariable('id', Response.GetValue<String>('id', ''));    HTMLRenderDetail.SetTwigVariable('firstName', Response.GetValue<String>('firstName', ''));    HTMLRenderDetail.SetTwigVariable('lastName', Response.GetValue<String>('lastName', ''));    HTMLRenderDetail.SetTwigVariable('email', Response.GetValue<String>('email', ''));    HTMLRenderDetail.SetTwigVariable('phone', Response.GetValue<String>('phone', ''));    HTMLRenderDetail.SetTwigVariable('company', Response.GetValue<String>('company', ''));    HTMLRenderDetail.SetTwigVariable('notes', Response.GetValue<String>('notes', ''));    HTMLRenderDetail.SetTwigVariable('createdAt', Response.GetValue<String>('createdAt', ''));โ€‹    // Render the template    HTMLRenderDetail.Twig.LoadFromFile(      ExtractFilePath(ParamStr(0)) + 'templates\contact-detail.html');โ€‹    SetStatus('Viewing: ' + Response.GetValue<String>('firstName', '') + ' ' +      Response.GetValue<String>('lastName', ''));  finally    Response.Free;  end;end;

8. Create Contact#

Contact Form Template (templates/contact-form.html)#

html
<div style="font-family: Arial, sans-serif; padding: 20px;">  <h2 style="color: #2c3e50;">    {% if id %}Edit Contact{% else %}New Contact{% endif %}  </h2>   <form name="contactForm">    {% if id %}      <input type="hidden" name="id" value="{{ id }}">    {% endif %}     <div style="margin-bottom: 15px;">      <label style="display: block; color: #666; margin-bottom: 4px;">First Name *</label>      <input type="text" name="firstName" value="{{ firstName|default('') }}"             class="form-control" placeholder="Enter first name"             style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">    </div>     <div style="margin-bottom: 15px;">      <label style="display: block; color: #666; margin-bottom: 4px;">Last Name *</label>      <input type="text" name="lastName" value="{{ lastName|default('') }}"             class="form-control" placeholder="Enter last name"             style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">    </div>     <div style="margin-bottom: 15px;">      <label style="display: block; color: #666; margin-bottom: 4px;">Email</label>      <input type="email" name="email" value="{{ email|default('') }}"             class="form-control" placeholder="name@example.com"             style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">    </div>     <div style="margin-bottom: 15px;">      <label style="display: block; color: #666; margin-bottom: 4px;">Phone</label>      <input type="text" name="phone" value="{{ phone|default('') }}"             class="form-control" placeholder="+27 21 555 0100"             style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">    </div>     <div style="margin-bottom: 15px;">      <label style="display: block; color: #666; margin-bottom: 4px;">Company</label>      <input type="text" name="company" value="{{ company|default('') }}"             class="form-control" placeholder="Company name"             style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">    </div>     <div style="margin-bottom: 20px;">      <label style="display: block; color: #666; margin-bottom: 4px;">Notes</label>      <textarea name="notes" rows="4" class="form-control"                placeholder="Additional notes..."                style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">{{ notes|default('') }}</textarea>    </div>     <div style="display: flex; gap: 10px;">      <button type="submit" class="btn btn-primary"              style="background: #1abc9c; color: white; border: none; padding: 10px 24px; border-radius: 4px; cursor: pointer;">        {% if id %}Save Changes{% else %}Create Contact{% endif %}      </button>      <button type="button" onclick="App:CancelForm()"              style="background: #95a5a6; color: white; border: none; padding: 10px 24px; border-radius: 4px; cursor: pointer;">        Cancel      </button>    </div>  </form></div>

Show Create Form#

pascal
procedure TFormMain.ButtonNewClick(Sender: TObject);begin  ShowContactForm('', '', '', '', '', '', '');end;โ€‹procedure TFormMain.ShowContactForm(const AID, AFirstName, ALastName,  AEmail, APhone, ACompany, ANotes: string);begin  HTMLRenderDetail.SetTwigVariable('id', AID);  HTMLRenderDetail.SetTwigVariable('firstName', AFirstName);  HTMLRenderDetail.SetTwigVariable('lastName', ALastName);  HTMLRenderDetail.SetTwigVariable('email', AEmail);  HTMLRenderDetail.SetTwigVariable('phone', APhone);  HTMLRenderDetail.SetTwigVariable('company', ACompany);  HTMLRenderDetail.SetTwigVariable('notes', ANotes);โ€‹  HTMLRenderDetail.Twig.LoadFromFile(    ExtractFilePath(ParamStr(0)) + 'templates\contact-form.html');โ€‹  if AID.IsEmpty then    SetStatus('Creating new contact...')  else    SetStatus('Editing contact...');end;

Handle Form Submission#

pascal
procedure TFormMain.HandleFormSubmit(Sender: TObject;  const FormName: string; FormData: TStrings);var  StatusCode: Integer;  Response: TJSONObject;  ContactID: string;  Body: TJSONObject;begin  if FormName <> 'contactForm' then Exit;โ€‹  // Validate required fields  var FirstName := FormData.Values['firstName'].Trim;  var LastName := FormData.Values['lastName'].Trim;โ€‹  if FirstName.IsEmpty or LastName.IsEmpty then  begin    ShowMessage('First name and last name are required');    Exit;  end;โ€‹  ContactID := FormData.Values['id'];โ€‹  // Build the request body  Body := TJSONObject.Create;  try    Body.AddPair('firstName', FirstName);    Body.AddPair('lastName', LastName);    Body.AddPair('email', FormData.Values['email'].Trim);    Body.AddPair('phone', FormData.Values['phone'].Trim);    Body.AddPair('company', FormData.Values['company'].Trim);    Body.AddPair('notes', FormData.Values['notes'].Trim);โ€‹    if ContactID.IsEmpty then    begin      // CREATE      SetStatus('Creating contact...');      Response := Tina4REST1.Post(StatusCode, '/api/contacts', '', Body.ToString);    end    else    begin      // UPDATE      SetStatus('Updating contact...');      Response := Tina4REST1.Patch(StatusCode, '/api/contacts/' + ContactID, '', Body.ToString);    end;  finally    Body.Free;  end;โ€‹  try    if StatusCode in [200, 201] then    begin      if ContactID.IsEmpty then        SetStatus('Contact created successfully')      else        SetStatus('Contact updated successfully');โ€‹      // Refresh the list and show the detail      RefreshContacts;โ€‹      if Assigned(Response) then      begin        var NewID := Response.GetValue<String>('id', ContactID);        ShowContactDetail(NewID);      end;    end    else    begin      var ErrorMsg := 'Unknown error';      if Assigned(Response) then        ErrorMsg := Response.GetValue<String>('error', ErrorMsg);      SetStatus('Error: ' + ErrorMsg);      ShowMessage('Failed to save contact: ' + ErrorMsg);    end;  finally    Response.Free;  end;end;

9. Update Contact#

Edit Button Handler#

The Edit button in the detail template uses RTTI to call EditContact:

pascal
procedure TFormMain.EditContact(const AID: string);var  StatusCode: Integer;  Response: TJSONObject;begin  // Fetch the current contact data  Response := Tina4REST1.Get(StatusCode, '/api/contacts/' + AID);  try    if (StatusCode = 200) and Assigned(Response) then    begin      ShowContactForm(        Response.GetValue<String>('id', ''),        Response.GetValue<String>('firstName', ''),        Response.GetValue<String>('lastName', ''),        Response.GetValue<String>('email', ''),        Response.GetValue<String>('phone', ''),        Response.GetValue<String>('company', ''),        Response.GetValue<String>('notes', ''));    end    else      ShowMessage('Could not load contact for editing');  finally    Response.Free;  end;end;

The HandleFormSubmit method already handles both create and update -- it checks whether id is present in the form data.


10. Delete Contact#

Delete with Confirmation#

pascal
procedure TFormMain.DeleteContact(const AID: string);begin  // Confirm before deleting  MessageDlg('Are you sure you want to delete this contact?',    TMsgDlgType.mtConfirmation, [TMsgDlgBtn.mbYes, TMsgDlgBtn.mbNo], 0,    procedure(const AResult: TModalResult)    var      StatusCode: Integer;      Response: TJSONObject;    begin      if AResult <> mrYes then Exit;โ€‹      SetStatus('Deleting contact...');โ€‹      Response := Tina4REST1.Delete(StatusCode, '/api/contacts/' + AID);      try        if StatusCode in [200, 204] then        begin          SetStatus('Contact deleted');          RefreshContacts;          ShowEmptyState;        end        else        begin          var ErrorMsg := 'Delete failed';          if Assigned(Response) then            ErrorMsg := Response.GetValue<String>('error', ErrorMsg);          SetStatus('Error: ' + ErrorMsg);          ShowMessage(ErrorMsg);        end;      finally        Response.Free;      end;    end);end;โ€‹procedure TFormMain.ShowEmptyState;begin  HTMLRenderDetail.HTML.Text :=    '<div style="font-family: Arial, sans-serif; padding: 40px; text-align: center; color: #999;">' +    '  <h2>No Contact Selected</h2>' +    '  <p>Select a contact from the list or create a new one.</p>' +    '</div>';end;

11. Search and Filter#

Option 1: Filter MemTable Locally#

For small datasets, filter the existing MemTable without making another API call:

pascal
procedure TFormMain.ButtonSearchClick(Sender: TObject);var  SearchTerm: string;begin  SearchTerm := EditSearch.Text.Trim.ToLower;โ€‹  if SearchTerm.IsEmpty then  begin    MemTableContacts.Filtered := False;    PopulateGrid;    SetStatus(Format('Showing all %d contacts', [MemTableContacts.RecordCount]));    Exit;  end;โ€‹  MemTableContacts.OnFilterRecord := procedure(DataSet: TDataSet;    var Accept: Boolean)  begin    var FirstName := DataSet.FieldByName('first_name').AsString.ToLower;    var LastName := DataSet.FieldByName('last_name').AsString.ToLower;    var Email := DataSet.FieldByName('email').AsString.ToLower;    var Company := DataSet.FieldByName('company').AsString.ToLower;โ€‹    Accept := FirstName.Contains(SearchTerm) or              LastName.Contains(SearchTerm) or              Email.Contains(SearchTerm) or              Company.Contains(SearchTerm);  end;โ€‹  MemTableContacts.Filtered := True;  PopulateGrid;  SetStatus(Format('Found %d matching contacts', [MemTableContacts.RecordCount]));end;

Option 2: Search via API#

For large datasets, send the search term to the API:

pascal
procedure TFormMain.SearchViaAPI(const ASearchTerm: string);begin  if ASearchTerm.Trim.IsEmpty then    RESTRequestList.EndPoint := '/api/contacts'  else    RESTRequestList.EndPoint := '/api/contacts?search=' + ASearchTerm.Trim;โ€‹  RESTRequestList.OnExecuteDone := procedure(Sender: TObject)  begin    TThread.Synchronize(nil, procedure    begin      PopulateGrid;      SetStatus(Format('Found %d contacts for "%s"',        [MemTableContacts.RecordCount, ASearchTerm]));    end);  end;โ€‹  RESTRequestList.ExecuteRESTCallAsync;end;
pascal
procedure TFormMain.EditSearchKeyDown(Sender: TObject; var Key: Word;  var KeyChar: Char; Shift: TShiftState);begin  if Key = vkReturn then    ButtonSearchClick(Sender)  else if Key = vkEscape then  begin    EditSearch.Text := '';    MemTableContacts.Filtered := False;    PopulateGrid;  end;end;

12. Polish: Status, Loading, and Error Handling#

Status Bar#

pascal
procedure TFormMain.SetStatus(const AMessage: string);begin  LabelStatus.Text := FormatDateTime('hh:nn:ss', Now) + '  ' + AMessage;end;

Cancel Form Navigation#

pascal
procedure TFormMain.CancelForm;begin  // If a contact was selected before, show its detail again  if not MemTableContacts.IsEmpty then  begin    var ContactID := MemTableContacts.FieldByName('id').AsString;    ShowContactDetail(ContactID);  end  else    ShowEmptyState;end;

Error Handling Wrapper#

pascal
procedure TFormMain.SafeAPICall(AProc: TProc);begin  try    AProc();  except    on E: Exception do    begin      SetStatus('Error: ' + E.Message);      ShowMessage('An error occurred: ' + E.Message);    end;  end;end;โ€‹// Usage:procedure TFormMain.ButtonRefreshClick(Sender: TObject);begin  SafeAPICall(procedure  begin    RefreshContacts;  end);end;

13. Full Source Code#

Here is the complete main form unit:

pascal
unit MainForm;โ€‹interfaceโ€‹uses  System.SysUtils, System.Types, System.Classes, System.JSON,  FMX.Types, FMX.Controls, FMX.Forms, FMX.StdCtrls, FMX.Edit,  FMX.Layouts, FMX.Grid, FMX.Grid.Style, FMX.ScrollBox,  FMX.Dialogs,  FireDAC.Comp.Client, FireDAC.Stan.Intf,  Data.DB,  Tina4REST, Tina4RESTRequest, Tina4HTMLRender, Tina4Core;โ€‹type  TFormMain = class(TForm)    LayoutMain: TLayout;    LayoutLeft: TLayout;    LayoutRight: TLayout;    Splitter1: TSplitter;    GridContacts: TStringGrid;    EditSearch: TEdit;    ButtonSearch: TButton;    ButtonNew: TButton;    ButtonRefresh: TButton;    HTMLRenderDetail: TTina4HTMLRender;    LabelStatus: TLabel;    Tina4REST1: TTina4REST;    RESTRequestList: TTina4RESTRequest;    RESTRequestDetail: TTina4RESTRequest;    MemTableContacts: TFDMemTable;    DataSourceContacts: TDataSource;โ€‹    procedure FormCreate(Sender: TObject);    procedure ButtonRefreshClick(Sender: TObject);    procedure ButtonNewClick(Sender: TObject);    procedure ButtonSearchClick(Sender: TObject);    procedure EditSearchKeyDown(Sender: TObject; var Key: Word;      var KeyChar: Char; Shift: TShiftState);  private    procedure SetupGrid;    procedure RefreshContacts;    procedure PopulateGrid;    procedure SetStatus(const AMessage: string);    procedure ShowEmptyState;    procedure ShowContactDetail(const AContactID: string);    procedure ShowContactForm(const AID, AFirstName, ALastName,      AEmail, APhone, ACompany, ANotes: string);    procedure HandleFormSubmit(Sender: TObject;      const FormName: string; FormData: TStrings);    procedure HandleElementClick(Sender: TObject;      const ObjectName, MethodName: string; Params: TStrings);    procedure GridCellClick(const Column: TColumn; const Row: Integer);  public    procedure EditContact(const AID: string);    procedure DeleteContact(const AID: string);    procedure CancelForm;  end;โ€‹var  FormMain: TFormMain;โ€‹implementationโ€‹{$R *.fmx}โ€‹procedure TFormMain.FormCreate(Sender: TObject);begin  // REST configuration  Tina4REST1.BaseUrl := 'https://api.example.com/v1';โ€‹  RESTRequestList.Tina4REST := Tina4REST1;  RESTRequestList.EndPoint := '/api/contacts';  RESTRequestList.RequestType := TTina4RequestType.Get;  RESTRequestList.DataKey := 'records';  RESTRequestList.MemTable := MemTableContacts;  RESTRequestList.SyncMode := TTina4RestSyncMode.Clear;โ€‹  RESTRequestDetail.Tina4REST := Tina4REST1;  RESTRequestDetail.RequestType := TTina4RequestType.Get;โ€‹  // Grid  SetupGrid;โ€‹  // HTML Renderer  HTMLRenderDetail.TwigTemplatePath := ExtractFilePath(ParamStr(0)) + 'templates';  HTMLRenderDetail.OnFormSubmit := HandleFormSubmit;  HTMLRenderDetail.RegisterObject('App', Self);โ€‹  // Initial state  ShowEmptyState;  RefreshContacts;end;โ€‹procedure TFormMain.SetupGrid;begin  GridContacts.ColumnCount := 4;  GridContacts.Columns[0].Header := 'Name';  GridContacts.Columns[0].Width := 150;  GridContacts.Columns[1].Header := 'Email';  GridContacts.Columns[1].Width := 130;  GridContacts.Columns[2].Header := 'Phone';  GridContacts.Columns[2].Width := 100;  GridContacts.Columns[3].Header := 'Company';  GridContacts.Columns[3].Width := 100;  GridContacts.OnCellClick := GridCellClick;end;โ€‹procedure TFormMain.RefreshContacts;begin  SetStatus('Loading contacts...');โ€‹  RESTRequestList.OnExecuteDone := procedure(Sender: TObject)  begin    TThread.Synchronize(nil, procedure    begin      PopulateGrid;      SetStatus(Format('Loaded %d contacts', [MemTableContacts.RecordCount]));    end);  end;โ€‹  RESTRequestList.ExecuteRESTCallAsync;end;โ€‹procedure TFormMain.PopulateGrid;begin  GridContacts.RowCount := MemTableContacts.RecordCount;  MemTableContacts.First;  var Row := 0;โ€‹  while not MemTableContacts.Eof do  begin    GridContacts.Cells[0, Row] :=      MemTableContacts.FieldByName('first_name').AsString + ' ' +      MemTableContacts.FieldByName('last_name').AsString;    GridContacts.Cells[1, Row] := MemTableContacts.FieldByName('email').AsString;    GridContacts.Cells[2, Row] := MemTableContacts.FieldByName('phone').AsString;    GridContacts.Cells[3, Row] := MemTableContacts.FieldByName('company').AsString;โ€‹    MemTableContacts.Next;    Inc(Row);  end;end;โ€‹procedure TFormMain.GridCellClick(const Column: TColumn; const Row: Integer);begin  MemTableContacts.First;  MemTableContacts.MoveBy(Row);  var ContactID := MemTableContacts.FieldByName('id').AsString;  ShowContactDetail(ContactID);end;โ€‹procedure TFormMain.ShowContactDetail(const AContactID: string);var  StatusCode: Integer;  Response: TJSONObject;begin  SetStatus('Loading contact...');  Response := Tina4REST1.Get(StatusCode, '/api/contacts/' + AContactID);  try    if (StatusCode = 200) and Assigned(Response) then    begin      HTMLRenderDetail.SetTwigVariable('id', Response.GetValue<String>('id', ''));      HTMLRenderDetail.SetTwigVariable('firstName', Response.GetValue<String>('firstName', ''));      HTMLRenderDetail.SetTwigVariable('lastName', Response.GetValue<String>('lastName', ''));      HTMLRenderDetail.SetTwigVariable('email', Response.GetValue<String>('email', ''));      HTMLRenderDetail.SetTwigVariable('phone', Response.GetValue<String>('phone', ''));      HTMLRenderDetail.SetTwigVariable('company', Response.GetValue<String>('company', ''));      HTMLRenderDetail.SetTwigVariable('notes', Response.GetValue<String>('notes', ''));      HTMLRenderDetail.SetTwigVariable('createdAt', Response.GetValue<String>('createdAt', ''));โ€‹      HTMLRenderDetail.Twig.LoadFromFile(        ExtractFilePath(ParamStr(0)) + 'templates\contact-detail.html');โ€‹      SetStatus('Viewing: ' + Response.GetValue<String>('firstName', '') + ' ' +        Response.GetValue<String>('lastName', ''));    end    else      SetStatus('Error loading contact');  finally    Response.Free;  end;end;โ€‹procedure TFormMain.ShowContactForm(const AID, AFirstName, ALastName,  AEmail, APhone, ACompany, ANotes: string);begin  HTMLRenderDetail.SetTwigVariable('id', AID);  HTMLRenderDetail.SetTwigVariable('firstName', AFirstName);  HTMLRenderDetail.SetTwigVariable('lastName', ALastName);  HTMLRenderDetail.SetTwigVariable('email', AEmail);  HTMLRenderDetail.SetTwigVariable('phone', APhone);  HTMLRenderDetail.SetTwigVariable('company', ACompany);  HTMLRenderDetail.SetTwigVariable('notes', ANotes);โ€‹  HTMLRenderDetail.Twig.LoadFromFile(    ExtractFilePath(ParamStr(0)) + 'templates\contact-form.html');โ€‹  if AID.IsEmpty then    SetStatus('Creating new contact...')  else    SetStatus('Editing contact...');end;โ€‹procedure TFormMain.ButtonNewClick(Sender: TObject);begin  ShowContactForm('', '', '', '', '', '', '');end;โ€‹procedure TFormMain.HandleFormSubmit(Sender: TObject;  const FormName: string; FormData: TStrings);var  StatusCode: Integer;  Response: TJSONObject;  ContactID: string;  Body: TJSONObject;begin  if FormName <> 'contactForm' then Exit;โ€‹  var FirstName := FormData.Values['firstName'].Trim;  var LastName := FormData.Values['lastName'].Trim;โ€‹  if FirstName.IsEmpty or LastName.IsEmpty then  begin    ShowMessage('First name and last name are required');    Exit;  end;โ€‹  ContactID := FormData.Values['id'];  Body := TJSONObject.Create;  try    Body.AddPair('firstName', FirstName);    Body.AddPair('lastName', LastName);    Body.AddPair('email', FormData.Values['email'].Trim);    Body.AddPair('phone', FormData.Values['phone'].Trim);    Body.AddPair('company', FormData.Values['company'].Trim);    Body.AddPair('notes', FormData.Values['notes'].Trim);โ€‹    if ContactID.IsEmpty then    begin      SetStatus('Creating contact...');      Response := Tina4REST1.Post(StatusCode, '/api/contacts', '', Body.ToString);    end    else    begin      SetStatus('Saving changes...');      Response := Tina4REST1.Patch(StatusCode, '/api/contacts/' + ContactID, '', Body.ToString);    end;  finally    Body.Free;  end;โ€‹  try    if StatusCode in [200, 201] then    begin      SetStatus(IfThen(ContactID.IsEmpty, 'Contact created', 'Contact updated'));      RefreshContacts;      if Assigned(Response) then        ShowContactDetail(Response.GetValue<String>('id', ContactID));    end    else    begin      var ErrorMsg := 'Save failed';      if Assigned(Response) then        ErrorMsg := Response.GetValue<String>('error', ErrorMsg);      SetStatus('Error: ' + ErrorMsg);      ShowMessage(ErrorMsg);    end;  finally    Response.Free;  end;end;โ€‹procedure TFormMain.HandleElementClick(Sender: TObject;  const ObjectName, MethodName: string; Params: TStrings);begin  // RTTI-based onclick routing is handled automatically  // This handler is available for custom processing if neededend;โ€‹procedure TFormMain.EditContact(const AID: string);var  StatusCode: Integer;  Response: TJSONObject;begin  Response := Tina4REST1.Get(StatusCode, '/api/contacts/' + AID);  try    if (StatusCode = 200) and Assigned(Response) then      ShowContactForm(        Response.GetValue<String>('id', ''),        Response.GetValue<String>('firstName', ''),        Response.GetValue<String>('lastName', ''),        Response.GetValue<String>('email', ''),        Response.GetValue<String>('phone', ''),        Response.GetValue<String>('company', ''),        Response.GetValue<String>('notes', ''))    else      ShowMessage('Could not load contact');  finally    Response.Free;  end;end;โ€‹procedure TFormMain.DeleteContact(const AID: string);begin  MessageDlg('Delete this contact?', TMsgDlgType.mtConfirmation,    [TMsgDlgBtn.mbYes, TMsgDlgBtn.mbNo], 0,    procedure(const AResult: TModalResult)    var      StatusCode: Integer;      Response: TJSONObject;    begin      if AResult <> mrYes then Exit;โ€‹      SetStatus('Deleting...');      Response := Tina4REST1.Delete(StatusCode, '/api/contacts/' + AID);      try        if StatusCode in [200, 204] then        begin          SetStatus('Contact deleted');          RefreshContacts;          ShowEmptyState;        end        else          SetStatus('Delete failed');      finally        Response.Free;      end;    end);end;โ€‹procedure TFormMain.CancelForm;begin  if not MemTableContacts.IsEmpty then    ShowContactDetail(MemTableContacts.FieldByName('id').AsString)  else    ShowEmptyState;end;โ€‹procedure TFormMain.ButtonSearchClick(Sender: TObject);var  SearchTerm: string;begin  SearchTerm := EditSearch.Text.Trim.ToLower;โ€‹  if SearchTerm.IsEmpty then  begin    MemTableContacts.Filtered := False;    PopulateGrid;    SetStatus(Format('Showing all %d contacts', [MemTableContacts.RecordCount]));    Exit;  end;โ€‹  MemTableContacts.OnFilterRecord := procedure(DataSet: TDataSet; var Accept: Boolean)  begin    Accept :=      DataSet.FieldByName('first_name').AsString.ToLower.Contains(SearchTerm) or      DataSet.FieldByName('last_name').AsString.ToLower.Contains(SearchTerm) or      DataSet.FieldByName('email').AsString.ToLower.Contains(SearchTerm) or      DataSet.FieldByName('company').AsString.ToLower.Contains(SearchTerm);  end;โ€‹  MemTableContacts.Filtered := True;  PopulateGrid;  SetStatus(Format('Found %d contacts for "%s"', [MemTableContacts.RecordCount, SearchTerm]));end;โ€‹procedure TFormMain.EditSearchKeyDown(Sender: TObject; var Key: Word;  var KeyChar: Char; Shift: TShiftState);begin  if Key = vkReturn then    ButtonSearchClick(Sender)  else if Key = vkEscape then  begin    EditSearch.Text := '';    MemTableContacts.Filtered := False;    PopulateGrid;  end;end;โ€‹procedure TFormMain.ShowEmptyState;begin  HTMLRenderDetail.HTML.Text :=    '<div style="font-family: Arial, sans-serif; padding: 40px; text-align: center; color: #999;">' +    '  <h2>No Contact Selected</h2>' +    '  <p>Select a contact from the list, or click "New" to create one.</p>' +    '</div>';end;โ€‹procedure TFormMain.SetStatus(const AMessage: string);begin  LabelStatus.Text := FormatDateTime('hh:nn:ss', Now) + '  ' + AMessage;end;โ€‹end.

14. Template Files#

Create a templates folder next to your executable. Place contact-detail.html and contact-form.html there (shown in sections 7 and 8 above).

ContactManager.exetemplates/  contact-detail.html  contact-form.html

Exercise: Extend the Contact Manager#

Add these features to the contact manager:

  1. Categories/Tags -- Add a category field to contacts (e.g., "Work", "Personal", "Client"). Add a dropdown filter above the grid to filter by category. Add a category select to the form.
  2. Export to CSV -- Add an "Export" button that saves the current contact list (filtered or unfiltered) to a CSV file using TSaveDialog.

Solution: CSV Export#

pascal
procedure TFormMain.ButtonExportClick(Sender: TObject);var  SaveDialog: TSaveDialog;  CSV: TStringList;begin  SaveDialog := TSaveDialog.Create(Self);  CSV := TStringList.Create;  try    SaveDialog.Filter := 'CSV files|*.csv';    SaveDialog.DefaultExt := 'csv';    SaveDialog.FileName := 'contacts_' + FormatDateTime('yyyymmdd', Now) + '.csv';โ€‹    if not SaveDialog.Execute then Exit;โ€‹    // Header row    CSV.Add('"First Name","Last Name","Email","Phone","Company","Notes"');โ€‹    // Data rows    MemTableContacts.First;    while not MemTableContacts.Eof do    begin      CSV.Add(Format('"%s","%s","%s","%s","%s","%s"', [        MemTableContacts.FieldByName('first_name').AsString.Replace('"', '""'),        MemTableContacts.FieldByName('last_name').AsString.Replace('"', '""'),        MemTableContacts.FieldByName('email').AsString.Replace('"', '""'),        MemTableContacts.FieldByName('phone').AsString.Replace('"', '""'),        MemTableContacts.FieldByName('company').AsString.Replace('"', '""'),        MemTableContacts.FieldByName('notes').AsString.Replace('"', '""')      ]));โ€‹      MemTableContacts.Next;    end;โ€‹    CSV.SaveToFile(SaveDialog.FileName, TEncoding.UTF8);    SetStatus(Format('Exported %d contacts to %s',      [MemTableContacts.RecordCount, ExtractFileName(SaveDialog.FileName)]));  finally    CSV.Free;    SaveDialog.Free;  end;end;

Solution: Category Filter#

Add a TComboBox named ComboCategory above the grid:

pascal
procedure TFormMain.FormCreate(Sender: TObject);begin  // ... existing setup ...โ€‹  ComboCategory.Items.Add('All Categories');  ComboCategory.Items.Add('Work');  ComboCategory.Items.Add('Personal');  ComboCategory.Items.Add('Client');  ComboCategory.Items.Add('Vendor');  ComboCategory.ItemIndex := 0;  ComboCategory.OnChange := ComboCategoryChange;end;โ€‹procedure TFormMain.ComboCategoryChange(Sender: TObject);begin  if ComboCategory.ItemIndex = 0 then  begin    MemTableContacts.Filtered := False;    PopulateGrid;    SetStatus(Format('Showing all %d contacts', [MemTableContacts.RecordCount]));  end  else  begin    var Category := ComboCategory.Items[ComboCategory.ItemIndex];โ€‹    MemTableContacts.OnFilterRecord := procedure(DataSet: TDataSet; var Accept: Boolean)    begin      Accept := DataSet.FieldByName('category').AsString = Category;    end;โ€‹    MemTableContacts.Filtered := True;    PopulateGrid;    SetStatus(Format('Showing %d "%s" contacts',      [MemTableContacts.RecordCount, Category]));  end;end;

Add the category field to contact-form.html:

html
<div style="margin-bottom: 15px;">  <label style="display: block; color: #666; margin-bottom: 4px;">Category</label>  <select name="category" class="form-control"          style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">    <option value="">-- Select --</option>    <option value="Work" {% if category == 'Work' %}selected{% endif %}>Work</option>    <option value="Personal" {% if category == 'Personal' %}selected{% endif %}>Personal</option>    <option value="Client" {% if category == 'Client' %}selected{% endif %}>Client</option>    <option value="Vendor" {% if category == 'Vendor' %}selected{% endif %}>Vendor</option>  </select></div>