Skip to content

Admin and Forms ​

So far the front end shows the data generated by the seeder. Now we build an admin area to manage it.

Create the Admin Controller and List View ​

Run:

bash
php windwalker g controller Admin/Article
php windwalker g view Admin/Article/ArticleListView
php windwalker g route admin/article

Route:

php
// routes/admin/article.route.php

use App\Module\Admin\Article\ArticleController;
use App\Module\Admin\Article\ArticleListView;
use Windwalker\Core\Router\RouteCreator;

/** @var RouteCreator $router */

$router->group('article')
    ->register(function (RouteCreator $router) {
        $router->any('article_list', '/article/list')
            ->controller(ArticleController::class)
            ->view(ArticleListView::class);
    });

Admin routes are loaded by routes/admin.route.php, which adds the /admin URL prefix and the admin route namespace. That is why the page is served at /admin/article/list, and why $nav->to('article_list') inside the admin module resolves to the admin::article_list route.

WARNING

Make sure ArticleListView is the Admin class, not the Front one.

View:

php
// src/Module/Admin/Article/ArticleListView.php

use App\Entity\Article;
use Windwalker\Core\Application\AppContext;
use Windwalker\Core\Attributes\Filter;
use Windwalker\Core\Attributes\Request\Input;
use Windwalker\Core\Attributes\ViewMetadata;
use Windwalker\Core\Attributes\ViewModel;
use Windwalker\Core\Attributes\ViewPrepare;
use Windwalker\Core\Html\HtmlFrame;
use Windwalker\Core\Pagination\PaginationFactory;
use Windwalker\Core\View\View;
use Windwalker\ORM\ORM;

#[ViewModel(
    layout: 'article-list',
    js: 'article-list.ts'
)]
class ArticleListView
{
    public function __construct(
        protected ORM $orm,
        protected PaginationFactory $paginationFactory
    ) {
        //
    }

    #[ViewPrepare]
    public function prepare(
        AppContext $app,
        View $view,
        #[Input, Filter('int|range(min: 1)')]
        int $page = 1,
    ): array {
        $limit = 5;

        $query = $this->orm->from(Article::class)
            ->offset(($page - 1) * $limit)
            ->order('id', 'DESC')
            ->limit($limit);

        $pagination = $this->paginationFactory->create($page, $limit)
            ->total(fn () => $this->orm->countWith($query));

        $items = $query->getIterator(Article::class);

        return compact('items', 'pagination');
    }

    #[ViewMetadata]
    public function prepareMetadata(HtmlFrame $htmlFrame): void
    {
        $htmlFrame->setTitle('Article Management');
    }
}

TIP

It is very similar to the front end, with a few differences: there is no state condition, the order is id DESC, and the title is set in #[ViewMetadata].

article-list.blade.php:

php
// src/Module/Admin/Article/views/article-list.blade.php

/**
 * @var $items \App\Entity\Article[]
 */
?>

@extends('global.body')

@section('content')
    <div class="container">

        <table class="table table-striped">
            <thead>
            <tr>
                <th style="width: 5%">Image</th>
                <th>Title</th>
                <th style="width: 10%">State</th>
                <th style="width: 5%">Delete</th>
                <th style="width: 5%" class="text-end">ID</th>
            </tr>
            </thead>
            <tbody>
            @foreach ($items as $item)
                <tr>
                    <td>
                        <img style="height: 30px" src="{{ $item->image }}" alt="image">
                    </td>
                    <td>
                        <a href="{{ $nav->to('article_edit')->id($item->id) }}">
                            {{ $item->title }}
                        </a>
                    </td>
                    <td>
                        @if ($item->state === \App\Enum\ItemState::ENABLED)
                            Enabled
                        @else
                            Disabled
                        @endif
                    </td>
                    <td>
                        <button type="button" class="btn btn-sm btn-outline-danger">
                            Delete
                        </button>
                    </td>
                    <td class="text-end">
                        {{ $item->id }}
                    </td>
                </tr>
            @endforeach
            </tbody>
        </table>

        <div class="my-4">
            <x-pagination :pagination="$pagination"></x-pagination>
        </div>
    </div>
