Skip to content

AI blog · DotApp PHP Framework 2.0

How to build AJAX lists with pagination in DotApp PHP Framework

A Shop catalog that can grow must ship SQL paginate() and an interactive pager on the first version — even when the table is empty today. Further pages stay on the screen: $dotapp().load(), a visible busy overlay on desktop and mobile, then a patch of the inner HTML (rows and pager together). This article is a complete, Installation-free Shop items list: controller, fragment view, overlay CSS, and page script with search.

Common mistakes

Wrong Right
Dump the table with ->all() because there are only three rows now. DB::module('RAW')->q(...)->paginate($perPage, $page) on the first ship.
<a href="?page=2"> or location.reload() after a click. type="button" plus $dotapp().load(); stay on the page.
Replace #listWrap inner HTML (the overlay node). Cover #listWrap; patch the child #listInner (rows and pager).
Bind pager clicks with .on() once at boot. Bind with .live() so new buttons still work after a patch.
Wrap Prev / Next in <fo-rm>, or skip the overlay on a phone. Pager is buttons only. Overlay intercepts pointer and touch until success and error.
Raw ids in data-id="7", or filter ->all() in JavaScript. {{ enc(Shop.item.id): $id }} with a unique key2; search in SQL with LIKE + paginate().

When to paginate

Paginate any list that can accumulate: items, orders, users, logs, messages, files, events. Skip a pager only for a set that is closed by product design (four fixed cards). Lookup lists (catalog, articles, customers, orders by number) also ship interactive search unless you explicitly decline it. Query builder details: How to use the database in DotApp PHP Framework.

Overlay while the request runs

DotApp core does not ship a page overlay — build it in the Shop module. The wrapper is position: relative. While load() runs, a busy class covers the whole list on desktop and mobile, intercepts pointer and touch, and is removed on success and error. Patch a stable child. Replacing the wrapped node’s HTML wipes the overlay.

Same channel as every other load()

load() posts { data, crc } with the framework headers. PHP calls crcCheck() before it trusts the body. How that channel is built: How DotApp PHP Framework protects the browser-to-PHP channel. When to choose load() over a full <fo-rm>: Secure backend-frontend communication in DotApp PHP Framework.

paginate() return keys

SQL paginate() returns data, current_page, per_page, total, last_page, from, to, has_more_pages, prev_page, next_page. Clamp the requested page to 1 … last_page after the first query. UI chrome is separate: Pagination::paginate($current, $last)->render(...) must emit type="button", never a document-reloading href.

Complete controller (no Installation.php)

File: app/modules/Shop/Controllers/Items.php. Register GET /shop/items for first paint and POST /shop/items/list for every later page and search. Staff lists call Auth::can(); a public catalog can skip that line but never skip crcCheck() on the POST. Controllers are public static — there is no $this.


<?php
namespace Dotsystems\App\Modules\Shop\Controllers;

use Dotsystems\App\DotApp;
use Dotsystems\App\Parts\Auth;
use Dotsystems\App\Parts\Config;
use Dotsystems\App\Parts\DB;
use Dotsystems\App\Parts\Logger;
use Dotsystems\App\Parts\Pagination;
use Dotsystems\App\Parts\Renderer;

class Items extends \Dotsystems\App\Parts\Controller
{
    public static function index($request)
    {
        $html = Renderer::new()
            ->module('Shop')
            ->setView('items/page')
            ->setViewVar('title', 'Items')
            ->setViewVar('listHtml', self::renderList(1, ''))
            ->renderView();
        if ($html === '') {
            Logger::use()->error('Shop items page produced empty output');
            return new \Dotsystems\App\Parts\Response(500, 'Template error');
        }
        return $html;
    }

    public static function list($request)
    {
        if (!$request->crcCheck()) {
            return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Bad request'], 400);
        }
        if (!Auth::can(['Shop.catalog'])) {
            return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Forbidden'], 403);
        }
        $payload = $request->data(true)['data'] ?? [];
        $pageNo = (int) ($payload['page'] ?? 1);
        $q = trim((string) ($payload['q'] ?? ''));
        if (strlen($q) > 80) {
            $q = substr($q, 0, 80);
        }
        if (strlen($q) < 3) {
            $q = '';
        }
        $html = self::renderList($pageNo, $q);
        if ($html === '') {
            return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Template error'], 500);
        }
        return DotApp::DotApp()->ajaxReply(['status' => 1, 'html' => $html], 200);
    }

