Item List
Click to edit names, change status via dropdown, delete with confirmation. Uses hx-swap-oob for notifications.
Click to edit names, change status via dropdown, delete with confirmation. Uses hx-swap-oob for notifications.
| ID | Name | Status | |
|---|---|---|---|
| 1 | Project Alpha |
active |
|
| 2 | Project Beta |
pending |
|
| 3 | Project Gamma |
active |
|
| 4 | Project Delta |
completed |
|
| 5 | Project Epsilon |
active |
hx-confirm shows native confirmationhx-swap-oob with CSS animation<?php
declare(strict_types=1);
namespace App\Controller;
use Mdxpl\HtmxBundle\Attribute\HtmxOnly;
use Mdxpl\HtmxBundle\Request\HtmxRequest;
use Mdxpl\HtmxBundle\Response\HtmxResponse;
use Mdxpl\HtmxBundle\Response\HtmxResponseBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/items')]
final class ItemListController extends AbstractController
{
private const DEFAULT_ITEMS = [
['id' => 1, 'name' => 'Project Alpha', 'status' => 'active'],
['id' => 2, 'name' => 'Project Beta', 'status' => 'pending'],
['id' => 3, 'name' => 'Project Gamma', 'status' => 'active'],
['id' => 4, 'name' => 'Project Delta', 'status' => 'completed'],
['id' => 5, 'name' => 'Project Epsilon', 'status' => 'active'],
];
private const TEMPLATE = 'item_list.html.twig';
#[Route('', name: 'app_items')]
public function index(HtmxRequest $htmx, Request $request): HtmxResponse
{
$items = $this->getItems($request);
return HtmxResponseBuilder::create($htmx->isHtmx)
->success()
->view(self::TEMPLATE, ['items' => $items])
->build();
}
#[Route('/{id}/edit', name: 'app_items_edit', methods: ['GET'])]
#[HtmxOnly]
public function edit(HtmxRequest $htmx, Request $request, int $id): HtmxResponse
{
$items = $this->getItems($request);
$item = $this->findItem($items, $id);
return HtmxResponseBuilder::create($htmx->isHtmx)
->success()
->viewBlock(self::TEMPLATE, 'editName', ['item' => $item])
->build();
}
#[Route('/{id}', name: 'app_items_update', methods: ['PUT'])]
#[HtmxOnly]
public function update(HtmxRequest $htmx, Request $request, int $id): HtmxResponse
{
$items = $this->getItems($request);
$newName = trim($request->request->getString('name'));
$item = $this->findItem($items, $id);
if ($newName === '') {
return HtmxResponseBuilder::create($htmx->isHtmx)
->failure()
->viewBlock(self::TEMPLATE, 'editName', ['item' => $item])
->viewBlock(self::TEMPLATE, 'notificationOob', ['type' => 'error', 'message' => 'Name cannot be empty'])
->build();
}
if (\strlen($newName) < 3) {
return HtmxResponseBuilder::create($htmx->isHtmx)
->failure()
->viewBlock(self::TEMPLATE, 'editName', ['item' => $item])
->viewBlock(self::TEMPLATE, 'notificationOob', ['type' => 'error', 'message' => 'Name must be at least 3 characters'])
->build();
}
foreach ($items as &$itemRef) {
if ($itemRef['id'] === $id) {
$itemRef['name'] = $newName;
break;
}
}
$this->saveItems($request, $items);
$item = $this->findItem($items, $id);
return HtmxResponseBuilder::create($htmx->isHtmx)
->success()
->viewBlock(self::TEMPLATE, 'viewName', ['item' => $item])
->viewBlock(self::TEMPLATE, 'notificationOob', ['type' => 'success', 'message' => "'{$newName}' name updated"])
->build();
}
#[Route('/{id}/status', name: 'app_items_status', methods: ['PUT'])]
#[HtmxOnly]
public function updateStatus(HtmxRequest $htmx, Request $request, int $id): HtmxResponse
{
$items = $this->getItems($request);
$newStatus = $request->request->getString('status');
$itemName = $this->findItem($items, $id)['name'] ?? 'Item';
foreach ($items as &$item) {
if ($item['id'] === $id) {
$item['status'] = $newStatus;
break;
}
}
$this->saveItems($request, $items);
$item = $this->findItem($items, $id);
return HtmxResponseBuilder::create($htmx->isHtmx)
->success()
->viewBlock(self::TEMPLATE, 'viewStatus', ['item' => $item])
->viewBlock(self::TEMPLATE, 'notificationOob', ['type' => 'success', 'message' => "'{$itemName}' status updated"])
->build();
}
#[Route('/{id}', name: 'app_items_delete', methods: ['DELETE'])]
#[HtmxOnly]
public function delete(HtmxRequest $htmx, Request $request, int $id): HtmxResponse
{
$items = $this->getItems($request);
$deletedItem = null;
foreach ($items as $key => $item) {
if ($item['id'] === $id) {
$deletedItem = $item;
unset($items[$key]);
break;
}
}
$items = array_values($items);
$this->saveItems($request, $items);
$deletedName = $deletedItem['name'] ?? 'Unknown';
$builder = HtmxResponseBuilder::create($htmx->isHtmx)
->success()
->viewBlock(self::TEMPLATE, 'empty')
->viewBlock(self::TEMPLATE, 'notificationOob', ['type' => 'success', 'message' => "'{$deletedName}' deleted"]);
if ($items === []) {
$builder->viewBlock(self::TEMPLATE, 'itemsListOob', ['items' => []]);
}
return $builder->build();
}
#[Route('/reset', name: 'app_items_reset', methods: ['POST'])]
#[HtmxOnly]
public function reset(HtmxRequest $htmx, Request $request): HtmxResponse
{
$this->saveItems($request, self::DEFAULT_ITEMS);
return HtmxResponseBuilder::create($htmx->isHtmx)
->success()
->viewBlock(self::TEMPLATE, 'itemsList', ['items' => self::DEFAULT_ITEMS])
->build();
}
/**
* @return array<int, array{id: int, name: string, status: string}>
*/
private function getItems(Request $request): array
{
$items = $request->getSession()->get('item_list_items');
if ($items === null) {
$items = self::DEFAULT_ITEMS;
$this->saveItems($request, $items);
}
return $items;
}
/**
* @param array<int, array{id: int, name: string, status: string}> $items
*/
private function saveItems(Request $request, array $items): void
{
$request->getSession()->set('item_list_items', $items);
}
/**
* @param array<int, array{id: int, name: string, status: string}> $items
*
* @return array{id: int, name: string, status: string}|null
*/
private function findItem(array $items, int $id): ?array
{
foreach ($items as $item) {
if ($item['id'] === $id) {
return $item;
}
}
return null;
}
}
{% block notificationContent %}{% endblock %}
{% block notificationOob %}
<div id="notification" hx-swap-oob="true" style="margin-bottom: 1rem;">
<div class="notification-alert {{ type == 'error' ? 'notification-error' : 'notification-success' }}">
<span>{{ message }}</span>
<button type="button" style="float: right; background: none; border: none; cursor: pointer;" onclick="this.closest('.notification-alert').parentElement.innerHTML = ''">
×
</button>
</div>
</div>
{% endblock %}
{% block itemsList %}
{% if items|length > 0 %}
<figure>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr id="item-row-{{ item.id }}">
<td>{{ item.id }}</td>
<td id="item-name-{{ item.id }}">
{{ block('viewName') }}
</td>
<td id="item-status-{{ item.id }}">
{{ block('viewStatus') }}
</td>
<td>
<button class="outline btn-sm"
style="color: var(--pico-del-color); border-color: var(--pico-del-color);"
hx-delete="{{ path('app_items_delete', {id: item.id}) }}"
hx-target="#item-row-{{ item.id }}"
hx-swap="outerHTML swap:300ms"
hx-confirm="Are you sure you want to delete '{{ item.name }}'?">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% else %}
<p><ins>No items left.</ins> Click "Reset List" to restore.</p>
{% endif %}
{% endblock %}
{% block viewName %}
<span class="edit-link"
hx-get="{{ path('app_items_edit', {id: item.id}) }}"
hx-target="#item-name-{{ item.id }}"
hx-swap="innerHTML">
{{ item.name }}
</span>
{% endblock %}
{% block editName %}
<form style="display: flex; gap: 0.5rem; align-items: center;"
hx-put="{{ path('app_items_update', {id: item.id}) }}"
hx-target="#item-name-{{ item.id }}"
hx-swap="innerHTML">
<input type="text"
name="name"
value="{{ item.name }}"
style="width: 150px; padding: 0.25rem 0.5rem;"
autocomplete="off"
autofocus />
<button type="submit" class="btn-sm">Save</button>
<button type="button" class="outline btn-sm"
hx-get="{{ path('app_items') }}"
hx-target="#item-name-{{ item.id }}"
hx-select="#item-name-{{ item.id }} > *">
Cancel
</button>
</form>
{% endblock %}
{% block viewStatus %}
<details class="dropdown">
<summary>
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
</summary>
<ul>
{% for status in ['active', 'pending', 'completed'] %}
<li>
<button hx-put="{{ path('app_items_status', {id: item.id}) }}"
hx-vals='{"status": "{{ status }}"}'
hx-target="#item-status-{{ item.id }}"
hx-swap="innerHTML"
onclick="this.closest('details').open = false">
{{ status|capitalize }}
</button>
</li>
{% endfor %}
</ul>
</details>
{% endblock %}
{% block itemsListOob %}
<div id="items-list" hx-swap-oob="true">
{{ block('itemsList') }}
</div>
{% endblock %}
{% block empty %}{% endblock %}