Search and pagination
This listing searches resource titles and renders ten rows per page. Its query and page are reloadable through URLs such as ?q=resource&page=2.
Complete installation first. Copy the files below to the indicated application locations.
The schema tab includes 25 sample rows for SQLite. The finder uses SQLite's INSTR function for literal substring matching. Adapt that expression to your database or an existing application finder.
Files
<?php
declare(strict_types=1);
namespace App\Island;
use Cake\ORM\Locator\LocatorAwareTrait;
use Interact\Attribute\Action;
use Interact\Attribute\State;
use Interact\Island;
/**
* Searches a resource Table and renders one result page without transporting the rows.
*
* Save as src/Island/ResourceSearchIsland.php.
*/
class ResourceSearchIsland extends Island
{
use LocatorAwareTrait;
#[State(writable: true, url: 'q')]
public string $search = '';
#[State(writable: true, url: true, history: true)]
public int $page = 1;
/**
* Bound an initial page supplied by the URL before rendering results.
*/
public function mount(): void
{
$this->normalizePage();
}
/**
* Restart pagination when the search changes.
*
* @param list<string> $paths Changed writable paths.
*/
public function updated(array $paths): void
{
if (in_array('search', $paths, true) && !in_array('page', $paths, true)) {
$this->page = 1;
}
$this->normalizePage();
}
/**
* Bound navigation against the current filtered result count.
*/
#[Action]
public function goTo(int $page): void
{
$this->page = $page;
$this->normalizePage();
}
/**
* Count and fetch the current page using the Table's composable finder.
*
* @return array<string, mixed> Rows and pagination values for the template.
*/
public function viewData(): array
{
$query = $this->fetchTable('Resources')->find('matching', search: $this->search);
$total = $query->count();
return [
'resources' => $query->limit(10)->page($this->page)->all(),
'total' => $total,
'pages' => max(1, (int)ceil($total / 10)),
];
}
/**
* Normalize page input from direct links, writable edits, and history restoration.
*/
protected function normalizePage(): void
{
$total = $this->fetchTable('Resources')->find('matching', search: $this->search)->count();
$this->page = max(1, min($this->page, max(1, (int)ceil($total / 10))));
}
}<?php
declare(strict_types=1);
use function Cake\Core\h;
/**
* templates/island/resource_search.php
*
* @var \Interact\View\TemplateView $this
* @var string $search
* @var int $page
* @var int $pages
* @var int $total
* @var iterable<\Cake\Datasource\EntityInterface> $resources
*/
?>
<?= $this->Form->create(null, ['url' => false, 'x-interact:submit' => '$server.sync()']) ?>
<?= $this->Form->control('search', [
'type' => 'search',
'label' => 'Search resources',
'value' => $search,
'maxlength' => 100,
'x-interact:model.live.debounce.300ms' => 'search',
]) ?>
<?= $this->Form->end() ?>
<p role="status"><?= $total ?> results. Page <?= $page ?> of <?= $pages ?>.</p>
<ul>
<?php foreach ($resources as $resource): ?>
<li><?=(string)h($resource->get('title')) ?></li>
<?php endforeach; ?>
</ul>
<button
type="button"
@click="$server.call('goTo', <?= $page - 1 ?>)"
<?= $page <= 1 ? 'disabled' : '' ?>
>
Previous
</button>
<button
type="button"
@click="$server.call('goTo', <?= $page + 1 ?>)"
<?= $page >= $pages ? 'disabled' : '' ?>
>
Next
</button>
<p role="alert" x-text="$server.error ? $server.error.message : ''"></p><?php
declare(strict_types=1);
namespace App\Model\Table;
use Cake\Database\Expression\FunctionExpression;
use Cake\ORM\Entity;
use Cake\ORM\Query\SelectQuery;
use Cake\ORM\Table;
use InvalidArgumentException;
/**
* Builds a literal, case-insensitive title search with deterministic ordering.
*
* This example uses SQLite's INSTR function. Save as src/Model/Table/ResourcesTable.php.
*/
class ResourcesTable extends Table
{
/**
* Use generic entities for the read-only result rows.
*/
public function initialize(array $config): void
{
parent::initialize($config);
$this->setEntityClass(Entity::class);
}
/**
* Return a composable query with bound search input and stable ordering.
*
* @param \Cake\ORM\Query\SelectQuery<\Cake\ORM\Entity> $query
*
* @return \Cake\ORM\Query\SelectQuery<\Cake\ORM\Entity>
*/
public function findMatching(SelectQuery $query, string $search = ''): SelectQuery
{
if (mb_strlen($search) > 100) {
throw new InvalidArgumentException('Search with up to 100 characters.');
}
$search = mb_strtolower(trim($search));
if ($search !== '') {
$lower = new FunctionExpression('LOWER', ['Resources.title' => 'identifier']);
$position = new FunctionExpression('INSTR', [$lower, $search]);
$query->where(static fn($expression) => $expression->gt($position, 0, 'integer'));
}
return $query->orderByAsc('Resources.title')->orderByAsc('Resources.id');
}
}CREATE TABLE resources (id INTEGER PRIMARY KEY, title VARCHAR(120) NOT NULL);
INSERT INTO resources (id, title) VALUES
(1, 'Resource 01'),
(2, 'Resource 02'),
(3, 'Resource 03'),
(4, 'Resource 04'),
(5, 'Resource 05'),
(6, 'Resource 06'),
(7, 'Resource 07'),
(8, 'Resource 08'),
(9, 'Resource 09'),
(10, 'Resource 10'),
(11, 'Resource 11'),
(12, 'Resource 12'),
(13, 'Resource 13'),
(14, 'Resource 14'),
(15, 'Resource 15'),
(16, 'Resource 16'),
(17, 'Resource 17'),
(18, 'Resource 18'),
(19, 'Resource 19'),
(20, 'Resource 20'),
(21, 'Resource 21'),
(22, 'Resource 22'),
(23, 'Resource 23'),
(24, 'Resource 24'),
(25, 'Resource 25');<?= $this->Interact->render('ResourceSearch') ?>Behavior
Typing sends the search after 300 ms and resets the page to one. goTo() bounds the requested page against the filtered count. Empty results remain on page one. Back and Forward restore the search and page together. updated() respects an explicitly supplied page, and the shared normalization method bounds initial links and updates. Rows and counts come from viewData(); only the search and page are transported.
The Table returns a composable query; the island chooses counting and pagination. The larger demo listing adds filters, allowlisted sorting, and page-size controls. See URL state for defaults, aliases, and history behavior.