    private static function likeContains(string $q): string
    {
        return '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q) . '%';
    }

    private static function highlight(string $text, string $q): string
    {
        $safe = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
        if ($q === '') {
            return $safe;
        }
        $needle = htmlspecialchars($q, ENT_QUOTES, 'UTF-8');
        return preg_replace('/(' . preg_quote($needle, '/') . ')/i', '<mark>$1</mark>', $safe) ?? $safe;
    }

    private static function fetchPage(int $pageNo, string $q, int $perPage): array
    {
        return DB::module('RAW')
            ->q(function ($qb) use ($q) {
                $qb->select(['id', 'title', 'sku'])->from('shop_items')->orderBy('title', 'ASC');
                if ($q !== '') {
                    $qb->where('title', 'LIKE', self::likeContains($q));
                }
            })
            ->paginate($perPage, $pageNo);
    }

    private static function renderList(int $pageNo, string $q): string
    {
        if ($pageNo < 1) {
            $pageNo = 1;
        }
        $perPage = (int) (Config::module('Shop', 'itemsPerPage') ?? 20);
        $page = self::fetchPage($pageNo, $q, $perPage);
        $last = max(1, (int) $page['last_page']);
        if ($pageNo > $last) {
            $pageNo = $last;
            $page = self::fetchPage($pageNo, $q, $perPage);
        }
        $rows = [];
        foreach ($page['data'] as $row) {
            $rows[] = [
                'id' => $row['id'],
                'title_html' => self::highlight((string) $row['title'], $q),
                'sku' => (string) $row['sku'],
            ];
        }
        $emptyKind = ((int) $page['total'] === 0) ? ($q === '' ? 'catalog' : 'search') : 'none';
        $pager = Pagination::paginate((int) $page['current_page'], (int) $page['last_page'])
            ->window(2)->arrows(true)->ellipsis(true)->edge(true)
            ->render(function ($type, $pageNo, $label, $state, $href) {
                if ($type === 'ellipsis') {
                    return '<li class="disabled"><span>…</span></li>';
                }
                $off = ($state === 'active' || $state === 'disabled') ? ' disabled' : '';
                return '<li class="' . $state . '"><button type="button" class="js-shop-page" data-page="'
                    . (int) $pageNo . '"' . $off . '>' . htmlspecialchars((string) $label, ENT_QUOTES, 'UTF-8')
                    . '</button></li>';
            });
        return Renderer::new()
            ->module('Shop')
            ->setView('items/list')
            ->setViewVar('rows', $rows)
            ->setViewVar('emptyKind', $emptyKind)
            ->setViewVar('pager', $pager)
            ->renderView();
    }
}
    

Complete list.view.php fragment

File: app/modules/Shop/views/items/list.view.php. This inner HTML is what load() replaces. Row ids use a unique key2 Shop.item.id — decrypt with that same string, treat false as reject, and still call Auth::can() on any later row action. {{ var: }} is a raw echo: pass already-escaped title HTML from PHP.


{{ if $emptyKind === "catalog" }}
<p class="shop_empty">No items yet.</p>
{{ elseif $emptyKind === "search" }}
<p class="shop_empty">No items match this search.</p>
{{ else }}
<table class="shop_table">
  <thead>
    <tr><th>Title</th><th>SKU</th></tr>
  </thead>
  <tbody>
    {{ foreach $rows as $item }}
    <tr data-item="{{ enc(Shop.item.id): $item['id'] }}">
      <td>{{ var: $item['title_html'] }}</td>
      <td>{{ var: $item['sku'] }}</td>
    </tr>
    {{ /foreach }}
  </tbody>
</table>
<nav class="shop_pager" aria-label="Items pages">
  <ul>{{ var: $pager }}</ul>