@stop

Like the front end it loops over the items, but this time as a table.

Open http://{site}/admin/article/list. The page shows a table of articles with their image, title, state, a Delete button and ID, with pagination below.

TIP

If the admin header covers the top of the page, open resources/assets/scss/admin/layout/_base.scss and add padding-top: 75px; to body.

Enum Meta ​

Above we used if else to print the enum value. Another way is the built-in #[Title] attribute, which provides a (translatable) name, and you can add an icon and color too:

php
use Windwalker\Utilities\Attributes\Enum\Color;
use Windwalker\Utilities\Attributes\Enum\Icon;
use Windwalker\Utilities\Attributes\Enum\Title;

// ...

enum ItemState: int implements EnumRichInterface
{
    use EnumRichTrait;

    #[Title('Enabled')]
    #[Icon('fa-solid fa-check')]
    #[Color('success')]
    case ENABLED = 1;

    #[Title('Disabled')]
    #[Icon('fa-solid fa-xmark')]
    #[Color('danger')]
    case DISABLED = 0;
}

Change the HTML to:

html
<td>
    <div class="text-{{ $item->state->getColor() }}">
        <i class="{{ $item->state->getIcon() }}"></i>
        {{ $item->state->getTitle() }}
    </div>
</td>

The result should look like below. If you cannot see the icons, add this to src/Module/Admin/AdminMiddleware.php:

php
$this->asset->css('vendor/@fortawesome/fontawesome-free/css/all.css');

The state column now shows a green check icon with "Enabled" for enabled articles, and a red cross icon with "Disabled" for disabled ones.

Add a Filter Form ​

Next we use the Form Builder. Install it first:

bash
composer require windwalker/form

After the package is downloaded, installation should complete automatically. Then create a form definition:

bash
php windwalker g form Admin/Article/ArticleGridForm

Open it, remove the generated basic() method, and use the following code:

php
use Windwalker\Form\Field\ListField;
use Windwalker\Form\Field\SearchField;

// ...

class ArticleGridForm
{
    #[FormDefine]
    public function filter(Form $form): void
    {
        $form->add('q', SearchField::class)
            ->label('Search')
            ->placeholder('Enter search keywords')
            ->addClass('form-control');

        $form->add('state', ListField::class)
            ->label('State')
            ->addClass('form-select')
            ->option('- Select -', '')
            ->option('Enabled', '1')
            ->option('Disabled', '0')
            ->onchange('this.form.submit()');
    }
}

Then build the form in the admin ArticleListView and modify the query:

diff
// src/Module/Admin/Article/ArticleListView.php

use Windwalker\Core\Attributes\Request\InputCompact;
use Windwalker\Data\Collection;
use Windwalker\Query\Query;

// ...

    public function __construct(
        protected ORM $orm,
        protected PaginationFactory $paginationFactory,
+        protected FormFactory $formFactory,
    ) {
        //
    }

    #[ViewPrepare]
    public function prepare(
        AppContext $app,
        View $view,
        #[Input, Filter('int|range(min: 1)')]
        int $page = 1,
+        #[InputCompact('q', 'state')] Collection $filter = new Collection(),
    ): array {
        $limit = 5;

+        [$q, $state] = $filter->values()->dump();

        $query = $this->orm->from(Article::class)
+            // Add state where, if filter exists.
+            ->tapIf(
+                (string) $state !== '',
+                fn (Query $query) => $query->where('state', '=', $state)
+            )
            ->offset(($page - 1) * $limit)
            ->order('id', 'DESC')
            ->limit($limit);

+        // Add search
+        if ($q !== '') {
+            $query->orWhere(
+                function (Query $query) use ($q) {
+                    $query->where('title', 'like', '%' . $q . '%');
+                    $query->where('content', 'like', '%' . $q . '%');
+                }
+            );
+        }

        $pagination = $this->paginationFactory->create($page, $limit)
            ->total(fn () => $this->orm->countWith($query));

        $items = $query->getIterator(Article::class);

+        $form = $this->formFactory->create(ArticleGridForm::class)
+            // Fill q and state value to form
+            ->fill(
+                [
+                    'q' => $q,
+                    'state' => $state
+                ]
+            );

        return compact(
            'items',
            'pagination',
+            'form'
        );
    }

