Tina4

Chapter 12: Building a Complete Application#

The Admin Dashboard#

Picture the screen. A login form rendered in HTML with styled inputs and a submit button. You type credentials, click Login, and the form posts to an API endpoint. The bearer token comes back. The dashboard appears -- stat cards showing user counts and active sessions, a sidebar menu, a user table with search and pagination. A WebSocket connection pushes live notifications. A red badge increments on the bell icon.

This chapter builds that application from scratch using every Tina4 Delphi component: TTina4REST, TTina4RESTRequest, TTina4HTMLRender, TTina4HTMLPages, TTina4Twig, TTina4JSONAdapter, and TTina4WebSocketClient. By the end, you will have a complete, working desktop admin dashboard -- and you will have seen how all the pieces from previous chapters fit together in a real application.


1. What We Are Building#

The admin dashboard has four pages:

  1. Login -- HTML form, POST to API, store bearer token
  2. Dashboard -- Stat cards with user count, active sessions, recent activity
  3. Users -- List, search, create, edit, delete users via REST
  4. Settings -- Application configuration form

Plus cross-cutting concerns:

  • Authentication -- Bearer token stored in TTina4REST, checked on every page
  • WebSocket notifications -- Live updates pushed from the server
  • Error handling -- Network errors, expired tokens, validation failures

2. Project Setup#

Create a new FMX application in Delphi. Name the project AdminDashboard.

File Structure#

AdminDashboard/  AdminDashboard.dpr         -- Project file  MainUnit.pas               -- Main form with all components  MainUnit.fmx               -- FMX form definition  templates/    login.html               -- Login page template    dashboard.html           -- Dashboard page template    users.html               -- Users list template    user-detail.html         -- User detail/edit template    settings.html            -- Settings page template    layout.html              -- Shared layout with sidebar

The Main Form#

Drop these components on the form:

ComponentNamePurpose
TTina4RESTRESTBase URL and authentication
TTina4RESTRequestRESTUsersFetch user list
TTina4RESTRequestRESTStatsFetch dashboard stats
TFDMemTableMemUsersStore user data
TFDMemTableMemStatsStore stats data
TTina4HTMLRenderRendererDisplay HTML content
TTina4HTMLPagesPagesPage navigation
TDataSourceDSUsersBridge MemTable to data-aware controls

Project File#

pascal
// AdminDashboard.dprprogram AdminDashboard;โ€‹uses  FMX.Forms,  MainUnit in 'MainUnit.pas' {FormMain};โ€‹{$R *.res}โ€‹begin  Application.Initialize;  Application.CreateForm(TFormMain, FormMain);  Application.Run;end.

3. Main Unit -- Declarations#

pascal
unit MainUnit;โ€‹interfaceโ€‹uses  System.SysUtils, System.Classes, System.JSON, System.IOUtils,  System.Generics.Collections, System.Threading,  FMX.Types, FMX.Controls, FMX.Forms, FMX.StdCtrls, FMX.Layouts,  FMX.Dialogs,  FireDAC.Stan.Intf, FireDAC.Stan.Option, FireDAC.Stan.Param,  FireDAC.Stan.Error, FireDAC.DatS, FireDAC.Phys.Intf,  FireDAC.DApt.Intf, FireDAC.Comp.DataSet, FireDAC.Comp.Client,  Data.DB,  Tina4REST, Tina4RESTRequest, Tina4HTMLRender, Tina4HTMLPages,  Tina4Twig, Tina4JSONAdapter, Tina4Core;โ€‹type  TFormMain = class(TForm)    REST: TTina4REST;    RESTUsers: TTina4RESTRequest;    RESTStats: TTina4RESTRequest;    MemUsers: TFDMemTable;    MemStats: TFDMemTable;    Renderer: TTina4HTMLRender;    Pages: TTina4HTMLPages;    DSUsers: TDataSource;    procedure FormCreate(Sender: TObject);  private    FBearerToken: string;    FCurrentUserId: string;    FSearchFilter: string;    FCurrentPage: Integer;    FPageSize: Integer;    FNotificationCount: Integer;โ€‹    { Authentication }    procedure DoLogin(const Username, Password: string);    procedure DoLogout;    function IsAuthenticated: Boolean;โ€‹    { Page setup }    procedure SetupPages;    procedure SetupLoginPage;    procedure SetupDashboardPage;    procedure SetupUsersPage;    procedure SetupSettingsPage;โ€‹    { REST operations }    procedure FetchStats;    procedure FetchUsers;    procedure CreateUser(const Name, Email, Role: string);    procedure UpdateUser(const Id, Name, Email, Role: string);    procedure DeleteUser(const Id: string);โ€‹    { Event handlers for HTML }    procedure HandleLogin(const FormData: string);    procedure HandleSearch(const Query: string);    procedure HandlePageChange(const Page: string);    procedure HandleEditUser(const UserId: string);    procedure HandleDeleteUser(const UserId: string);    procedure HandleSaveUser(const FormData: string);    procedure HandleSaveSettings(const FormData: string);โ€‹    { UI helpers }    procedure ShowNotification(const Msg: string; IsError: Boolean = False);    procedure RefreshCurrentPage;    function BuildUserTableHTML: string;    function BuildStatsHTML: string;    function ParseFormData(const Raw: string): TStringList;  public    { RTTI-callable methods for HTML onclick }    procedure NavigateTo(const PageName: string);    procedure OnLoginSubmit(const Username, Password: string);    procedure OnSearchSubmit(const Query: string);    procedure OnEditClick(const UserId: string);    procedure OnDeleteClick(const UserId: string);    procedure OnPageClick(const Page: string);  end;โ€‹var  FormMain: TFormMain;โ€‹implementationโ€‹{$R *.fmx}

