Getting started
A complete component from scratch: a page with a list of new support tickets, an action per ticket and one for all of them, the view and the tests.
A page “Triage” lists new support tickets. Each ticket can be marked as read; a button marks all of them as read. The pieces:
App\Pages\TriagePage: the page, provides the componentApp\Components\TriageComponent: actions and renderingapp/Views/triage/tickets.php: the HTML- a test
The ticket model is an Anvil model (Anvil\Data\Model) with the columns
subject and status; tenant scope, UUIDs and the audit log come from
the model.
The page
Section titled “The page”namespace App\Pages;
use Anvil\Endpoint\Component;use Anvil\Endpoint\ComponentClaims;use Anvil\Page\HasComponents;use Anvil\Page\Page;use App\Components\TriageComponent;
final class TriagePage extends Page implements HasComponents{ public function title(): string { return 'Triage'; }
public function navigationIcon(): string { return 'inbox'; }
// Called by the endpoint for the id "<panel>:triage:<kind>". public function component(string $kind): ?Component { return $kind === TriageComponent::KIND ? new TriageComponent($this->panel) : null; }
public function content(): string { return (new TriageComponent($this->panel))->render(new ComponentClaims( component: $this->panel->id() . ':' . $this->slug() . ':' . TriageComponent::KIND, panel: $this->panel->id(), tenantId: service('tenantContext')->id(), )); }}Register the page in Panel::pages() like any page. Its slug is triage,
so the component id is admin:triage:tickets in the panel admin.
The component
Section titled “The component”namespace App\Components;
use Anvil\Endpoint\Component;use Anvil\Endpoint\ComponentAction;use Anvil\Endpoint\ComponentClaims;use Anvil\Endpoint\ComponentRequest;use Anvil\Endpoint\ComponentResponse;use Anvil\Endpoint\EndpointException;use Anvil\Panel\Panel;use App\Models\TicketModel;
final readonly class TriageComponent implements Component{ public const string KIND = 'tickets';
public function __construct( private Panel $panel, ) { }
public function actions(ComponentClaims $claims): array { return [ new ComponentAction( name: 'markRead', permission: 'tickets.update', handler: $this->markRead(...), rules: ['ticket' => ['required', 'valid_uuid']], ), new ComponentAction( name: 'markAllRead', permission: 'tickets.update', handler: $this->markAllRead(...), ), ]; }
/** * Renders the list with a fresh token: used by the page and by every action. */ public function render(ComponentClaims $claims): string { return view('triage/tickets', [ 'tickets' => (new TicketModel())->where('status', 'new')->orderBy('created_at')->asArray()->findAll(50), 'token' => service('componentTokens')->issue($claims), 'endpoint' => $this->panel->endpointUrl(), ], ['saveData' => false]); }
private function markRead(ComponentRequest $request): ComponentResponse { $tickets = new TicketModel(); $ticket = $tickets->asArray()->findByUuid($request->string('ticket')) ?? throw EndpointException::forNotFound('ticket');
$tickets->update($ticket['id'], ['status' => 'read']);
return ComponentResponse::html($this->render($request->claims)); }
private function markAllRead(ComponentRequest $request): ComponentResponse { $tickets = new TicketModel(); $ids = array_column($tickets->select('id')->where('status', 'new')->asArray()->findAll(), 'id');
if ($ids !== []) { $tickets->update($ids, ['status' => 'read']); }
return ComponentResponse::html($this->render($request->claims)) ->withEvent('anvil:notify', ['message' => count($ids) . ' tickets marked as read', 'type' => 'success']); }}- The ticket comes from the input as a UUID (never an internal id) and is looked up through the model, so a ticket of another tenant is not found (404).
- Both actions change data, so they run in a transaction and are written to
the audit log as
component.action(defaultmutates: true).
The view
Section titled “The view”<?php/** * @var list<array<string, mixed>> $tickets * @var string $token * @var string $endpoint */// Token and action on the button, never on a container (see "htmx conventions").$vals = static fn (array $values): string => esc(json_encode(['_token' => $token, ...$values], JSON_THROW_ON_ERROR), 'attr');?><div id="triage" class="card"> <div class="card-header d-flex align-items-center"> <span class="fw-semibold me-auto">New tickets</span><?php if ($tickets !== []): ?> <button type="button" class="btn btn-sm btn-outline-secondary" hx-post="<?= esc($endpoint, 'attr') ?>" hx-vals="<?= $vals(['_action' => 'markAllRead']) ?>" hx-target="#triage" hx-swap="outerHTML" hx-confirm="Mark all tickets as read?">Mark all as read</button><?php endif ?> </div><?php if ($tickets === []): ?> <div class="anvil-empty py-4"><i class="bi bi-inbox" aria-hidden="true"></i>No new tickets</div><?php else: ?> <ul class="list-group list-group-flush"><?php foreach ($tickets as $ticket): ?> <li class="list-group-item d-flex align-items-center gap-2"> <span class="me-auto"><?= esc($ticket['subject']) ?></span> <button type="button" class="btn btn-sm btn-link" hx-post="<?= esc($endpoint, 'attr') ?>" hx-vals="<?= $vals(['_action' => 'markRead', 'ticket' => $ticket['uuid']]) ?>" hx-target="#triage" hx-swap="outerHTML">Mark as read</button> </li><?php endforeach ?> </ul><?php endif ?></div>- The answer is the whole component (
<div id="triage">…), and the buttons replace it (hx-target="#triage" hx-swap="outerHTML"). The new HTML carries a new token. hx-confirmopens Anvil’s confirmation dialog instead of the browser’sconfirm(); the question is inserted as text.- The CSRF header is added to every htmx request by
anvil.js.
The test
Section titled “The test”use Anvil\Data\Uuid;use App\Models\TicketModel;
final class TriagePageTest extends AppTestCase // your base class, see Testing{ public function testMarkRead(): void { $this->grantPermissions(['tickets.update']); $uuid = $this->createTicket('Printer is broken');
$response = $this->dispatchComponent('admin', 'triage:tickets', 'markRead', ['ticket' => $uuid]);
$this->assertStringNotContainsString('Printer is broken', $response->html); $this->seeInDatabase('tickets', ['subject' => 'Printer is broken', 'status' => 'read']); }
public function testMarkAllReadNotifies(): void { $this->grantPermissions(['tickets.update']); $this->createTicket('A'); $this->createTicket('B');
$response = $this->dispatchComponent('admin', 'triage:tickets', 'markAllRead');
$this->assertSame('2 tickets marked as read', $response->events['anvil:notify']['message']); }
public function testNeedsPermission(): void { $this->grantPermissions(['tickets.view']); $uuid = $this->createTicket('A');
$this->assertRejected(403, fn () => $this->dispatchComponent('admin', 'triage:tickets', 'markRead', ['ticket' => $uuid])); }
public function testUnknownTicket(): void { $this->grantPermissions(['tickets.update']);
$this->assertRejected(404, fn () => $this->dispatchComponent('admin', 'triage:tickets', 'markRead', ['ticket' => Uuid::v7()->toString()])); }
public function testTicketMustBeAUuid(): void { $this->grantPermissions(['tickets.update']);
$e = $this->assertRejected(422, fn () => $this->dispatchComponent('admin', 'triage:tickets', 'markRead', ['ticket' => '42']));
$this->assertArrayHasKey('ticket', $e->errors); }
private function createTicket(string $subject): string { $tickets = new TicketModel(); $tickets->insert(['subject' => $subject, 'status' => 'new']);
return (string) $tickets->getInsertUuid(); }}See Testing components for the helpers.