In the template, wrap the table with a <form> element, then render the form fields:

php
use Windwalker\Form\Form;

/**
 * @var \App\Entity\Article[] $items
 * @var Form                  $form
 */

// Use the simple renderer to render the form fields
$form->setRenderer(new \Windwalker\Form\Renderer\SimpleRenderer());

?>

...

    <form id="admin-form" action="{{ $nav->to('article_list') }}" method="get">

        <div class="d-flex gap-3 mb-5">
            {!! $form->renderField('q') !!}
            {!! $form->renderField('state') !!}

            {{-- A hidden submit button, so pressing Enter submits the search --}}
            <button type="submit" style="display: none"></button>
        </div>

        <!-- The original table -->
        <table class="table table-striped">
        ...
        </table>

    </form>

Now you can search and filter. Type a keyword into the search field and press Enter to filter by title or content, or pick a value from the state select, which submits the form immediately. The pagination keeps the current filter.

Create the Edit Page and Form ​

Now create a single-record edit form. Many steps are the same as before, so we won't repeat the explanation.

Run:

bash
php windwalker g view Admin/Article/ArticleEditView
php windwalker g form Admin/Article/ArticleEditForm

Then modify the route:

php
// routes/admin/article.route.php

use App\Module\Admin\Article\ArticleEditView;

// ...

$router->group('article')
    ->register(function (RouteCreator $router) {
        $router->any('article_list', '/article/list')
            ->controller(ArticleController::class)
            ->view(ArticleListView::class);

        // Add this route
        $router->any('article_edit', '/article/edit[/{id}]')
            ->controller(ArticleController::class)
            ->view(ArticleEditView::class);
    });

Edit ArticleEditForm:

php
// src/Module/Admin/Article/Form/ArticleEditForm.php

//...

    #[FormDefine]
    #[Fieldset('basic')]
    public function basic(Form $form): void
    {
        $form->add('id', HiddenField::class);

        $form->add('title', TextField::class)
            ->label('Title')
            ->addClass('form-control')
            ->addFilter('trim')
            ->required(true)
            ->maxlength(255);

        $form->add('state', RadioField::class)
            ->label('State')
            ->option('Enabled', '1')
            ->option('Disabled', '0');

        $form->add('content', TextareaField::class)
            ->label('Content')
            ->addClass('form-control')
            ->rows(10);
    }

Edit ArticleEditView:

php
// src/Module/Admin/Article/ArticleEditView.php

// ...

class ArticleEditView
{
    public function __construct(
        protected ORM $orm,
        protected FormFactory $formFactory
    ) {
        //
    }

    #[ViewPrepare]
    public function prepare(
        AppContext $app,
        View $view,
        #[Input, Filter('int')] ?int $id = null
    ): array {
        $item = $this->orm->findOne(Article::class, $id);

        $form = $this->formFactory->create(ArticleEditForm::class)
            ->fill($this->orm->extractEntity($item));

        $view->setTitle('Edit Article');

        return compact('item', 'form');
    }

    #[ViewMetadata]
    public function viewMetadata(HtmlFrame $htmlFrame): void
    {
        $htmlFrame->setTitle('Edit Article');
    }
}

Then edit article-edit.blade.php:

php
// src/Module/Admin/Article/views/article-edit.blade.php

/**
 * @var \App\Entity\Article   $item
 * @var \Windwalker\Form\Form $form
 */