4. Initialization#

pascal
procedure TFormMain.FormCreate(Sender: TObject);begin  FCurrentPage := 1;  FPageSize := 10;  FNotificationCount := 0;  FSearchFilter := '';โ€‹  { REST configuration }  REST.BaseUrl := 'https://api.example.com/v1';โ€‹  { Users request }  RESTUsers.Tina4REST := REST;  RESTUsers.EndPoint := '/users';  RESTUsers.RequestType := TTina4RequestType.Get;  RESTUsers.DataKey := 'records';  RESTUsers.MemTable := MemUsers;  RESTUsers.SyncMode := TTina4RestSyncMode.Clear;โ€‹  { Stats request }  RESTStats.Tina4REST := REST;  RESTStats.EndPoint := '/stats';  RESTStats.RequestType := TTina4RequestType.Get;  RESTStats.DataKey := 'stats';  RESTStats.MemTable := MemStats;โ€‹  { HTML renderer }  Renderer.CacheEnabled := True;  Renderer.CacheDir := TPath.Combine(TPath.GetDocumentsPath, 'AdminCache');  Renderer.RegisterObject('App', Self);โ€‹  { Pages }  Pages.Renderer := Renderer;  Pages.TwigTemplatePath := TPath.Combine(ExtractFilePath(ParamStr(0)), 'templates');โ€‹  SetupPages;โ€‹  { Page navigation guard }  Pages.OnBeforeNavigate := procedure(Sender: TObject;    const FromPage, ToPage: string; var Allow: Boolean)  begin    if (ToPage <> 'login') and (not IsAuthenticated) then    begin      Allow := False;      Pages.NavigateTo('login');    end;  end;end;

5. Authentication#

pascal
function TFormMain.IsAuthenticated: Boolean;begin  Result := not FBearerToken.IsEmpty;end;โ€‹procedure TFormMain.DoLogin(const Username, Password: string);var  StatusCode: Integer;  Response: TJSONObject;  Body: string;begin  Body := Format('{"username": "%s", "password": "%s"}',    [Username, Password]);โ€‹  Response := REST.Post(StatusCode, '/auth/login', '', Body);  try    if StatusCode = 200 then    begin      FBearerToken := Response.GetValue<string>('token');      REST.SetBearer(FBearerToken);โ€‹      { Navigate to dashboard }      Pages.NavigateTo('dashboard');      FetchStats;      FetchUsers;    end    else    begin      var Msg := 'Login failed.';      if Assigned(Response) then        Msg := Response.GetValue<string>('message', 'Invalid credentials.');      ShowNotification(Msg, True);    end;  finally    Response.Free;  end;end;โ€‹procedure TFormMain.DoLogout;begin  FBearerToken := '';  REST.SetBearer('');  Pages.NavigateTo('login');end;โ€‹{ RTTI-callable from HTML onclick }procedure TFormMain.OnLoginSubmit(const Username, Password: string);begin  DoLogin(Username, Password);end;

6. Page Definitions#

pascal
procedure TFormMain.SetupPages;begin  SetupLoginPage;  SetupDashboardPage;  SetupUsersPage;  SetupSettingsPage;end;โ€‹procedure TFormMain.SetupLoginPage;var  Page: TTina4Page;begin  Page := Pages.Pages.Add;  Page.PageName := 'login';  Page.IsDefault := True;  Page.HTMLContent.Text :=    '<div style="max-width: 400px; margin: 80px auto; ' +    '  font-family: Arial, sans-serif;">' +    '  <h1 style="text-align: center; color: #2c3e50;">Admin Dashboard</h1>' +    '  <div style="background: white; padding: 30px; border-radius: 8px; ' +    '    box-shadow: 0 2px 10px rgba(0,0,0,0.1);">' +    '    <div style="margin-bottom: 15px;">' +    '      <label style="display: block; margin-bottom: 5px; ' +    '        color: #555; font-size: 14px;">Username</label>' +    '      <input type="text" id="loginUser" ' +    '        style="width: 100%; padding: 10px; border: 1px solid #ddd; ' +    '        border-radius: 4px; font-size: 14px;" ' +    '        placeholder="Enter username">' +    '    </div>' +    '    <div style="margin-bottom: 20px;">' +    '      <label style="display: block; margin-bottom: 5px; ' +    '        color: #555; font-size: 14px;">Password</label>' +    '      <input type="password" id="loginPass" ' +    '        style="width: 100%; padding: 10px; border: 1px solid #ddd; ' +    '        border-radius: 4px; font-size: 14px;" ' +    '        placeholder="Enter password">' +    '    </div>' +    '    <button onclick="App:OnLoginSubmit(' +    '      document.getElementById(''loginUser'').value, ' +    '      document.getElementById(''loginPass'').value)" ' +    '      style="width: 100%; padding: 12px; background: #3498db; ' +    '      color: white; border: none; border-radius: 4px; ' +    '      font-size: 16px; cursor: pointer;">Login</button>' +    '    <div id="loginError" style="display: none; margin-top: 15px; ' +    '      padding: 10px; background: #e74c3c; color: white; ' +    '      border-radius: 4px;"></div>' +    '  </div>' +    '</div>';end;

