jdz / adminui
Framework-agnostic admin UI rendering (list, columns, triggers, toolbar) and shared admin controller traits
Requires
- php: >=8.2
- jdz/data: ^2.0
- jdz/form: ^1.1
- jdz/htmlrenderer: ^1.0
- jdz/ui: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- jdz/mediamanager: ^2.0
- phpunit/phpunit: ^11.0
- slim/psr7: ^1.6
- symfony/http-foundation: ^7.4
Suggests
- jdz/mediamanager: Required by JDZ\AdminUi\Controller\MediasControllerTrait — the media library engine it drives
- symfony/http-foundation: Required by JDZ\AdminUi\Controller\MediasControllerTrait — the uploaded-file type jdz/mediamanager speaks
Provides
None
Conflicts
None
Replaces
None
README
Framework-agnostic admin UI rendering primitives.
Value objects that serialize (via toData()) to the JSON consumed by the JS
admin bundle — no HTTP, no database, no app lifecycle. Built on
jdz/htmlrenderer.
Contents
| Namespace | Classes |
|---|---|
JDZ\AdminUi\List |
Columns, Column, Triggers, Trigger, ItemActions, Row, RowInterface |
JDZ\AdminUi\Toolbar |
Toolbar, ToolbarButton, AdminToolbar |
JDZ\AdminUi\Item |
Item, ItemSection, ItemField, ItemTable — read-only detail views |
JDZ\AdminUi\Form |
FormView (wraps a jdz/form form with fieldset panel states), FormActions |
JDZ\AdminUi\ValueObject |
ModalChrome, Filterbar |
JDZ\AdminUi\Contract |
AdminServiceInterface, ListState, SaveResult — what an entity admin asks of the app |
JDZ\AdminUi\Controller\Traits |
AdminControllerTrait — the shared entity admin; MediasControllerTrait — the shared media manager (both opt-in, see below) |
JDZ\AdminUi\Controller |
PsrUploadedFileBridge — the PSR-7 → HttpFoundation uploaded-file adapter the media manager needs |
JDZ\AdminUi |
IconPrefix — which icon vocabulary the toolbars and triggers paint (see below) |
Scope
This package is the rendering layer only. Data access (queries, repositories) and the app lifecycle stay in the consuming framework. A list query builder, for example, is not part of this package.
The one exception is JDZ\AdminUi\Controller\ — shared admin behaviour, for
the screens where six back offices would otherwise grow six copies of the same
file. It is opt-in: nothing else in the package touches HTTP, and the extra
dependencies are suggested rather than required.
Upgrading to 2.1.0
Nothing breaks; one seam is added. The shared screens are now named through
adminViewName() (entity admin) and mediaView() (library) instead of literal
strings, and both still default to what 2.0.0 rendered:
protected function adminViewName(string $name): string // 'items', 'item' { return 'views/admin/' . $name . '.tmpl'; } protected function mediaView(string $name): string // 'display', 'selector', … { return 'views/admin/medias/' . $name . '.tmpl'; }
A site whose templates live under templates/admin/ overrides one line each.
The medias controller carries its own copy because it is standalone — it does
not use AdminControllerTrait.
The defaults become views/<name>.twig in 3.0.0.
Upgrading to 2.0.0
Two breaking changes, both one-line fixes:
- The two controller traits moved to
JDZ\AdminUi\Controller\Traits\— update theuselines. The old names still resolve (as deprecated traits that compose the new ones) so an upgrade need not be atomic; they go in 2.1.0. adminBaseUrl()and the newmediaBaseUrl()both default to/admin. A site served from another prefix must override them — the package no longer assumes one.
Icons
Toolbar buttons and row triggers take a bare icon name ('plus', 'edit') and
render it as a CSS class. Which class is the admin's business, not the package's:
use JDZ\AdminUi\IconPrefix; IconPrefix::use('icon icon-'); // -> class="icon icon-plus"
The default is glyphicons glyphicons-, which is what every consumer painted
before this was configurable — so an admin that says nothing keeps exactly the
markup it had. Set it once at bootstrap, before the first toolbar is built.
The entity admin
JDZ\AdminUi\Controller\Traits\AdminControllerTrait is one back-office component —
list, form, publish, delete, reorder, filterbar — as a single implementation.
It owns the whole callisto task vocabulary
(list add edit save apply cancel publish unpublish trash changeorder setFilters resetFilters plus the json publish/unpublish pair) and every
value object the list screen needs.
It splits into three questions.
What the component is — four hooks a controller must answer: its slug, its
title, how one row paints (buildRow), and what its form looks like
(buildForm).
Where the data lives — service(), returning a
JDZ\AdminUi\Contract\AdminServiceInterface. That is the whole data contract:
public function count(ListState $state): int; public function list(ListState $state): array; public function find(int $id): ?object; public function save(array $input, ?int $id): SaveResult; public function setPublished(array $ids, int $published): void; public function delete(array $ids): void; public function moveOrdering(int $id, int $newValue): void;
The controller never sees a query. ListState is the filterbar window
(limit, offset, search, orderBy, plus whatever extra selects the site
declared — $state->filter('idCat') casts one to the type of its default);
SaveResult is ok($id) or fail(['title' => 'Titre requis.']), and a
failure is what turns a save into a 422 re-render of the posted values instead
of a redirect. Validation and business rules live in the service, which is the
only layer that knows how a row is stored.
How the site's chrome works — six admin* hooks (adminRender,
adminFlash, adminCsrfToken, adminFilterState, adminFiltersSet,
adminFiltersReset), the same shape MediasControllerTrait uses for its
mediaRender* / mediaState* pair. Answer them once, in an abstract site-side
AdminController, and every component extends that:
abstract class AdminController // the site, once { use JDZ\AdminUi\Controller\Traits\AdminControllerTrait; public function __construct( protected AdminRenderer $renderer, protected Session $session, protected Filters $filters, ) {} protected function adminRender(ResponseInterface $r, string $view, array $vData, string $c, string $task): ResponseInterface { return $this->renderer->render($r, $view, $vData, $c, $task); } protected function adminFlash(string $message, string $type = 'success'): void { $this->renderer->flash($message, $type); } protected function adminCsrfToken(): string { return $this->session->csrfToken(); } protected function adminFilterState(string $c): array { return $this->filters->for($c); } protected function adminFiltersSet(string $c, array $filter): void { $this->filters->set($c, $filter); } protected function adminFiltersReset(string $c): void { $this->filters->reset($c); } } final class FormationsController extends AdminController // one component { public function __construct(AdminRenderer $r, Session $s, Filters $f, private FormationService $service) { parent::__construct($r, $s, $f); } protected function component(): string { return 'formations'; } protected function title(): string { return 'Formations'; } protected function service(): AdminServiceInterface { return $this->service; } protected function buildRow(object $item, bool $orderValid): Row { … } protected function buildForm(?object $item, array $input = []): Form { … } }
Hold the concrete service in the controller, not the interface — a container cannot instantiate an interface.
Everything else has a default worth keeping: buildColumns() paints the usual
checkbox / label / published / ordering / id / triggers set,
adminBaseUrl() is /admin, adminViewName() points at
views/admin/<name>.tmpl, hasSearch() is off, filterOptions() is empty,
orderingSort() is 'ordering ASC' (an admin listing newest first returns
'ordering DESC' and drag-reorder follows), and adminText() holds the French
wording of every flash, tooltip and confirm — override the keys a site wants to
reword.
JDZ\AdminUi\Form\FiltersForm is the filterbar form the trait builds: the
searchbox, the limit select, and one select per declared filter, under the
exact fieldset and field names Filterbar::fromFormData() and the admin
bundle read.
The JS contract, which must not drift
jizy.admin.js rewrites the toolbar hrefs with ?id=1,2,3, posts the
filterbar to json/{component}/setFilters/, expects {success:true} back from
changeorder, and reads the list view data off ordervalid, orderDir,
listHeaders, items, pagination and filterbar.filterActive. The trait
exists to keep exactly that shape in one place.
The media manager
MediasControllerTrait is the whole /admin media manager: browse, create /
rename / move / delete folders and files, upload, watermark, and the image
picker a form field opens. The filesystem work is
jdz/mediamanager; the browser is
mediamanager.js from the admin bundle; this is the adapter between them, and
it emits exactly the JSON that JS already expects.
composer require jdz/mediamanager
A consuming controller supplies the per-site parts and nothing else:
final class MediasController extends AdminController { use JDZ\AdminUi\Controller\Traits\MediasControllerTrait; protected function mediaConfig(): MediaConfig { /* where the library lives */ } protected function mediaTranslate(string $key, array $params = []): string { … } protected function mediaRenderFragment(string $view, array $vData): string { … } protected function mediaRenderPage(ResponseInterface $r, string $v, array $d): ResponseInterface { … } protected function mediaCsrfToken(): string { … } protected function mediaStateLoad(): array { … } protected function mediaStateSave(array $state): void { … } protected function mediaThumbUrl(string $relPath, int $width): string { … } protected function mediaSelectorImage(string $relPath, int $width): ?array { … } }
Routes (the paths JiZy.makeUrl() builds — they hang off mediaBaseUrl(),
which defaults to /admin; override that one method to serve the back office
somewhere else, or mediaTaskUrl() / mediaDownloadUrl() to reshape a single
URL). A medias controller does not inherit adminBaseUrl(): it is standalone,
so a site that moves its admin overrides both hooks — and likewise
mediaView() alongside adminViewName() when it renames its templates.
json/mediamanager/{init,fs,upload},
json/mediamanager/folder/{create,rename,move,delete},
json/mediamanager/file/{infos,rename,move,delete,protect,unprotect},
json/medias/selector, plus medias and medias/download. The create,
rename and move folder/file paths answer a GET with the dialog and a POST
with the operation.
The picker
json/medias/selector renders the modal JiZy.Admin.mediaPicker opens — on a
form's image field, and on the rich-text editor's image button. It always ships
the folder list alongside the tree, so the picker can switch folders without
going through the media manager screen, and json/mediamanager/folder/create
accepts a posted parent for the same reason: the picker creates a folder where
it is looking, not where the session last left the media screen. The created
folder comes back in folder so the caller can reload straight into it.
?mode=editor adds the caption / alt / anchoring fieldset the editor needs to
build its <figure>. Re-opening the picker on an existing figure passes src,
alt, caption and align back in and they arrive prefilled — align is
whitelisted against MEDIA_ALIGNMENTS rather than echoed, since it is query
string on its way into a view.
GET json/medias/selector?folder=blog
GET json/medias/selector?mode=editor&src=media/blog/x.jpg&alt=…&caption=…&align=right
POST json/mediamanager/folder/create m[parent]=blog&m[folderName]=2026
PsrUploadedFileBridge turns a PSR-7 upload into the HttpFoundation one the
uploader takes. It hands over the mime detected from the bytes, not the one
the browser declared, and the trait additionally checks the stored extension
against the configured whitelist — an upload endpoint inside the web root gets
both.
Install
composer require jdz/adminui