// Use the simple renderer to render the form fields
$form->setRenderer(new \Windwalker\Form\Renderer\SimpleRenderer());
?>

@extends('global.body')

@section('content')
    <div class="container my-5">
        <form id="admin-form" action="{{ $nav->to('article_edit') }}" method="post">
            <div class="row">
                <div class="col-lg-8">
                    {!! $form->renderFields(); !!}

                    <div class="mt-4">
                        <button type="submit" class="btn btn-primary">
                            Save
                        </button>
                    </div>
                </div>
            </div>
        </form>
    </div>
@stop

Because the links were already added in the list view, clicking an article title now shows the edit page, with the title input, state radio buttons, content textarea and a Save button.

Save with the Controller ​

Now add a save function to this form. Because we use messages, a session is required. Install it:

bash
composer require windwalker/session

Then add a save method to the controller:

php
// src/Module/Admin/Article/ArticleController.php

// ...
class ArticleController
{
    public function save(
        AppContext $app,
        ORM $orm,
        Navigator $nav,
        #[InputCompact('id', 'title', 'state', 'content')] Collection $item = new Collection()
    ) {
        /** @var Article $item */
        $item = $orm->saveOne(Article::class, $item);

        $app->addMessage('Saved successfully', 'success');

        return $nav->to('article_list');
    }
}

Go back to the edit page, change the title and press Save. After saving, you are redirected to the list and a green "Saved successfully" message is displayed at the top of the page.

Create a New Record ​

You can also add a button to the list view that links to the edit page without an id. That creates a new record:

php
<div class="my-2">
    <a href="{{ $nav->to('article_edit') }}" class="btn btn-primary btn-sm">
        New Item
    </a>
</div>

Click New Item to open an empty edit form. After you fill it in and save, the new article appears at the top of the list.

Delete ​

Deleting is a little more complex, because we use JS to switch the form to the DELETE method.

Edit article-list.ts:

ts
// src/Module/Admin/Article/assets/article-list.ts

const delButtons = document.querySelectorAll<HTMLButtonElement>('button[data-delete]');

for (const button of delButtons) {
  button.addEventListener('click', (e) => {
    deleteItem(button.dataset.delete!);
  });
}

function deleteItem(id: string) {
  if (!confirm('Are you sure you want to delete this item?')) {
    return;
  }

  const form = document.querySelector<HTMLFormElement>('#admin-form')!;
  form.method = 'post';

  const idInput = document.createElement('input');
  idInput.value = id;
  idInput.name = 'id';
  idInput.type = 'hidden';

  form.appendChild(idInput);

  const methodInput = document.createElement('input');
  methodInput.value = 'DELETE';
  methodInput.name = '_method';
  methodInput.type = 'hidden';

  form.appendChild(methodInput);

  form.submit();
}

It looks long, but the idea is simple: insert two inputs, id and _method, into the form and change the form method to post.

TIP

REST has many methods, including GET, POST, PUT, PATCH and DELETE.

HTML forms only support GET and POST, so we use _method to simulate DELETE. Note that _method only takes effect when the form method is post.

After editing, run this so the JS is compiled:

bash
yarn dev

Then add data-delete to the delete button in article-list.blade.php:

php
// src/Module/Admin/Article/views/article-list.blade.php
    ...

    <td>
        <button type="button" class="btn btn-sm btn-outline-danger"
            data-delete="{{ $item->id }}">
            Delete
        </button>
    </td>

    ...

Then add a delete method to the controller:

php
// src/Module/Admin/Article/ArticleController.php

// ...

    public function delete(
        AppContext $app,
        ORM $orm,
        Navigator $nav,
        #[Input] string $id,
    ): RouteUri {
        $orm->deleteWhere(Article::class, (int) $id);

        $app->addMessage('Item deleted successfully', 'success');

        return $nav->to('article_list');
    }

Now clicking Delete shows a confirmation dialog. After you confirm, the item is removed and an "Item deleted successfully" message is displayed.

Released under the MIT License.