The login page uses HTML inputs rendered by TTina4HTMLRender. The button's onclick calls App:OnLoginSubmit(...) which resolves via RTTI to the TFormMain.OnLoginSubmit method. The parameters are extracted from the input fields using document.getElementById.


7. Dashboard Page with Stats#

pascal
procedure TFormMain.SetupDashboardPage;var  Page: TTina4Page;begin  Page := Pages.Pages.Add;  Page.PageName := 'dashboard';  { Content is set dynamically after stats load }  Page.HTMLContent.Text :=    '<div style="padding: 20px; font-family: Arial;">' +    '  <p>Loading dashboard...</p>' +    '</div>';end;โ€‹function TFormMain.BuildStatsHTML: string;var  UserCount, ActiveSessions, Revenue, OrderCount: string;begin  UserCount := '0';  ActiveSessions := '0';  Revenue := '$0';  OrderCount := '0';โ€‹  if MemStats.Active and (MemStats.RecordCount > 0) then  begin    MemStats.First;    UserCount := MemStats.FieldByName('user_count').AsString;    ActiveSessions := MemStats.FieldByName('active_sessions').AsString;    Revenue := '$' + MemStats.FieldByName('revenue').AsString;    OrderCount := MemStats.FieldByName('order_count').AsString;  end;โ€‹  Result :=    '<div style="font-family: Arial; padding: 20px;">' +โ€‹    { Navigation bar }    '  <div style="display: flex; justify-content: space-between; ' +    '    align-items: center; margin-bottom: 30px; padding-bottom: 15px; ' +    '    border-bottom: 2px solid #ecf0f1;">' +    '    <h1 style="margin: 0; color: #2c3e50;">Dashboard</h1>' +    '    <div>' +    '      <span onclick="App:NavigateTo(''dashboard'')" ' +    '        style="margin-right: 15px; color: #3498db; ' +    '        cursor: pointer; font-weight: bold;">Dashboard</span>' +    '      <span onclick="App:NavigateTo(''users'')" ' +    '        style="margin-right: 15px; color: #7f8c8d; cursor: pointer;">Users</span>' +    '      <span onclick="App:NavigateTo(''settings'')" ' +    '        style="margin-right: 15px; color: #7f8c8d; cursor: pointer;">Settings</span>' +    '      <span onclick="App:DoLogout" ' +    '        style="color: #e74c3c; cursor: pointer;">Logout</span>' +    '    </div>' +    '  </div>' +โ€‹    { Stat cards row }    '  <div style="display: flex; gap: 20px; margin-bottom: 30px;">' +โ€‹    '    <div style="flex: 1; background: #3498db; color: white; ' +    '      padding: 20px; border-radius: 8px;">' +    '      <p style="font-size: 14px; margin: 0; opacity: 0.8;">Total Users</p>' +    '      <p style="font-size: 36px; margin: 5px 0 0 0; font-weight: bold;">' +    UserCount + '</p>' +    '    </div>' +โ€‹    '    <div style="flex: 1; background: #2ecc71; color: white; ' +    '      padding: 20px; border-radius: 8px;">' +    '      <p style="font-size: 14px; margin: 0; opacity: 0.8;">Active Sessions</p>' +    '      <p style="font-size: 36px; margin: 5px 0 0 0; font-weight: bold;">' +    ActiveSessions + '</p>' +    '    </div>' +โ€‹    '    <div style="flex: 1; background: #9b59b6; color: white; ' +    '      padding: 20px; border-radius: 8px;">' +    '      <p style="font-size: 14px; margin: 0; opacity: 0.8;">Revenue</p>' +    '      <p style="font-size: 36px; margin: 5px 0 0 0; font-weight: bold;">' +    Revenue + '</p>' +    '    </div>' +โ€‹    '    <div style="flex: 1; background: #e67e22; color: white; ' +    '      padding: 20px; border-radius: 8px;">' +    '      <p style="font-size: 14px; margin: 0; opacity: 0.8;">Orders</p>' +    '      <p style="font-size: 36px; margin: 5px 0 0 0; font-weight: bold;">' +    OrderCount + '</p>' +    '    </div>' +โ€‹    '  </div>' +โ€‹    { Notification area }    '  <div id="notificationArea" style="display: none; ' +    '    padding: 10px; border-radius: 4px; margin-bottom: 20px;"></div>' +โ€‹    '</div>';end;โ€‹procedure TFormMain.FetchStats;begin  RESTStats.OnExecuteDone := procedure(Sender: TObject)  begin    TThread.Synchronize(nil, procedure    begin      var Page := Pages.Pages.FindPage('dashboard');      if Assigned(Page) then      begin        Page.HTMLContent.Text := BuildStatsHTML;        if Pages.ActivePage = 'dashboard' then          Pages.NavigateTo('dashboard');      end;    end);  end;  RESTStats.ExecuteRESTCallAsync;end;

The dashboard fetches stats asynchronously. When the REST call completes, it rebuilds the HTML and refreshes the page. The stat cards use colored backgrounds with large numbers -- a common dashboard pattern.


8. Users Page -- CRUD Operations#

