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:
| Feature | Components Used |
|---|---|
| List all contacts | TTina4RESTRequest, FDMemTable, StringGrid |
| View contact detail | TTina4HTMLRender, TTina4Twig |
| Create new contact | TTina4HTMLRender (form), OnFormSubmit, TTina4REST.Post |
| Edit existing contact | TTina4HTMLRender (form), OnFormSubmit, TTina4REST.Patch |
| Delete contact | TTina4REST.Delete with confirmation |
| Search/filter | MemTable filtering or REST query params |
| Status messages | TTina4HTMLRender 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#
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/contacts | List all contacts |
| GET | /api/contacts?search=query | Search contacts |
| GET | /api/contacts/{id} | Get single contact |
| POST | /api/contacts | Create contact |
| PATCH | /api/contacts/{id} | Update contact |
| DELETE | /api/contacts/{id} | Delete contact |
Response Format#
List response:
{ "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:
{ "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:
{ "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):
| Component | Name | Purpose |
|---|---|---|
| TTina4REST | Tina4REST1 | Base REST configuration |
| TTina4RESTRequest | RESTRequestList | Fetch contact list |
| TTina4RESTRequest | RESTRequestDetail | Fetch single contact |
| TFDMemTable | MemTableContacts | Contact list data |
| TDataSource | DataSourceContacts | Binds MemTable to grid |
Visual components:
| Component | Name | Purpose |
|---|---|---|
| TLayout | LayoutMain | Main container |
| TLayout | LayoutLeft | Left panel (list) |
| TLayout | LayoutRight | Right panel (detail/form) |
| TSplitter | Splitter1 | Resizable divider |
| TStringGrid | GridContacts | Contact list |
| TEdit | EditSearch | Search input |
| TButton | ButtonSearch | Search button |
| TButton | ButtonNew | New contact button |
| TButton | ButtonRefresh | Refresh list button |
| TTina4HTMLRender | HTMLRenderDetail | Contact detail / form display |
| TLabel | LabelStatus | Status 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#
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#
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#
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#
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)#
<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#
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)#
<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#
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#
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:
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#
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:
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:
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;Clear Search#
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#
procedure TFormMain.SetStatus(const AMessage: string);begin LabelStatus.Text := FormatDateTime('hh:nn:ss', Now) + ' ' + AMessage;end;Cancel Form Navigation#
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#
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:
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.htmlExercise: Extend the Contact Manager#
Add these features to the contact manager:
- Categories/Tags -- Add a
categoryfield 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. - 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#
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:
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:
<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>