</nav>
{{ /if }}
    

Page shell (items/page.view.php): search input outside #listInner, list wrapper around the fragment, then /assets/dotapp/dotapp.js and the module script. {{ var: $listHtml }} fills first paint with page 1.


<input type="search" id="shopSearch" class="shop_search" placeholder="Search items…" autocomplete="off">
<p id="shopStatus" hide="true"></p>
<div id="listWrap" class="shop_listwrap">
  <div id="listInner">{{ var: $listHtml }}</div>
</div>
<script src="/assets/dotapp/dotapp.js"></script>
<script src="/assets/modules/Shop/js/shop_list.js"></script>
    

Complete overlay CSS

File: app/modules/Shop/assets/css/shop_list.css — classes shop_*. ::after intercepts pointer and touch; ::before is a spinner large enough on a phone.


.shop_listwrap { position: relative; min-height: 8rem; }
.shop_listwrap.shop_busy::after {
  content: ""; position: absolute; inset: 0; z-index: 2;
  background: rgba(255,255,255,.72); cursor: wait; pointer-events: all;
}
.shop_listwrap.shop_busy::before {
  content: ""; position: absolute; z-index: 3; inset: 50% auto auto 50%;
  width: 2.25rem; height: 2.25rem; margin: -1.125rem 0 0 -1.125rem;
  border: 3px solid #ccc; border-top-color: #222; border-radius: 50%;
  animation: shop_spin .7s linear infinite; pointer-events: none;
}
@keyframes shop_spin { to { transform: rotate(360deg); } }
.shop_table thead th { position: sticky; top: 0; background: #fff; }
    

Complete JavaScript

File: app/modules/Shop/assets/js/shop_list.js. Boot on dotapp (or immediately if window.$dotapp exists). Always parseReply()ajaxReply is base64 JSON. Search: debounce ~300 ms, from 3 characters; shorter or empty is unfiltered page 1. Keep q on every pager POST. New query sends page: 1. Client API: How to use $dotapp() JavaScript in DotApp PHP Framework.


(function () {
  var runMe = function ($dotapp) {
    var listBusy = false;
    var currentQuery = "";
    var searchTimer = null;
    function listDone() {
      listBusy = false;
      $dotapp("#listWrap").removeClass("shop_busy");
    }
    function loadList(page) {
      if (listBusy) return;
      listBusy = true;
      $dotapp("#listWrap").addClass("shop_busy");
      $dotapp().load("/shop/items/list", "POST", { page: page, q: currentQuery },
        function (raw) {
          var reply = $dotapp().parseReply(raw);
          if (reply && reply.status == 1 && reply.html) $dotapp("#listInner").html(reply.html);
          listDone();
        },
        function () { listDone(); }
      );
    }
    $dotapp().live("click", ".js-shop-page", function (e) {
      var page = parseInt($dotapp(e.currentTarget).attr("data-page"), 10) || 1;
      loadList(page);
    });
    $dotapp().live("input", "#shopSearch", function () {
      var q = ($dotapp("#shopSearch").val() || "").trim();
      if (searchTimer) clearTimeout(searchTimer);
      searchTimer = setTimeout(function () {
        currentQuery = q.length >= 3 ? q : "";
        loadList(1);
      }, 300);
    });
  };
  if (window.$dotapp) runMe(window.$dotapp);
  else window.addEventListener("dotapp", function () { runMe(window.$dotapp); }, { once: true });
})();
    

FAQ

Must page 1 be AJAX too?

No. First paint may be server-rendered page 1 inside #listInner. Every later page and every search POST uses load().

Why not a form around the pager?

<fo-rm> is for a real multi-field submit. Pager, toggle, delete, and reorder are one-shot actions: type="button" plus load().

Can I filter the current HTML in JavaScript?

No. Bind LIKE, escape % and _ in the user string, cap length, and paginate in SQL. Never concatenate $q into a query string.

Empty overlay or empty table?

Remove the busy class on error as well as success (400/403/404/429 still uncover the list). Zero rows: product copy plus a primary action. Search with total 0: “No items match …”. Never a naked blank table.

See also