Resource bundle
Introduction
The resource bundle is a very important bundle in the enhavo eco system. It's responsible to register resources and defining grids and input masks and provides a handful of useful functions and workflows, helping you to handle your business model and data.
The resource bundle is completely headless and don't serve any assets. It's build to serve CRUD features and keep all customizable. If you have any requirements which the bundle does not provide, we have a good Api to easily add your own features.
This bundle is heavily inspired by the sylius resource bundle, but uses the typical enhavo type pattern
Installation
$ composer require enhavo/resource-bundle
Resource
Resources are commonly standalone entities or sometimes called root entities. We use alias as abstraction for a resource, that helps us to later change the concrete model.
Register
Simple we add a book
resource, where model is mandatory and factory and repository is optional.
# config/resources/book.yaml
enhavo_resource:
resources:
app.book: # resource name
classes:
model: App\Entity\Book
factory: App\Factory\BookFactory
repository: App\Repository\BookRepository
label: Book
translation_domain: ~
priority: 10
This will create us a factory service with name app.book.factory
and a repository service app.book.repository
, that can be injected to any other service if necessary. If you don't define a factory or repository, a default ones with these classes Enhavo\Bundle\ResourceBundle\Factory\Factory
and Enhavo\Bundle\ResourceBundle\Repository\EntityRepository
will be created.
WARNING
A model can applied only once to a resource name and the repository class will always overwrite the repository class in your doctrine meta configuration!
If more configurations for the same resources name are defined, the one with higher priority
will overwrite lower ones.
Give the resource name also a label
, to display the name wherever it will be needed.
ResourceManager
The ResourceManager
keep all factory and repository services. So you can retrieve the services with the alias from it. The factory is used to create a new entity while the repository to find an existing one. Use the save
method to save the entity.
namespace App\Endpoint;
use Enhavo\Bundle\ApiBundle\Endpoint\AbstractEndpointType;
use Enhavo\Bundle\ResourceBundle\Resource\ResourceManager;
class BookEndpoint extends AbstractEndpointType
{
public function __construct(
private ResourceManager $resourceManager,
) {}
public function handleRequest($options, Request $request, Data $data, Context $context)
{
// repository
$repository = $this->resourceManager('app.book')->getRepository();
$book = $repository->find($request->get('id'));
// or factory
$factory = $this->resourceManager('app.book')->getFactory();
$book = $factory->createNew();
// update entity and save
$book->setTitle($request->get('title'));
$this->resourceManager->save($book);
// or duplicate
$otherBook = $this->resourceManager->duplicate($book);
// or validate
$otherBook = $this->resourceManager->validate($book);
// or apply transition
$otherBook = $this->resourceManager->applyTransition($book, 'publish', 'book_graph');
// or delete
$this->resourceManager->delete($book);
// or get metadata
$metadata = $this->resourceManager->getMetadata($book);
$label = $metadata->getLabel()
}
}
As you can see in the above example, we don't need the doctrine entity manager here. This is encapsulated in the ResourceManager
.
Hooks
The save
method of the ResourceManager
also fires some hooks we can catch with an event listener or subscriber.
class BookSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return [
ResourceEvents::PRE_CREATE => 'hook',
ResourceEvents::POST_CREATE => 'hook',
ResourceEvents::PRE_UPDATE => 'hook',
ResourceEvents::POST_UPDATE => 'hook',
ResourceEvents::PRE_DELETE => 'hook',
ResourceEvents::POST_DELETE => 'hook',
];
}
public function hook(ResourceEvent $event)
{
$resource = $event->getSubject();
// do something here
}
}
Duplicate
With duplicate, you can create deep clones of a resource. To know which properties need to be copied, you have to mark up them. This is normally done by the Attribute Duplicate
but you can also use configuration.
<?php
namspace App\Entity;
use Enhavo\Bundle\ResourceBundle\Attribute\Duplicate;
use Doctrine\Common\Collections\Collection;
class Book
{
#[Duplicate('string', ['postfix' => ' Copy!!', 'group' => ['duplicate']])]
private ?string $name = null;
#[Duplicate('model', [
'group' => ['duplicate']
])]
private Collection $chapters = null;
}
enhavo_resources:
duplicate:
App\Entity\Book:
name:
type: string
postfix: ' Copy!!'
group: ['duplicate']
chapters:
type: model
group: ['duplicate']
Use the manager to duplicate a resource. The returned resource is not saved yet. Use the save
method to make it persistent.
$otherBook = $this->resourceManager->duplicate($book, ['group' => 'duplicate']);
$this->resourceManager->save($otherBook);
State Machine
The resource bundle uses the winzou state machine. Here is a small graph example. For more documentation read the docs.
winzou_state_machine:
app.book:
class: App\Entity\Book
property_path: state
graph: book_graph
states:
- new
- published
transitions:
publish:
from: [new]
to: published
# hooks
callbacks:
before:
update_publish_date:
on: 'publish'
do: ['@book.manager', 'updatePublishDate']
args: ['object']
Use canApplyTransition
to check if a transition can be applied or applyTransition
to execute the transition with it callbacks and save the state.
if ($this->resourceManager->canApplyTransition($book, 'publish', 'book_graph')) {
$otherBook = $this->resourceManager->applyTransition($book, 'publish', 'book_graph');
}
WARNING
The save hook will not be triggered if you use applyTransition
Grid
A grid is a list of resources. The resource bundle allow highly customized grids. This means, you can redefine your own grid or customize the default one. It comes with a lot of options and subcomponents to fit most of your needs.
Define your grid under enhavo_resource.grids
.
# config/resources/book.yaml
enhavo_resource:
grids:
# name of the grid
app.book:
# configuration options
extends: app.parent_grid
priority: 10
overwrite: false
# default grid class (optional)
class: Enhavo\Bundle\ResourceBundle\Grid\Grid
# options of the default grid class
resource: app.book # resource name (mandatory)
component: 'grid-grid'
routes: {} # route options
collection: {} # collection options
actions: {} # list of actions
filters: {} # list of filters
batches: {} # list of batches
columns: {} # list of columns
Configuration control
The options extends
, priority
and overwrite
are not part of the grid class. These options controlling the handling to other configured grids.
If multiple grids defined with the same name, the priority
options control the order. Not all options will be overwritten. For example, if you define a action in the parent and another in the child grid, both actions will be used. If you use overwrite
with value true
in the child, all actions will be overwritten and only the actions in the child definition are used.
With extends
, you will inherit the grid class and options from that specified grid.
Grid class and options
By default, the class Enhavo\Bundle\ResourceBundle\Grid\Grid
is used and don't need to be defined if you stick to it.
Further options depends on the grid class and it's subsystems.
In the default grid the name is not automatically connected to the resource name. So you are able to define multiple grids for a single resource.
Routing and flow
An admin route is needed to display the resource-index
component.
# config/routes/admin/book.yaml
app_admin_book_index:
path: /book/index
defaults:
_expose: admin
_vue:
component: resource-index
groups: admin
meta:
api: app_admin_api_book_index
_endpoint:
type: admin
This component will use the api route, which passed over meta and fetch the configuration from this endpoint. Inside the configuration, we find all infos for the subcomponent. The collection subcomponent get a new url to fetch the data for the grid.
# config/routes/admin_api/book.yaml
app_admin_api_book_index:
path: /book/index
methods: [GET]
defaults:
_expose: admin_api
_endpoint:
type: resource_index
grid: app.book
app_admin_api_book_list:
path: /book/list
methods: [GET,POST]
defaults:
_expose: admin_api
_endpoint:
type: resource_list
grid: app.book
app_admin_api_book_batch:
path: /book/batch
methods: [POST]
defaults:
_expose: admin_api
_endpoint:
type: resource_batch
grid: app.book
Collection
enhavo_resource:
grids:
app.book:
collection:
class: Enhavo\Bundle\ResourceBundle\Collection\TableCollection
limit: 100,
paginated: true,
repository_method: ~
repository_arguments: ~
pagination_steps: [5, 10, 50, 100, 500, 1000]
component: 'collection-table'
model: 'TableCollection'
filters: []
sorting: []
criteria: []
Action
Actions are like buttons. If you click on one of these buttons, something will be executed. For example an overlay could be displayed etc.
Filter
tbc.
Batch
Batch are like actions, that can be run on multiple items from the table view by selecting the items and choosing an action from a dropdown menu.
Input
An input control the input mask for creating and editing a resource. You have to define all options similar to a grid.
# config/resources/book.yaml
enhavo_resource:
inputs:
# name of the input
app.book:
# configuration options
extends: enhavo_resource.input
priority: 10
overwrite: false
# default input class (optional)
class: Enhavo\Bundle\ResourceBundle\Input\Input
# options of the input class
resource: app.book
form: App\Form\Type\BookType
form_options: []
actions: []
actions_secondary: []
tabs: []
factory_method: createNew
factory_arguments: []
repository_method: find
repository_arguments: [
'expr:resource.getId()'
]
serialization_groups: endpoint
Routes
Admin routes.
# config/routes/admin/book.yaml
app_admin_book_create:
path: /book/create
defaults:
_expose: admin
_vue:
component: resource-input
groups: admin
meta:
api: app_admin_api_create
_endpoint:
type: admin
app_admin_book_update:
path: /book/update/{id}
defaults:
_expose: admin
_vue:
component: resource-input
groups: admin
meta:
api: app_admin_api_update
_endpoint:
type: admin
Admin api routes.
# config/routes/admin_api/book.yaml
app_admin_api_create:
path: /book/create
methods: [GET, POST]
defaults:
_expose: admin_api
_endpoint:
type: resource_create
input: app.book
app_admin_api_update:
path: /book/update/{id}
methods: [GET, POST]
defaults:
_expose: admin_api
_endpoint:
type: resource_update
input: app.book
Tabs
Define tabs.
enhavo_resource:
inputs:
app.book:
tabs:
main:
label: Book
type: form
arrangement: |
name
chapters
Duplicate
Define the action button.
enhavo_resource:
inputs:
app.book:
actions:
duplicate:
type: duplicate
enabled: 'expr:resource && resource.getId()'
Define the route.
app_api_book_duplicate:
path: /book/duplicate/{id}
methods: [POST]
defaults:
_expose: admin_api
_endpoint:
type: resource_duplicate
input: app.book
Preview
Define the preview action.
enhavo_resource:
inputs:
app.book:
actions:
preview:
type: preview
enabled: 'expr:resource && resource.getId()'
Define the route.
app_admin_api_preview:
path: /book/preview/{id}
methods: [GET, POST]
defaults:
_expose: admin_api
_area: theme
_endpoint:
type: preview
input: app.book
endpoint:
type: App\Endpoint\BookEndpointType
resource: expr:resource
preview: true
Route Resolver
The naming of routes following a convention to prevent configuration (convention over configuration).
Convention
Parts:
- namespace, normally app or the bundle name
- area, e.g. theme or admin
api
if it is an api route- resource name or subsystem
- action
Usage
use Enhavo\Bundle\ResourceBundle\RouteResolver\RouteResolverInterface;
class BookEndpointType extends AbstractEndpointType
{
public function __construct(
private readonly RouteResolverInterface $routeResolver,
)
{
}
public function handleRequest($options, Request $request, Data $data, Context $context): void
{
$createRoute = $this->routeResolver->getRoute('create', ['api' => false]);
$updateApiRoute = $this->routeResolver->getRoute('update', ['api' => true]);
}
}
Expression Language
Usage
use Enhavo\Bundle\ResourceBundle\ExpressionLanguage\ResourceExpressionLanguage;
class BookEndpointType extends AbstractEndpointType
{
public function __construct(
private readonly ResourceExpressionLanguage $resourceExpression,
)
{
}
public function handleRequest($options, Request $request, Data $data, Context $context): void
{
$createRoute = $this->resourceExpression->evalute($options['']);
$updateApiRoute = $this->routeResolver->evaluteArray($options[''], [
'resource' => $context->get('resource')
]);
}
}