I know the bundle is young and we need to wait & see what direction the bundle will take.
However, I'd like to open the debate about the ability to add custom actions, as I feel like this would be a priority & must-have feature for this bundle.
Find below a draft of my personal suggestion.
⚠️ Caution: This is a TL;DR. I know. But please, do read :) and let's debate about your concerns/other suggestions.
Suggestion: Actions as tagged services
Instead of having one controller, with all the logic inside, we'd rather have separated classes for each action
.
Those actions
are gathered by a CompilerPass
using _tagged services_.
Therefore, here is what it might look like:
#actions.yml
services:
easy_admin.actions.edit:
class: JavierEguiluz\Bundle\EasyAdminBundle\Action\EditAction
arguments: [@doctrine.orm.entity_manager]
tags:
- { name: easy_admin.action, alias: edit }
easy_admin.actions.show:
class: JavierEguiluz\Bundle\EasyAdminBundle\Action\ShowAction
arguments: [@doctrine.orm.entity_manager]
tags:
- { name: easy_admin.action, alias: show }
...
Workflow
1. kernel.request
→ 2. Match easy_admin.path
→ 3. ActionHandler
→ 4. getAppropriateAction
→ 5. run
→ 6. return Response
.
- Listen to the
kernel.request
event prior to the RouterListener
.
- If
Request
match the configured easy_admin.path
, continue. Otherwise, let the RouterListener act.
- Get the
ActionHandler
. This is the service that has gathered every actions
through tags during compilation pass.
- From the
ActionHandler
, find the appropriate action
. Inject the handler
& the template engine or get the "proxified" version of the action
.
- Execute the action with the
ActionInterface::run()
method.
- This method returns a
Response
. So let's return it directly.
📝 NOTE: The step 1 about the kernel.request
listener, prior to the symfony/httpkernel
RouterListener is optional. It is semantically easiest to understand the controller. But as the AdminController does only contain one real route/action, and as I want to inject some Twig_Globals and maybe the extensions in a kernel.request
listener(done), that's a way to kill two targets with one bullet, and remove entirely the AdminController.
Having the admin path
registered in the config, instead of a routing prefix might also be useful further. (we might add dynamically a route only for generation purposes. But this require using a workaround. See [Routing] Routes only for generation, not matching)
View Hooks
When the Response is rendered, multiple hooks are available in the template to add controls & elements on multiple parts of the views.
In order to do that, a new Twig_Function will be available: render_hook('hookName', {})
.
Actions will subscribe to those hooks in order to add their own elements to the views. Create their own hooks is easy as just using the new render_hook('new_hook_name', args)
in their templates.
Workflow
1. render_hook
→ 2. ActionHandler::getSubscribingHooks()
→ 3. render
- The
render_hook('list.item.actions', { id: 10, entity: 'Product' })
is called.
- The Twig_Function calls the
ActionHandler::getSubscribingHooks
method, which returns the list of action
subscribing to this hook.
- For each
action
subscribing to this hook
, render the specified view and order by priority in an array. The Twig_Function then returns the array.
Eventually, for most of the hooks, return a string of the concatenated result instead of an array. (Might be another twig function name in order to avoid confusion)
_e.g:_ When render_hook('list.item.actions', { id: 10, entity: 'Product' })
is called, the Twig_Function will search through the ActionHandler every actions subscribing the list.item.actions
hook and call the corresponding method defined in the getSubscribedHooks
method. Then, the Twig_Function will loop over & display the result in the view:
<td class="actions">
{% for action in render_hook('list.item.actions', { id: 10, entity: 'Product' })) %}
{{ action }}
{% endfor %}
</td>
With the two edit
& show
actions subscribing to the hook list.item.actions
, will generate:
<td class="actions">
<a href="{{ path('easy_admin', { action: 'show', entity: 'Product', id: 10}) }}">Show</a>
<a href="{{ path('easy_admin', { action: 'edit', entity: 'Product', id: 10}) }}">Edit</a>
</td>
The Action class
From these informations, we could imagine what an Action will look like :
#JavierEguiluz\Bundle\EasyAdminBundle\Action\EditAction
class EditAction extends AbstractAction
{
function __construct(ObjectManager $em)
{
//Inject needed dependencies
$this->em = $em;
}
/**
* Executes the logic about the action.
* The action must be initiated by the ActionHandler prior being executed,
* in order to inject the handler itself & template engine (or find a way to proxyfy this service, with mandatory
* dependencies already injected, or simply using parent services)
* Protected, as it will be executed by the handler through the run method.
*
* @param Request $request
* @param array $config
* @param array $entity
* @return Response
*/
protected function execute(Request $request, array $config, array $entity)
{
if (!$item = $this->em->getRepository($entity['class'])->find($request->query->get('id'))) {
throw $this->createNotFoundException(sprintf('Unable to find entity (%s #%d).', $entity['name'], $request->query->get('id')));
}
$fields = $entity['edit']['fields'];
$editForm = $this->createEditForm($item, $fields);
$deleteForm = $this->actionHandler->getAppropriateAction('delete')->createDeleteForm($entity['name'], $request->query->get('id'));
$editForm->handleRequest($this->request);
if ($editForm->isValid()) {
$this->prepareEditEntityForPersist($item); //Maybe use the eventDispatcher instead
$this->em->flush();
return new RedirectResponse(($this->router->generateUrl('admin', array('action' => 'list', 'entity' => $entity['name']))));
}
return $this->render('@EasyAdmin/edit.html.twig', array(
'config' => $this->config, //Might be injected as a Twig Global if feasible, on kernel.request
'entity' => $this->entity, //Ditto
'form' => $editForm->createView(),
'entity_fields' => $fields,
'item' => $item,
'delete_form' => $deleteForm->createView(),
));
}
public static function getSubscribedHooks()
{
return array(
array('hook' => 'list.item.actions', 'method' => 'renderListItemActionHook', 'priority' => 1),
array('hook' => 'show.actions', 'method' => 'renderShowActionActionHook', 'priority' => 10),
);
}
public function renderListItemActionHook($hook, array $args)
{
//...
//Render a view
}
public function renderShowActionActionHook($hook, array $args)
{
//...
//Render a view
}
public static function getName()
{
return 'edit';
}
}
✨ BONUS: it will allow to easily override how items specific to each action are rendered.
For instance, overriding the renderListItemActionHook
method for the EditAction will allow to render a real bootstrap button instead of the simple link in the entity listing:
<a href="{{ path('easy_admin', { action: 'show', entity: 'Product', id: 10}) }}">Show</a>
<a class="btn btn-primary" href="{{ path('easy_admin', { action: 'edit', entity: 'Product', id: 10}) }}">Edit</a>
📝 NOTE: Should the getSubscribedHooks
accept a callable at the hook
key, in order to not only validate the hookName it's subscribing, but other arguments as entity
, ... ?
Further
This workflow might not be limited to actions
on entities: See my comment about Extending to custom pages & dashboards.
See actions on entities as a particular case of pages where entity is additionally injected.
What do you think ?
If that makes sense to you, I might work on it when I have some time in order to provide a more concrete draft.