diff --git a/docs/book/v6/tutorials/creating-a-module-via-dot-maker.md b/docs/book/v6/tutorials/creating-a-module-via-dot-maker.md new file mode 100644 index 0000000..4064198 --- /dev/null +++ b/docs/book/v6/tutorials/creating-a-module-via-dot-maker.md @@ -0,0 +1,980 @@ +# Implementing a book module in Dotkernel Admin using dotkernel/dot-maker + +The `dotkernel/dot-maker` library can be used to programmatically generate project files and directories. +It can be added to your API installation by following the [official documentation](https://docs.dotkernel.org/dot-maker/). + +## Folder and files structure + +The below files structure is what we will have at the end of this tutorial and is just an example, +you can have multiple components such as event listeners, wrappers, etc. + +```markdown +. +└── src/ + ├── Book/ + │ ├── src/ + │ │ ├── Handler/ + │ │ │ ├── GetCreateBookFormHandler.php + │ │ │ ├── GetDeleteBookFormHandler.php + │ │ │ ├── GetEditBookFormHandler.php + │ │ │ ├── GetListBookHandler.php + │ │ │ ├── PostCreateBookHandler.php + │ │ │ ├── PostDeleteBookHandler.php + │ │ │ └── PostEditBookHandler.php + │ │ ├── InputFilter/ + │ │ │ ├── Input/ + │ │ │ │ └── ConfirmDeleteBookInput.php + │ │ │ ├── CreateBookInputFilter.php + │ │ │ ├── DeleteBookInputFilter.php + │ │ │ └── EditBookInputFilter.php + │ │ ├── Service/ + │ │ │ ├── BookService.php + │ │ │ └── BookServiceInterface.php + │ │ ├── ConfigProvider.php + │ │ └── RoutesDelegator.php + │ └── templates/ + │ └── book/ + │ ├── create-book-form.html.twig + │ ├── delete-book-form.html.twig + │ ├── edit-book-form.html.twig + │ └── list-book.html.twig + └── Core/ + └── src/ + └── Book/ + └── src/ + ├──Entity/ + │ └──Book.php + ├──Repository/ + │ └──BookRepository.php + └── ConfigProvider.php +``` + +* `src/Book/src/Handler/GetCreateBookFormHandler.php` – handler that reflects the GET action for the `CreateBookForm` class +* `src/Book/src/Handler/GetDeleteBookFormHandler.php` – handler that reflects the GET action for the `DeleteBookForm` class +* `src/Book/src/Handler/GetEditBookFormHandler.php` – handler that reflects the GET action for the `EditBookForm` class +* `src/Book/src/Handler/GetListBookHandler.php` – handler that reflects the GET action for a configurable list of `Book` entities +* `src/Book/src/Handler/PostCreateBookHandler.php` – handler that reflects the POST action for creating a `Book` entity +* `src/Book/src/Handler/PostDeleteBookHandler.php` – handler that reflects the POST action for deleting a `Book` entity +* `src/Book/src/Handler/PostEditBookHandler.php` – handler that reflects the POST action for editing a `Book` entity +* `src/Book/src/InputFilter/Input/*` – input filters and validator configurations +* `src/Book/src/InputFilter/CreateBookInputFilter.php` – input filters and validators +* `src/Book/src/InputFilter/EditBookInputFilter.php` – input filters and validators +* `src/Book/src/InputFilter/DeleteBookInputFilter.php` – input filters and validators +* `src/Book/src/Service/BookService.php` – is a class or component responsible for performing a specific task or providing functionality to other parts of the application +* `src/Book/src/Service/BookServiceInterface.php` – interface that reflects the publicly available methods in `BookService` +* `src/Book/src/ConfigProvider.php` – is a class that provides configuration for various aspects of the framework or application +* `src/Book/src/RoutesDelegator.php` – a routes delegator is a delegator factory responsible for configuring routing middleware based on routing configuration provided by the application +* `src/Book/templates/book/create-book-form.html.twig` – a Twig template for generating the view for the `CreateBookForm` class +* `src/Book/templates/book/delete-book-form.html.twig` – a Twig template for generating the view for the `DeleteBookForm` class +* `src/Book/templates/book/edit-book-form.html.twig` – a Twig template for generating the view for the `EditBookForm` class +* `src/Book/templates/book/list-book.html.twig` – a Twig template for generating the view for the list of `Book` entities +* `src/Core/src/Book/src/Entity/Book.php` – an entity refers to a PHP class that represents a persistent object or data structure +* `src/Core/src/Book/src/Repository/BookRepository.php` – a repository is a class responsible for querying and retrieving entities from the database +* `src/Core/src/Book/src/ConfigProvider.php` – is a class that provides configuration for Doctrine ORM + +> Note that while this tutorial covers a standalone case, the `Core` module generated by default has the same structure as the one described in the +> [Dotkernel API "Book" module](https://docs.dotkernel.org/api-documentation/v6/tutorials/create-book-module-via-dot-maker/) +> allowing use as part of the [Dotkernel Headless Platform](https://docs.dotkernel.org/headless-documentation/) + +## File creation and contents + +After successfully installing `dot-maker`, it can be used to generate the Book module. +Invoke `dot-maker` by executing `./vendor/bin/dot-maker` or via the optional script described in the documentation - `composer make`. +This will list all component types that can be created - for the purposes of this tutorial, enter `module`: + +```shell +./vendor/bin/dot-maker module +``` + +Type `book` when prompted to enter the module name. + +Next you will be prompted to add the relevant components of a module, accepting `y(es)`, `n(o)` and `Enter` (defaults to `yes`): + +> Note that `dot-maker` will automatically split the files into the described `Api` and `Core` structure without a further input needed. + +* `Entity and repository` (Y): will generate the `Book.php` entity and the associated `BookRepository.php`. +* `Service` and `service interface` (Y): will generate the `BookService` and the `BookServiceInterface`. +* `Command`, followed by `middleware`(N): not necessary for the module described in this tutorial. +* `Handler` (Y): this option is needed, and will further prompt you for the required actions. + * `Allow listing Books?` (Y): this will generate the `GetListBookHandler.php` class and the `list-book.html.twig`. + * `Allow viewing Books?` (N): not necessary for the module described in this tutorial. + * `Allow creating Books?` (Y): will generate all files used for creating `Book` entities, as follows: + * The form used for creation `CreateBookForm` as well as the input filter it uses `CreateBookInputFilter` + * The handler that fetches the form `GetCreateBookFormHandler` + * The handler for the POST action `PostCreateBookHandler` + * The template file used for the form `create-book-form.html.twig` + * `Allow deleting Books?` (Y): similar to the previous step, this step will generate multiple files: + * The form used for creation `DeleteBookForm`, the input filter it uses `DeleteBookInputFilter` as well as a singular Input class it uses - `ConfirmDeleteBookInput` + * The handler that fetches the form `GetDeleteBookFormHandler` + * The handler for the POST action `PostDeleteTreeHandler` + * The template file used for the form `delete-book-form.html.twig` + * `Allow editing Books?` (Y): as the previous two cases, multiple files are generated on this step as well: + * The form used for creation `EditBookForm` and the input filter it uses `EditBookInputFilter` + * The handler that fetches the form `GetEditBookFormHandler` + * The handler for the POST action `PostEditBookHandler` + * The template file used for the form `edit-book-form.html.twig` +* Following this step, `dot-maker` will automatically generate the `ConfigProvider.php` classes for both the `Admin` and `Core` namespaces, +as well as the `RoutesDelegator` class containing all the relevant routes. + +You will then be instructed to: + +* Register the `ConfigProvider` classes by adding `Admin\Book\ConfigProvider::class` and `Core\Computer\ConfigProvider::class` to `config/config.php` +* Register the new `Book` namespace by adding `"Admin\\Book\\": "src/Book/src/"` and `"Core\\Book\\": "src/Core/src/Book/src/"` to `composer.json` under the `autoload.psr-4` key. + * After registering the namespace, run the following command to regenerate the autoloaded files, as notified by `dot-maker`: + +```shell +composer dump +``` + +* `dot-maker` will by default prompt you to generate the migrations for the new entity, but for the purpose of this tutorial + we will run this after updating the generated entity. + +The next step is filling in the required logic for the proposed flow of this module. +While `dot-maker` does also include common logic in the relevant files, the tutorial adds custom functionality. +As such, the following section will go over the files that require changes. + +* `src/Book/src/Handler/GetListBookHandler.php` + +The overall class structure is fully generated, but for the purpose of this tutorial you will need to send the `indentifier` key +to the template, as shown below: + +```php +return new HtmlResponse( + $this->template->render('book::book-list', [ + 'pagination' => $this->bookService->getBooks($request->getQueryParams()), + 'identifier' => SettingIdentifierEnum::IdentifierTableUserListSelectedColumns->value, + ]) +); +``` + +* `src/Core/src/App/src/Message.php` + +The generated `PostCreateBookHandler`, `PostEditBookHandler` and `PostDeleteBookHandler` classes will by default make use +of the `Message::BOOK_CREATED`, `Message::BOOK_UPDATED` and `Message::BOOK_DELETED` constants which you will have to manually add: + +```php +public const BOOK_CREATED = 'Book created successfully.'; +public const BOOK_UPDATED = 'Book updated successfully.'; +public const BOOK_DELETED = 'Book deleted successfully.'; +``` + +* `src/Core/src/Book/src/Entity/Book.php` + +To keep things simple in this tutorial, our book will have three properties: `name`, `author` and `releaseDate`. +Add the three properties and their getters and setters, while making sure to update the generated constructor method. + +```php +setName($name); + $this->setAuthor($author); + $this->setReleaseDate($releaseDate); + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getAuthor(): string + { + return $this->author; + } + + public function setAuthor(string $author): self + { + $this->author = $author; + + return $this; + } + + public function getReleaseDate(): DateTimeImmutable + { + return $this->releaseDate; + } + + public function setReleaseDate(DateTimeImmutable $releaseDate): self + { + $this->releaseDate = $releaseDate; + + return $this; + } + + public function getArrayCopy(): array + { + return [ + 'uuid' => $this->getUuid()->toString(), + 'name' => $this->getName(), + 'author' => $this->getAuthor(), + 'releaseDate' => $this->getReleaseDate(), + ]; + } +} + +``` + +The `BookService` class will require minor modifications for the `getBooks()` and `saveBook()` methods, to add the custom properties added in the previous step. +The class should look like the following after updating the methods. + +* `src/Book/src/Service/BookService.php` + +```php +bookRepository; + } + + public function deleteBook( + Book $book, + ): void { + $this->bookRepository->deleteResource($book); + } + + /** + * @param array $params + */ + public function getBooks( + array $params, + ): array { + $filters = $params['filters'] ?? []; + $params = Paginator::getParams($params, 'book.created'); + + $sortableColumns = [ + 'book.name', + 'book.author', + 'book.releaseDate', + 'book.created', + 'book.updated', + ]; + if (! in_array($params['sort'], $sortableColumns, true)) { + $params['sort'] = 'book.created'; + } + + $paginator = new DoctrinePaginator($this->bookRepository->getBooks($params, $filters)->getQuery()); + + return Paginator::wrapper($paginator, $params, $filters); + } + + /** + * @param array $data + * @throws \DateMalformedStringException + */ + public function saveBook( + array $data, + ?Book $book = null, + ): Book { + if (! $book instanceof Book) { + $book = new Book( + $data['name'], + $data['author'], + new DateTimeImmutable($data['releaseDate']) + ); + } else { + if (array_key_exists('name', $data) && $data['name'] !== null) { + $book->setName($data['name']); + } + + if (array_key_exists('author', $data) && $data['author'] !== null) { + $book->setAuthor($data['author']); + } + + if (array_key_exists('releaseDate', $data) && $data['releaseDate'] !== null) { + $book->setReleaseDate(new DateTimeImmutable($data['releaseDate'])); + } + } + + $this->bookRepository->saveResource($book); + + return $book; + } + + /** + * @throws NotFoundException + */ + public function findBook( + string $uuid, + ): Book { + $book = $this->bookRepository->find($uuid); + if (! $book instanceof Book) { + throw new NotFoundException(Message::resourceNotFound('Book')); + } + + return $book; + } +} + +``` + +When creating a book, we will need some validators, so we will create a form and the input filter that will be used to validate the data received in the request. + +* `src/Book/src/Form/CreateBookForm.php` + +The default `Csrf` and `Submit` Inputs will be automatically added to the `CreateBookForm.php` class that `dot-maker` will create for you. +For this tutorial, you will have to add the custom inputs, by copying the following code in the `init` function of `CreateBookForm`: + +```php +$this->add( + (new Text('name')) + ->setLabel('Name') + ->setAttribute('required', true) +)->add( + (new Text('author')) + ->setLabel('Author') + ->setAttribute('required', true) +)->add( + (new Date('releaseDate')) + ->setLabel('Release Date') + ->setAttribute('required', true) +); +``` + +* `src/Book/src/Form/EditBookForm.php` + +A similar sequence is used for the `init` function of `EditBookForm`, with the `required` attributes removed, +as leaving the inputs empty is allowed for keeping the original data: + +```php +$this->add( + (new Text('name')) + ->setLabel('Name') +)->add( + (new Text('author')) + ->setLabel('Author') +)->add( + (new Date('releaseDate')) + ->setLabel('Release Date') +); +``` + +By creating a `module` with `dot-maker`, separate inputs will not be created. +However, you can still generate them as using these steps: + +* Run the following to start adding `Input` classes: + +```shell +./vendor/bin/dot-maker input +``` + +* When prompted, enter the names `Author`, `Name` and `ReleaseDate` one by one to generate the classes. +* The resulting `AuthorInput.php`, `NameInput.php` and `ReleaseDateInput.php` classes require no further changes for the tutorial use case. + +The module creation process has generated the parent input filters `CreateBookInputFilter.php` and `EditBookInputFilter.php` containing only the default `CsrfInput`. +Now we add all the inputs together in the parent input filters' `init` functions, as below: + +* `src/Book/src/InputFilter/CreateBookInputFilter.php` and ``src/Book/src/InputFilter/EditBookInputFilter.php`` + +```php +$this->add(new NameInput('name')) + ->add(new AuthorInput('author')) + ->add(new ReleaseDateInput('releaseDate')); +``` + +We create separate `Input` files to demonstrate their reusability and obtain a clean `InputFilter`s, but you could have all the inputs created directly in the `InputFilter` like this: + +> Note that `dot-maker` will not generate inputs in the `init` method, so the following are to be added by hand before the default `CsrfInput`, **if** going for this approach. + +`CreateBookInputFilter` + +```php +$nameInput = new Input('name'); +$nameInput->setRequired(true); + +$nameInput->getFilterChain() + ->attachByName(StringTrim::class) + ->attachByName(StripTags::class); + +$nameInput->getValidatorChain() + ->attachByName(NotEmpty::class, [ + 'message' => Message::VALIDATOR_REQUIRED_FIELD, + ], true); + +$this->add($nameInput); + +$authorInput = new Input('author'); +$authorInput->setRequired(true); + +$authorInput->getFilterChain() + ->attachByName(StringTrim::class) + ->attachByName(StripTags::class); + +$authorInput->getValidatorChain() + ->attachByName(NotEmpty::class, [ + 'message' => Message::VALIDATOR_REQUIRED_FIELD, + ], true); + +$this->add($authorInput); + +$releaseDateInput = new Input('releaseDate'); +$releaseDateInput->setRequired(true); + +$releaseDateInput->getFilterChain() + ->attachByName(StringTrim::class) + ->attachByName(StripTags::class); + +$releaseDateInput->getValidatorChain() + ->attachByName(NotEmpty::class, [ + 'message' => Message::VALIDATOR_REQUIRED_FIELD, + ], true); + +$this->add($releaseDateInput); +``` + +`EditBookInputFilter` + +```php +$nameInput = new Input('name'); +$nameInput->setRequired(false); + +$nameInput->getFilterChain() + ->attachByName(StringTrim::class) + ->attachByName(StripTags::class); + +$nameInput->getValidatorChain() + ->attachByName(NotEmpty::class, [ + 'message' => Message::VALIDATOR_REQUIRED_FIELD, + ], true); + +$this->add($nameInput); + +$authorInput = new Input('author'); +$authorInput->setRequired(false); + +$authorInput->getFilterChain() + ->attachByName(StringTrim::class) + ->attachByName(StripTags::class); + +$authorInput->getValidatorChain() + ->attachByName(NotEmpty::class, [ + 'message' => Message::VALIDATOR_REQUIRED_FIELD, + ], true); + +$this->add($authorInput); + +$releaseDateInput = new Input('releaseDate'); +$releaseDateInput->setRequired(false); + +$releaseDateInput->getFilterChain() + ->attachByName(StringTrim::class) + ->attachByName(StripTags::class); + +$releaseDateInput->getValidatorChain() + ->attachByName(NotEmpty::class, [ + 'message' => Message::VALIDATOR_REQUIRED_FIELD, + ], true); + +$this->add($releaseDateInput); +``` + +* `src/App/assets/js/components/_book.js` + +As the listing pages make use of JavaScript, you will need to manually create your module specific `_book.js` file and register +it in `webpack.config.js` for building. + +You may copy this sample `_book.js` file to the `src/App/assets/js/components/` directory: + +```js +$(document).ready(() => { + const request = async(url, options = {}) => { + try { + const response = await fetch(url, options); + const body = await response.text(); + if (! response.ok) { + throw { + data: body, + } + } + return body; + } catch (error) { + throw { + data: error.data, + } + } + } + + $("#add-book-modal").on('show.bs.modal', function () { + const modal = $(this); + request(modal.data('add-url'), { + method: 'GET' + }).then(data => { + modal.find('.modal-dialog').html(data); + }).catch(error => { + console.error(error); + location.reload(); + }); + }).on('hidden.bs.modal', function () { + const modal = $(this); + modal.find('.modal-dialog').find('.modal-body').html('Loading...'); + }); + + $("#edit-book-modal").on('show.bs.modal', function () { + const selectedElement = $('.ui-checkbox:checked'); + if (selectedElement.length !== 1) { + return; + } + + const modal = $(this); + request(selectedElement.data('edit-url'), { + method: 'GET' + }).then(data => { + modal.find('.modal-dialog').html(data); + }).catch(error => { + console.error(error); + location.reload(); + }); + }).on('hidden.bs.modal', function () { + const modal = $(this); + modal.find('.modal-dialog').find('.modal-body').html('Loading...'); + }); + + $("#delete-book-modal").on('show.bs.modal', function () { + const selectedElement = $('.ui-checkbox:checked'); + if (selectedElement.length !== 1) { + return; + } + + const modal = $(this); + request(selectedElement.data('delete-url'), { + method: 'GET' + }).then(data => { + modal.find('.modal-dialog').html(data); + }).catch(error => { + console.error(error); + location.reload(); + }); + }).on('hidden.bs.modal', function () { + const modal = $(this); + modal.find('.modal-dialog').find('.modal-body').html('Loading...'); + }); + + $(document).on("submit", "#book-form", (event) => { + event.preventDefault(); + + const form = event.target; + if (! form.checkValidity()) { + event.stopPropagation(); + form.classList.add('was-validated'); + return; + } + + const modal = $(form.closest('.modal')); + request(form.getAttribute('action'), { + method: 'POST', + body: new FormData(form), + }).then(() => { + location.reload(); + }).catch(error => { + modal.find('.modal-dialog').html(error.data); + }); + }); + + $(document).on("submit", "#delete-book-form", (event) => { + event.preventDefault(); + + const form = event.target; + if (! form.checkValidity()) { + event.stopPropagation(); + form.classList.add('was-validated'); + return; + } + + const modal = $(form.closest('.modal')); + request(form.getAttribute('action'), { + method: 'POST', + body: new FormData(form), + }).then(() => { + location.reload(); + }).catch(error => { + modal.find('.modal-dialog').html(error.data); + }); + }); +}); +``` + +Next you have to register the file in the `entries` array of `webpack.config.js` by adding the following key: + +```js +book: [ + './App/assets/js/components/_book.js' +] +``` + +To make use of the newly added scripts, make sure to build your assets by running the command: + +```shell +npm run prod +``` + +* `src/Book/templates/book/*` + +The next step is creating the page structures in the `.twig` files `dot-maker` automatically generated for you. + +For this tutorial you may copy the following default page layout in the `list-book.html.twig`: + +```html +{% from '@partial/macros.html.twig' import sortableColumn %} + +{% extends '@layout/default.html.twig' %} + +{% block title %}Manage books{% endblock %} + +{% block content %} +
+