pascal
procedure TFormMain.SetupUsersPage;var  Page: TTina4Page;begin  Page := Pages.Pages.Add;  Page.PageName := 'users';  Page.HTMLContent.Text :=    '<div style="padding: 20px; font-family: Arial;">' +    '  <p>Loading users...</p>' +    '</div>';end;โ€‹function TFormMain.BuildUserTableHTML: string;var  Rows: string;  RowClass: string;  I: Integer;begin  Rows := '';  if MemUsers.Active then  begin    MemUsers.First;    I := 0;    while not MemUsers.Eof do    begin      if I mod 2 = 0 then        RowClass := 'background: #f9f9f9;'      else        RowClass := 'background: white;';โ€‹      Rows := Rows +        '<tr style="' + RowClass + '">' +        '  <td style="padding: 10px; border-bottom: 1px solid #eee;">' +             MemUsers.FieldByName('id').AsString + '</td>' +        '  <td style="padding: 10px; border-bottom: 1px solid #eee;">' +             MemUsers.FieldByName('name').AsString + '</td>' +        '  <td style="padding: 10px; border-bottom: 1px solid #eee;">' +             MemUsers.FieldByName('email').AsString + '</td>' +        '  <td style="padding: 10px; border-bottom: 1px solid #eee;">' +             MemUsers.FieldByName('role').AsString + '</td>' +        '  <td style="padding: 10px; border-bottom: 1px solid #eee;">' +        '    <span onclick="App:OnEditClick(''' +               MemUsers.FieldByName('id').AsString + ''')" ' +        '      style="color: #3498db; cursor: pointer; margin-right: 10px;">Edit</span>' +        '    <span onclick="App:OnDeleteClick(''' +               MemUsers.FieldByName('id').AsString + ''')" ' +        '      style="color: #e74c3c; cursor: pointer;">Delete</span>' +        '  </td>' +        '</tr>';โ€‹      MemUsers.Next;      Inc(I);    end;  end;โ€‹  if Rows.IsEmpty then    Rows := '<tr><td colspan="5" style="padding: 20px; text-align: center; ' +      'color: #999;">No users found.</td></tr>';โ€‹  Result :=    '<div style="font-family: Arial; padding: 20px;">' +โ€‹    { Navigation }    '  <div style="display: flex; justify-content: space-between; ' +    '    align-items: center; margin-bottom: 30px; padding-bottom: 15px; ' +    '    border-bottom: 2px solid #ecf0f1;">' +    '    <h1 style="margin: 0; color: #2c3e50;">Users</h1>' +    '    <div>' +    '      <span onclick="App:NavigateTo(''dashboard'')" ' +    '        style="margin-right: 15px; color: #7f8c8d; cursor: pointer;">Dashboard</span>' +    '      <span onclick="App:NavigateTo(''users'')" ' +    '        style="margin-right: 15px; color: #3498db; ' +    '        cursor: pointer; font-weight: bold;">Users</span>' +    '      <span onclick="App:NavigateTo(''settings'')" ' +    '        style="margin-right: 15px; color: #7f8c8d; cursor: pointer;">Settings</span>' +    '      <span onclick="App:DoLogout" ' +    '        style="color: #e74c3c; cursor: pointer;">Logout</span>' +    '    </div>' +    '  </div>' +โ€‹    { Search bar }    '  <div style="display: flex; gap: 10px; margin-bottom: 20px;">' +    '    <input type="text" id="searchInput" placeholder="Search users..." ' +    '      style="flex: 1; padding: 10px; border: 1px solid #ddd; ' +    '      border-radius: 4px; font-size: 14px;" ' +    '      value="' + FSearchFilter + '">' +    '    <button onclick="App:OnSearchSubmit(' +    '      document.getElementById(''searchInput'').value)" ' +    '      style="padding: 10px 20px; background: #3498db; color: white; ' +    '      border: none; border-radius: 4px; cursor: pointer;">Search</button>' +    '    <button onclick="App:NavigateTo(''user-create'')" ' +    '      style="padding: 10px 20px; background: #2ecc71; color: white; ' +    '      border: none; border-radius: 4px; cursor: pointer;">+ New User</button>' +    '  </div>' +โ€‹    { User table }    '  <table style="width: 100%; border-collapse: collapse;">' +    '    <thead>' +    '      <tr style="background: #2c3e50; color: white;">' +    '        <th style="padding: 12px; text-align: left;">ID</th>' +    '        <th style="padding: 12px; text-align: left;">Name</th>' +    '        <th style="padding: 12px; text-align: left;">Email</th>' +    '        <th style="padding: 12px; text-align: left;">Role</th>' +    '        <th style="padding: 12px; text-align: left;">Actions</th>' +    '      </tr>' +    '    </thead>' +    '    <tbody>' + Rows + '</tbody>' +    '  </table>' +โ€‹    { Pagination }    '  <div style="margin-top: 20px; text-align: center;">' +    '    <span onclick="App:OnPageClick(''' + (FCurrentPage - 1).ToString + ''')" ' +    '      style="padding: 8px 16px; margin: 0 5px; background: #ecf0f1; ' +    '      border-radius: 4px; cursor: pointer;">Previous</span>' +    '    <span style="padding: 8px 16px; margin: 0 5px; background: #3498db; ' +    '      color: white; border-radius: 4px;">Page ' + FCurrentPage.ToString + '</span>' +    '    <span onclick="App:OnPageClick(''' + (FCurrentPage + 1).ToString + ''')" ' +    '      style="padding: 8px 16px; margin: 0 5px; background: #ecf0f1; ' +    '      border-radius: 4px; cursor: pointer;">Next</span>' +    '  </div>' +โ€‹    '</div>';end;โ€‹procedure TFormMain.FetchUsers;var  QueryParams: string;begin  QueryParams := Format('page=%d&limit=%d', [FCurrentPage, FPageSize]);  if not FSearchFilter.IsEmpty then    QueryParams := QueryParams + '&search=' + FSearchFilter;โ€‹  RESTUsers.EndPoint := '/users';  RESTUsers.QueryParams := QueryParams;  RESTUsers.OnExecuteDone := procedure(Sender: TObject)  begin    TThread.Synchronize(nil, procedure    begin      var Page := Pages.Pages.FindPage('users');      if Assigned(Page) then      begin        Page.HTMLContent.Text := BuildUserTableHTML;        if Pages.ActivePage = 'users' then          Pages.NavigateTo('users');      end;    end);  end;  RESTUsers.ExecuteRESTCallAsync;end;

Creating a User#

pascal
procedure TFormMain.CreateUser(const Name, Email, Role: string);var  StatusCode: Integer;  Body: string;  Response: TJSONObject;begin  Body := Format('{"name": "%s", "email": "%s", "role": "%s"}',    [Name, Email, Role]);โ€‹  Response := REST.Post(StatusCode, '/users', '', Body);  try    if StatusCode in [200, 201] then    begin      ShowNotification('User created successfully.');      FetchUsers;      Pages.NavigateTo('users');    end    else      ShowNotification('Failed to create user.', True);  finally    Response.Free;  end;end;

Updating a User#

pascal
procedure TFormMain.UpdateUser(const Id, Name, Email, Role: string);var  StatusCode: Integer;  Body: string;  Response: TJSONObject;begin  Body := Format('{"name": "%s", "email": "%s", "role": "%s"}',    [Name, Email, Role]);โ€‹  Response := REST.Patch(StatusCode, '/users/' + Id, '', Body);  try    if StatusCode = 200 then    begin      ShowNotification('User updated successfully.');      FetchUsers;      Pages.NavigateTo('users');    end    else      ShowNotification('Failed to update user.', True);  finally    Response.Free;  end;end;

Deleting a User#

pascal
procedure TFormMain.DeleteUser(const Id: string);var  StatusCode: Integer;  Response: TJSONObject;begin  Response := REST.Delete(StatusCode, '/users/' + Id, '');  try    if StatusCode in [200, 204] then    begin      ShowNotification('User deleted.');      FetchUsers;    end    else      ShowNotification('Failed to delete user.', True);  finally    Response.Free;  end;end;

9. Settings Page#

pascal
procedure TFormMain.SetupSettingsPage;var  Page: TTina4Page;begin  Page := Pages.Pages.Add;  Page.PageName := 'settings';  Page.HTMLContent.Text :=    '<div style="font-family: Arial; padding: 20px;">' +โ€‹    { Navigation }    '  <div style="display: flex; justify-content: space-between; ' +    '    align-items: center; margin-bottom: 30px; padding-bottom: 15px; ' +    '    border-bottom: 2px solid #ecf0f1;">' +    '    <h1 style="margin: 0; color: #2c3e50;">Settings</h1>' +    '    <div>' +    '      <span onclick="App:NavigateTo(''dashboard'')" ' +    '        style="margin-right: 15px; color: #7f8c8d; cursor: pointer;">Dashboard</span>' +    '      <span onclick="App:NavigateTo(''users'')" ' +    '        style="margin-right: 15px; color: #7f8c8d; cursor: pointer;">Users</span>' +    '      <span onclick="App:NavigateTo(''settings'')" ' +    '        style="margin-right: 15px; color: #3498db; ' +    '        cursor: pointer; font-weight: bold;">Settings</span>' +    '      <span onclick="App:DoLogout" ' +    '        style="color: #e74c3c; cursor: pointer;">Logout</span>' +    '    </div>' +    '  </div>' +โ€‹    { Settings form }    '  <div style="max-width: 600px;">' +    '    <div style="margin-bottom: 15px;">' +    '      <label style="display: block; margin-bottom: 5px; color: #555;">App Name</label>' +    '      <input type="text" id="settAppName" value="Admin Dashboard" ' +    '        style="width: 100%; padding: 10px; border: 1px solid #ddd; ' +    '        border-radius: 4px;">' +    '    </div>' +    '    <div style="margin-bottom: 15px;">' +    '      <label style="display: block; margin-bottom: 5px; color: #555;">API URL</label>' +    '      <input type="text" id="settApiUrl" value="https://api.example.com/v1" ' +    '        style="width: 100%; padding: 10px; border: 1px solid #ddd; ' +    '        border-radius: 4px;">' +    '    </div>' +    '    <div style="margin-bottom: 15px;">' +    '      <label style="display: block; margin-bottom: 5px; color: #555;">' +    '        Items Per Page</label>' +    '      <select id="settPageSize" style="width: 100%; padding: 10px; ' +    '        border: 1px solid #ddd; border-radius: 4px;">' +    '        <option value="10">10</option>' +    '        <option value="25">25</option>' +    '        <option value="50">50</option>' +    '      </select>' +    '    </div>' +    '    <div style="margin-bottom: 20px;">' +    '      <label style="display: block; margin-bottom: 5px; color: #555;">' +    '        Enable Notifications</label>' +    '      <input type="checkbox" id="settNotify" checked ' +    '        style="margin-right: 8px;">' +    '      <span style="color: #555;">Receive real-time notifications</span>' +    '    </div>' +    '    <button onclick="App:HandleSaveSettings(' +    '      document.getElementById(''settApiUrl'').value)" ' +    '      style="padding: 12px 30px; background: #3498db; color: white; ' +    '      border: none; border-radius: 4px; font-size: 16px; ' +    '      cursor: pointer;">Save Settings</button>' +    '  </div>' +    '</div>';end;โ€‹procedure TFormMain.HandleSaveSettings(const FormData: string);begin  REST.BaseUrl := FormData;  // The API URL passed from the form  ShowNotification('Settings saved.');end;

10. RTTI Event Handlers#

These methods are called from HTML onclick attributes via the RTTI mechanism. They bridge the HTML UI to the Pascal logic.

pascal
procedure TFormMain.NavigateTo(const PageName: string);begin  if not IsAuthenticated and (PageName <> 'login') then  begin    Pages.NavigateTo('login');    Exit;  end;โ€‹  Pages.NavigateTo(PageName);โ€‹  { Refresh data when entering certain pages }  if PageName = 'dashboard' then    FetchStats  else if PageName = 'users' then    FetchUsers;end;โ€‹procedure TFormMain.OnSearchSubmit(const Query: string);begin  FSearchFilter := Query;  FCurrentPage := 1;  FetchUsers;end;โ€‹procedure TFormMain.OnEditClick(const UserId: string);var  Page: TTina4Page;begin  FCurrentUserId := UserId;โ€‹  { Find the user in MemTable }  if MemUsers.Locate('id', UserId) then  begin    Page := Pages.Pages.FindPage('user-edit');    if not Assigned(Page) then    begin      Page := Pages.Pages.Add;      Page.PageName := 'user-edit';    end;โ€‹    Page.HTMLContent.Text :=      '<div style="font-family: Arial; padding: 20px; max-width: 600px;">' +      '  <h2 style="color: #2c3e50;">Edit User</h2>' +      '  <div style="margin-bottom: 15px;">' +      '    <label style="display: block; margin-bottom: 5px;">Name</label>' +      '    <input type="text" id="editName" ' +      '      value="' + MemUsers.FieldByName('name').AsString + '" ' +      '      style="width: 100%; padding: 10px; border: 1px solid #ddd; ' +      '      border-radius: 4px;">' +      '  </div>' +      '  <div style="margin-bottom: 15px;">' +      '    <label style="display: block; margin-bottom: 5px;">Email</label>' +      '    <input type="text" id="editEmail" ' +      '      value="' + MemUsers.FieldByName('email').AsString + '" ' +      '      style="width: 100%; padding: 10px; border: 1px solid #ddd; ' +      '      border-radius: 4px;">' +      '  </div>' +      '  <div style="margin-bottom: 20px;">' +      '    <label style="display: block; margin-bottom: 5px;">Role</label>' +      '    <select id="editRole" style="width: 100%; padding: 10px; ' +      '      border: 1px solid #ddd; border-radius: 4px;">' +      '      <option value="user">User</option>' +      '      <option value="admin">Admin</option>' +      '      <option value="editor">Editor</option>' +      '    </select>' +      '  </div>' +      '  <button onclick="App:HandleSaveUser(' +      '    document.getElementById(''editName'').value)" ' +      '    style="padding: 10px 20px; background: #3498db; color: white; ' +      '    border: none; border-radius: 4px; cursor: pointer; ' +      '    margin-right: 10px;">Save</button>' +      '  <button onclick="App:NavigateTo(''users'')" ' +      '    style="padding: 10px 20px; background: #95a5a6; color: white; ' +      '    border: none; border-radius: 4px; cursor: pointer;">Cancel</button>' +      '</div>';โ€‹    Pages.NavigateTo('user-edit');  end;end;โ€‹procedure TFormMain.OnDeleteClick(const UserId: string);begin  { In a real app, show a confirmation dialog first }  DeleteUser(UserId);end;โ€‹procedure TFormMain.OnPageClick(const Page: string);var  NewPage: Integer;begin  NewPage := StrToIntDef(Page, 1);  if NewPage < 1 then NewPage := 1;  FCurrentPage := NewPage;  FetchUsers;end;โ€‹procedure TFormMain.HandleSaveUser(const FormData: string);begin  { FormData contains the name; in a real app, read all fields }  var Name := Renderer.GetElementValue('editName');  var Email := Renderer.GetElementValue('editEmail');  var Role := Renderer.GetElementValue('editRole');โ€‹  if FCurrentUserId.IsEmpty then    CreateUser(Name, Email, Role)  else    UpdateUser(FCurrentUserId, Name, Email, Role);end;

11. Notifications#

pascal
procedure TFormMain.ShowNotification(const Msg: string; IsError: Boolean);var  BgColor: string;begin  if IsError then    BgColor := '#e74c3c'  else    BgColor := '#2ecc71';โ€‹  { Update the notification area on the current page }  Renderer.SetElementVisible('notificationArea', True);  Renderer.SetElementStyle('notificationArea', 'background-color', BgColor);  Renderer.SetElementStyle('notificationArea', 'color', 'white');  Renderer.SetElementText('notificationArea', Msg);โ€‹  { Auto-hide after 3 seconds }  TTask.Run(procedure  begin    Sleep(3000);    TThread.Synchronize(nil, procedure    begin      Renderer.SetElementVisible('notificationArea', False);    end);  end);end;

The notification uses DOM manipulation to show and hide a div. No page rebuild needed. The SetElementVisible, SetElementStyle, and SetElementText methods update the rendered HTML in place. A background task hides it after three seconds.


12. WebSocket Notifications#

Add a WebSocket connection for real-time updates. If your API supports WebSocket, add this to FormCreate:

pascal
procedure TFormMain.SetupWebSocket;begin  { WebSocket for real-time notifications }  { Note: TTina4WebSocketClient is a separate component }  FWebSocket := TTina4WebSocketClient.Create(Self);  FWebSocket.URL := 'wss://api.example.com/ws/notifications';โ€‹  FWebSocket.OnMessage := procedure(Sender: TObject; const Msg: string)  begin    TThread.Synchronize(nil, procedure    var      JSON: TJSONObject;    begin      JSON := StrToJSONObject(Msg);      try        if Assigned(JSON) then        begin          Inc(FNotificationCount);          var NotifText := JSON.GetValue<string>('message', 'New notification');          ShowNotification(NotifText);โ€‹          { If on dashboard, refresh stats }          if Pages.ActivePage = 'dashboard' then            FetchStats;        end;      finally        JSON.Free;      end;    end);  end;โ€‹  FWebSocket.OnDisconnect := procedure(Sender: TObject)  begin    TThread.Synchronize(nil, procedure    begin      ShowNotification('Connection lost. Reconnecting...', True);    end);  end;โ€‹  FWebSocket.AutoReconnect := True;  FWebSocket.ReconnectInterval := 5000;end;

After successful login, connect the WebSocket:

pascal
procedure TFormMain.DoLogin(const Username, Password: string);begin  { ... existing login code ... }โ€‹  if StatusCode = 200 then  begin    FBearerToken := Response.GetValue<string>('token');    REST.SetBearer(FBearerToken);โ€‹    { Connect WebSocket after auth }    FWebSocket.Headers.Values['Authorization'] := 'Bearer ' + FBearerToken;    FWebSocket.Connect;โ€‹    Pages.NavigateTo('dashboard');    FetchStats;    FetchUsers;  end;end;

13. Error Handling#

Every REST call in this application handles errors. Here is the complete error handling pattern used throughout:

pascal
procedure TFormMain.SafeRESTCall(const Method: string; const EndPoint: string;  const Body: string; OnSuccess: TProc<TJSONObject>);var  StatusCode: Integer;  Response: TJSONObject;begin  try    case Method.ToUpper.Chars[0] of      'G': Response := REST.Get(StatusCode, EndPoint, '');      'P': Response := REST.Post(StatusCode, EndPoint, '', Body);      'A': Response := REST.Patch(StatusCode, EndPoint, '', Body);      'D': Response := REST.Delete(StatusCode, EndPoint, '');    else      Response := REST.Get(StatusCode, EndPoint, '');    end;โ€‹    try      case StatusCode of        200, 201, 204:          begin            if Assigned(OnSuccess) then              OnSuccess(Response);          end;        401:          begin            ShowNotification('Session expired. Please log in again.', True);            DoLogout;          end;        403:          ShowNotification('You do not have permission for this action.', True);        404:          ShowNotification('The requested resource was not found.', True);        422:          begin            var Msg := 'Validation error.';            if Assigned(Response) then              Msg := Response.GetValue<string>('message', Msg);            ShowNotification(Msg, True);          end;      else        ShowNotification('Server error: ' + StatusCode.ToString, True);      end;    finally      Response.Free;    end;  except    on E: ENetHTTPClientException do      ShowNotification('Network error: Could not reach server.', True);    on E: Exception do      ShowNotification('Unexpected error: ' + E.Message, True);  end;end;

Usage:

pascal
procedure TFormMain.FetchStatsWithSafeCall;begin  SafeRESTCall('GET', '/stats', '', procedure(Response: TJSONObject)  begin    PopulateMemTableFromJSON(MemStats, 'stats', Response.ToString);    var Page := Pages.Pages.FindPage('dashboard');    if Assigned(Page) then      Page.HTMLContent.Text := BuildStatsHTML;  end);end;

14. Using Twig Templates Instead of String Concatenation#

The inline HTML strings above work but become unwieldy. For production applications, use Twig template files. Here is the dashboard page as a Twig template:

html
{# templates/dashboard.html #}{% extends 'layout.html' %} {% block title %}Dashboard{% endblock %} {% block content %}<div style="display: flex; gap: 20px; margin-bottom: 30px;">  {% for stat in stats %}  <div style="flex: 1; background: {{ stat.color }}; color: white;    padding: 20px; border-radius: 8px;">    <p style="font-size: 14px; margin: 0; opacity: 0.8;">{{ stat.label }}</p>    <p style="font-size: 36px; margin: 5px 0 0 0; font-weight: bold;">      {{ stat.value }}    </p>  </div>  {% endfor %}</div> <div id="notificationArea" style="display: none;  padding: 10px; border-radius: 4px; margin-bottom: 20px;"></div>{% endblock %}
html
{# templates/layout.html #}<div style="font-family: Arial; padding: 20px;">  <div style="display: flex; justify-content: space-between;    align-items: center; margin-bottom: 30px; padding-bottom: 15px;    border-bottom: 2px solid #ecf0f1;">    <h1 style="margin: 0; color: #2c3e50;">{% block title %}{% endblock %}</h1>    <div>      <span onclick="App:NavigateTo('dashboard')"        style="margin-right: 15px; color: #7f8c8d; cursor: pointer;">Dashboard</span>      <span onclick="App:NavigateTo('users')"        style="margin-right: 15px; color: #7f8c8d; cursor: pointer;">Users</span>      <span onclick="App:NavigateTo('settings')"        style="margin-right: 15px; color: #7f8c8d; cursor: pointer;">Settings</span>      <span onclick="App:DoLogout"        style="color: #e74c3c; cursor: pointer;">Logout</span>    </div>  </div>  {% block content %}{% endblock %}</div>

Use Twig pages in your TTina4HTMLPages:

pascal
procedure TFormMain.SetupDashboardPageWithTwig;var  Page: TTina4Page;begin  Page := Pages.Pages.Add;  Page.PageName := 'dashboard';  Page.TwigContent.LoadFromFile(    TPath.Combine(Pages.TwigTemplatePath, 'dashboard.html'));end;โ€‹procedure TFormMain.FetchStatsWithTwig;begin  RESTStats.OnExecuteDone := procedure(Sender: TObject)  begin    TThread.Synchronize(nil, procedure    begin      { Set Twig variables from MemTable data }      Pages.SetTwigVariable('stats',        '[' +        '{"label": "Total Users", "value": "' +          MemStats.FieldByName('user_count').AsString +          '", "color": "#3498db"},' +        '{"label": "Active Sessions", "value": "' +          MemStats.FieldByName('active_sessions').AsString +          '", "color": "#2ecc71"},' +        '{"label": "Revenue", "value": "$' +          MemStats.FieldByName('revenue').AsString +          '", "color": "#9b59b6"},' +        '{"label": "Orders", "value": "' +          MemStats.FieldByName('order_count').AsString +          '", "color": "#e67e22"}' +        ']');โ€‹      if Pages.ActivePage = 'dashboard' then        Pages.NavigateTo('dashboard');    end);  end;  RESTStats.ExecuteRESTCallAsync;end;

15. Project Organization#

For a production application, organize your project like this:

AdminDashboard/  AdminDashboard.dpr  AdminDashboard.dproj  src/    MainUnit.pas              -- Main form, page setup, navigation    MainUnit.fmx              -- FMX form with all components    DataModule.pas            -- REST components, MemTables    DataModule.dfm            -- DataModule design file    Auth.pas                  -- Authentication logic    UserOperations.pas        -- User CRUD operations  templates/    layout.html               -- Shared layout with navigation    login.html                -- Login page    dashboard.html            -- Dashboard with stat cards    users.html                -- User list with table    user-edit.html            -- User edit form    settings.html             -- Settings form  ssl/    libeay32.dll              -- 32-bit SSL (for IDE)    ssleay32.dll    libcrypto-3-x64.dll       -- 64-bit SSL (for compiled app)    libssl-3-x64.dll

Move REST components to a data module so the main form stays focused on UI:

pascal
// DataModule.pasunit DataModule;โ€‹interfaceโ€‹uses  System.SysUtils, System.Classes,  FireDAC.Comp.Client, Data.DB,  Tina4REST, Tina4RESTRequest;โ€‹type  TDM = class(TDataModule)    REST: TTina4REST;    RESTUsers: TTina4RESTRequest;    RESTStats: TTina4RESTRequest;    MemUsers: TFDMemTable;    MemStats: TFDMemTable;    DSUsers: TDataSource;    procedure DataModuleCreate(Sender: TObject);  private    FToken: string;  public    procedure SetToken(const AToken: string);    property Token: string read FToken;  end;โ€‹var  DM: TDM;โ€‹implementationโ€‹{$R *.dfm}โ€‹procedure TDM.DataModuleCreate(Sender: TObject);begin  REST.BaseUrl := 'https://api.example.com/v1';  RESTUsers.Tina4REST := REST;  RESTUsers.EndPoint := '/users';  RESTUsers.DataKey := 'records';  RESTUsers.MemTable := MemUsers;  RESTStats.Tina4REST := REST;  RESTStats.EndPoint := '/stats';  RESTStats.DataKey := 'stats';  RESTStats.MemTable := MemStats;  DSUsers.DataSet := MemUsers;end;โ€‹procedure TDM.SetToken(const AToken: string);begin  FToken := AToken;  REST.SetBearer(AToken);end;โ€‹end.

Summary#

This chapter built a complete admin dashboard using:

  • TTina4REST for API authentication and HTTP calls
  • TTina4RESTRequest for declarative data fetching into MemTables
  • TTina4HTMLRender for rendering the entire UI as styled HTML
  • TTina4HTMLPages for SPA-style page navigation
  • Twig templates for maintainable, dynamic HTML
  • RTTI onclick for bridging HTML events to Pascal methods
  • WebSocket for real-time notifications
  • DOM manipulation for in-place UI updates without full re-renders

The data flows in one direction: API response goes into a MemTable, MemTable data is rendered into HTML, user actions in HTML call Pascal methods via RTTI, and those methods make API calls that restart the cycle. This unidirectional flow makes the application predictable and debuggable.

Every pattern shown here -- authentication guards, async data loading, error handling, notification toasts -- is reusable in any Tina4 Delphi application. The admin dashboard is not just an example. It is a template for how to build desktop applications that talk to REST APIs.