Manage books

+
+
+
+
+ + + + +
+ + + +
+
+ +
+
+
+
+
+
+ + + + + + + + + + + + + {% for book in pagination.items %} + + + + + + + + + {% endfor %} + + + {% if pagination.isOutOfBounds %} + + {% endif %} +
+
+
+
+ {{ include('@partial/pagination.html.twig', {pagination: pagination, path: 'book::list-book'}, false) }} +
+
+
+ + + + + + +
+{% endblock %} + +{% block javascript %} +{{ parent() }} + + + +{% endblock %} +``` + +To add books, a modal must be generated based on the `CreateBookForm.php` class. +You may copy the following structure in `create-book-form.html.twig`: + +```html +{% from '@partial/macros.html.twig' import inputElement, submitElement %} + + +``` + +For the "edit" action, use the following modal in the `edit-book-form.html.twig`: + +```html +{% from '@partial/macros.html.twig' import inputElement, submitElement %} + + +``` + +Add the following structure to the `delete-book-form.html.twig` file: + +```html + +``` + +* `/config/autoload/navigation.global.php` + +Lastly, link the new module to the admin side-menu by adding the following array to `navigation.global.php`, +under the `dot_navigation.containers.main_menu.options.items` key: + +```php +[ + 'options' => [ + 'label' => 'Book', + 'route' => [ + 'route_name' => 'book::list-book', + ], + 'icon' => 'c-blue-500 fa fa-book', + ], +], +``` + +## Migrations + +All changes are done, so at this point the migration file can be generated to create the associated table for the `Book` entity. + +> You can check the mapping files by running: + +```shell +php ./bin/doctrine orm:validate-schema +``` + +> Generate the migration files by running: + +```shell +php ./vendor/bin/doctrine-migrations diff +``` + +This will check for differences between your entities and database structure and create migration files if necessary, in `src/Core/src/App/src/Migration`. + +To execute the migrations run: + +```shell +php ./vendor/bin/doctrine-migrations migrate +``` + +## Update the authorization file + +We need to configure access to the newly created endpoints. +Open `config/autoload/authorization-guards.global.php` and append the below routes to the `guards.options.rules` key: + +```php +'book::create-book-form' => ['authenticated'], +'book::create-book' => ['authenticated'], +'book::list-book' => ['authenticated'], +``` + +> Make sure you read and understand the `rbac` [documentation](https://docs.dotkernel.org/dot-rbac-guard/v4/configuration/). + +## Checking routes + +The module should now be accessible via the `Book` section of the `Admin` main menu, linking to the newly created `/list-book` +route. + +New book entities can be added via the new "Create book" modal accessible form the `+` button on the management page. + +Once selected with the checkbox, existing entries can be edited via the `-` button , or deleted via the "trash" icon. diff --git a/mkdocs.yml b/mkdocs.yml index 9b21473..4fe65e3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,8 @@ nav: - "Use NPM Commands": v6/how-to/npm_commands.md - "Inject Dependencies": v6/how-to/dependency-injection.md - "Set Up CSRF": v6/how-to/csrf.md + - Tutorials: + - "Creating a book module using dotkernel/dot-maker": v6/tutorials/create-book-module-via-dot-maker.md site_name: admin site_description: "DotKernel Admin" repo_url: "https://github.com/dotkernel/admin"