Skip to content

Instantly share code, notes, and snippets.

@daffoxdev
Last active December 22, 2023 11:58
Show Gist options
  • Save daffoxdev/eff74f0606bb4889d270756fec15e4b0 to your computer and use it in GitHub Desktop.
Save daffoxdev/eff74f0606bb4889d270756fec15e4b0 to your computer and use it in GitHub Desktop.
EasyAdmin 3.2.7 show records list of another controller inside of details page. Can be used as base to continue the idea
<?php
namespace App\Controller\Admin;
use App\Admin\Field\ControllerIndexField;
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Config\KeyValueStore;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Dto\PaginatorDto;
use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory;
use EasyCorp\Bundle\EasyAdminBundle\Factory\FilterFactory;
use EasyCorp\Bundle\EasyAdminBundle\Factory\PaginatorFactory;
use EasyCorp\Bundle\EasyAdminBundle\Field\Field;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
use function is_string;
abstract class AbstractAdminCrudController extends AbstractCrudController
{
public const PAGE_LIST_HTML = 'listHtml';
public const ACTION_LIST_HTML = 'listHtml';
public static function getSubscribedServices(): array
{
return array_merge(parent::getSubscribedServices(), [
Environment::class => '?'.Environment::class,
]);
}
public function isDetailPage(string $page): bool
{
return $page === Crud::PAGE_DETAIL;
}
/**
* Returns the list of records from another controller as formatted html
*
* @param AdminContext $context
* @return Response
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function listHtml(AdminContext $context): Response
{
$context->getCrud()->setPageName(self::PAGE_LIST_HTML);
$this->hackPageSizeForListHtml($context);
// duplicating almost the same steps that are done in index()
$fields = FieldCollection::new($this->configureFields(self::PAGE_LIST_HTML));
$filters = $this->get(FilterFactory::class)->create($context->getCrud()->getFiltersConfig(), $fields, $context->getEntity());
$queryBuilder = $this->createIndexQueryBuilder($context->getSearch(), $context->getEntity(), $fields, $filters);
$paginator = $this->get(PaginatorFactory::class)->create($queryBuilder);
$entities = $this->get(EntityFactory::class)->createCollection($context->getEntity(), $paginator->getResults());
$this->get(EntityFactory::class)->processFieldsForAll($entities, $fields);
$templateParameters = [
// new context should be passed to twig here cause easy admin passes only globally context from container as ea variable
'admin_context' => $context,
'pageName' => self::PAGE_LIST_HTML,
'entities' => $entities,
'paginator' => $paginator,
'filters' => $filters,
];
$formatted = $this->get(Environment::class)
->render(
'admin/crud/inner_index.html.twig',
$templateParameters
);
return new Response($formatted);
}
private function hackPageSizeForListHtml(AdminContext $context): void
{
$request = $context->getRequest();
$pageSize = $request->query->get(ControllerIndexField::OPT_PAGE_SIZE, 10);
if ($pageSize > 50) {
$pageSize = 50;
}
// hack with paginator. Rewrite DTO to change the per page items
$oldPaginatorDto = $context->getCrud()->getPaginator();
$context->getCrud()->setPaginator(new PaginatorDto(
$pageSize,
$oldPaginatorDto->getRangeSize(),
$oldPaginatorDto->getRangeEdgeSize(),
$oldPaginatorDto->fetchJoinCollection(),
$oldPaginatorDto->useOutputWalkers()
));
}
protected function showOnlyOn(FieldInterface $field, string $page): FieldInterface
{
$field
->getAsDto()
->setDisplayedOn(KeyValueStore::new([
$page => $page
]));
return $field;
}
/**
* @param string|FieldInterface $field
* @param string|null $label
* @return Field
*/
protected function newField($field, ?string $label = null): FieldInterface
{
if (is_string($field)) {
$field = Field::new($field, $label);
}
$this->addDisplayOn($field, self::PAGE_LIST_HTML);
return $field;
}
protected function addDisplayOn(FieldInterface $field, string $page): FieldInterface
{
$field
->getAsDto()
->getDisplayedOn()
->set($page, $page);
return $field;
}
}
<?php
namespace App\Admin\Filter;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterInterface;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDataDto;
use EasyCorp\Bundle\EasyAdminBundle\Filter\FilterTrait;
use EasyCorp\Bundle\EasyAdminBundle\Form\Filter\Type\TextFilterType;
/**
* Simple filter that allows filtering by assosiated fields
*/
class AssociationFilter implements FilterInterface
{
use FilterTrait;
private string $field = 'id';
private ?string $associationProperty = null;
/**
* @param string $propertyName name of field that will be used as key for identify filter
* @param string|null $label displayed label of filter
* @param string|null $associationProperty property name inside of working entity. Using $propertyName by default
* @return static
*/
public static function new(
string $propertyName,
string $label = null,
string $associationProperty = null
): self {
if (!$associationProperty) {
$associationProperty = $propertyName;
}
return (new self())
->setFilterFqcn(__CLASS__)
->setProperty($propertyName)
->setAssociationProperty($associationProperty)
->setLabel($label)
->setFormType(TextFilterType::class);
}
public function apply(
QueryBuilder $queryBuilder,
FilterDataDto $filterDataDto,
?FieldDto $fieldDto,
EntityDto $entityDto
): void {
if (!$this->field) {
return;
}
$property = sprintf(
'%s.%s',
$filterDataDto->getEntityAlias(),
$this->associationProperty
);
$queryBuilder->innerJoin(
$property,
'association',
Join::WITH,
sprintf('IDENTITY(%s) = association.id', $property)
);
$queryBuilder->andWhere(
sprintf(
'association.%s %s :value',
$this->field,
$filterDataDto->getComparison()
)
)
->setParameter('value', $filterDataDto->getValue());
}
public function setField(string $fieldName): self
{
$this->field = $fieldName;
return $this;
}
public function setAssociationProperty(string $associationProperty): self
{
$this->associationProperty = $associationProperty;
return $this;
}
public function getParent(): string
{
return TextFilterType::class;
}
}
<?php
namespace App\Admin\Field\Configurator;
use App\Admin\Field\ControllerIndexField;
use App\Controller\Admin\AbstractAdminCrudController;
use App\Controller\Admin\DashboardController;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldConfiguratorInterface;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
use EasyCorp\Bundle\EasyAdminBundle\Factory\AdminContextFactory;
use EasyCorp\Bundle\EasyAdminBundle\Factory\ControllerFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Configures ControllerIndexField::class field that shows list of different controller as a simple field
*/
class ControllerIndexConfigurator implements FieldConfiguratorInterface
{
private AdminContextFactory $adminContextFactory;
private ControllerFactory $controllerFactory;
private RequestStack $requestStack;
public function __construct(
AdminContextFactory $adminContextFactory,
ControllerFactory $controllerFactory,
RequestStack $requestStack
) {
$this->adminContextFactory = $adminContextFactory;
$this->controllerFactory = $controllerFactory;
$this->requestStack = $requestStack;
}
public function supports(FieldDto $field, EntityDto $entityDto): bool
{
return $field->getFieldFqcn() === ControllerIndexField::class;
}
public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $context): void
{
/** @var Request $request */
$request = $field->getCustomOption(ControllerIndexField::OPT_REQUEST);
$pageSize = $field->getCustomOption(ControllerIndexField::OPT_PAGE_SIZE) ?? 5;
if (!$request->query->has(ControllerIndexField::OPT_PAGE_SIZE)) {
$request->query->set(ControllerIndexField::OPT_PAGE_SIZE, $pageSize);
}
$formatted = $this->getControllerHtml($request);
$field->setLabel(null);
$field->setFormattedValue($formatted);
}
/**
* All the thing are doing here could be interpreting as hack of EasyAdmin
* Cause from the box bundle not provides such functionality and it's code
* is very encapsulated with final classes, arrays with options that are not
* configurable from outside, services that has a state, services that uses
* current request and so on.
*
* @param Request $request
* @return string
*/
private function getControllerHtml(Request $request): string
{
$controllerFqcn = $request->query->get(EA::CRUD_CONTROLLER_FQCN);
$request->query->set(EA::CRUD_ACTION, AbstractAdminCrudController::ACTION_LIST_HTML);
$this->requestStack->push($request);
$dashboardController = $this->controllerFactory
->getDashboardControllerInstance(
DashboardController::class,
$request
);
/** @var AbstractAdminCrudController $crudController */
$crudController = $this->controllerFactory->getCrudControllerInstance(
$controllerFqcn,
AbstractAdminCrudController::ACTION_LIST_HTML,
$request
);
// all this we need just to create another admin context for sub controller (listHtml page)
// cause current context is setting up for parent controller (detail page)
$adminContext = $this->adminContextFactory->create($request, $dashboardController, $crudController);
$adminContext->getCrud()->setPageName(AbstractAdminCrudController::PAGE_LIST_HTML);
$request->attributes->set(EA::CONTEXT_REQUEST_ATTRIBUTE, $adminContext);
$response = $crudController->listHtml($adminContext);
$this->requestStack->pop();
return $response->getContent();
}
}
<?php
namespace App\Admin\Field;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\ComparisonType;
use Symfony\Component\HttpFoundation\Request;
/**
* Field that shows list of different controller as a simple field
*/
final class ControllerIndexField implements FieldInterface
{
use FieldTrait;
public const OPT_REQUEST = 'request';
public const OPT_PAGE_SIZE = 'page_size';
public static function new(string $propertyName, ?string $label = null): self
{
return (new self())
->setProperty($propertyName)
->setLabel($label)
->setTemplatePath('admin/field/html_field.html.twig')
->onlyOnDetail()
->setRequest(new Request());
}
public function setControllerFqcn(string $controllerFqcn): self
{
$this->getRequest()
->query
->set(EA::CRUD_CONTROLLER_FQCN, $controllerFqcn);
return $this;
}
public function setFilter(
string $field,
string $value,
string $comparisonType = ComparisonType::EQ
): self {
$currentFilters = $this->getRequest()->query->get(EA::FILTERS, []);
$currentFilters[$field] = [
'comparison' => $comparisonType,
'value' => $value
];
$this->getRequest()->query->set(EA::FILTERS, $currentFilters);
return $this;
}
public function setSort(
string $field,
string $direction,
?bool $resetSort = true
): self {
if ($resetSort) {
$currentSort = [];
} else {
$currentSort = $this->getRequest()->query->get(EA::SORT, []);
}
$currentSort[$field] = $direction;
$this->getRequest()->query->set(EA::SORT, $currentSort);
return $this;
}
public function setFilters(array $filters): self
{
$this->getRequest()->query->set(EA::FILTERS, $filters);
return $this;
}
public function setRequest(Request $request): self
{
$this->setCustomOption(self::OPT_REQUEST, $request);
return $this;
}
public function getRequest(): Request
{
return $this->getAsDto()->getCustomOption(self::OPT_REQUEST);
}
public function setPageSize(int $pageSize): self
{
$this->setCustomOption(self::OPT_PAGE_SIZE, $pageSize);
return $this;
}
}
<?php
namespace App\Controller\Admin;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
class DashboardController extends AbstractDashboardController
{
public function configureCrud(): Crud
{
return Crud::new()
->overrideTemplate('crud/detail', 'admin/crud/detail.html.twig')
;
}
}
{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{# @var context \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
{#{% extends ea.templatePath('crud/detail') %}#}
{% extends '@!EasyAdmin/crud/detail.html.twig' %}
{% block detail_field %}
{% if not is_decoration_field %}
{{ _self.render_field(entity, field, row_number) }}
{% endif %}
{% endblock %}
{% macro render_field(entity, field, row_number) %}
<div class="data-row {{ row_number is even ? 'with-background' }} {{ field.cssClass }}">
{% if field.label is not empty or field.help is not empty %}
<dd>
{{ field.label|raw }}
{% if field.help is not empty %}
<span class="data-help">
<i class="far fa-question-circle" data-toggle="tooltip" title="{{ field.help|e('html_attr') }}"></i>
</span>
{% endif %}
</dd>
{% endif %}
<dt class="w-100">
{{ include(field.templatePath, { field: field, entity: entity }, with_context = false) }}
</dt>
</div>
{% endmacro %}
{% block body_javascript %}
{{ parent() }}
<script type="text/javascript">
$(function() {
function pullContentAndRedraw(href, $clickedElement)
{
let $block = $clickedElement.closest('.content-panel').parent();
$.get(href, function (response) {
$block.html(response);
});
}
$('.ea')
.on('click', '.page-link', function(e) {
e.preventDefault();
let $this = $(this);
let href = $this.attr('href');
pullContentAndRedraw(href, $this);
})
.on('click', '.js-sort a', function(e) {
e.preventDefault();
let $this = $(this);
let href = $this.attr('href');
pullContentAndRedraw(href, $this);
});
});
</script>
{% endblock %}
{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #}
{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
{{ field.formattedValue|raw }}
{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{# @var admin_context \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{# @var entities \EasyCorp\Bundle\EasyAdminBundle\Collection\EntityDtoCollection #}
{# @var paginator \EasyCorp\Bundle\EasyAdminBundle\Orm\EntityPaginator #}
{# @var admin_context \EasyCorp\Bundle\EasyAdminBundle\Orm\AdminContext local admin context #}
{% trans_default_domain admin_context.i18n.translationDomain %}
{# sort can be multiple; let's consider the sorting field the first one #}
{% set sort_field_name = app.request.get('sort')|keys|first %}
{% set some_results_are_hidden = false %}
{% set has_footer = false %}
<div class="content-panel">
<div class="content-panel-body with-rounded-top with-min-h-250 without-padding {{ not has_footer ? 'without-footer' }}">
<table class="table datagrid with-rounded-top {{ not has_footer ? 'with-rounded-bottom' }}">
<thead>
{% block table_head %}
<tr>
{% set ea_sort_asc = constant('EasyCorp\\Bundle\\EasyAdminBundle\\Config\\Option\\SortOrder::ASC') %}
{% set ea_sort_desc = constant('EasyCorp\\Bundle\\EasyAdminBundle\\Config\\Option\\SortOrder::DESC') %}
{% for field in entities|first.fields ?? [] %}
{% set is_sorting_field = admin_context.search.isSortingField(field.property) %}
{% set next_sort_direction = is_sorting_field ? (admin_context.search.sortDirection(field.property) == ea_sort_desc ? ea_sort_asc : ea_sort_desc) : ea_sort_desc %}
{% set column_icon = is_sorting_field ? (next_sort_direction == ea_sort_desc ? 'fa-arrow-up' : 'fa-arrow-down') : 'fa-sort' %}
<th class="js-sort {{ is_sorting_field ? 'sorted' }} {{ field.isVirtual ? 'field-virtual' }} text-{{ field.textAlign }}" dir="{{ admin_context.i18n.textDirection }}">
{% if field.isSortable %}
<a href="{{ ea_url({ page: 1, sort: { (field.property): next_sort_direction } }).includeReferrer() }}">
{{ field.label|raw }} <i class="fa fa-fw {{ column_icon }}"></i>
</a>
{% else %}
<span>{{ field.label|raw }}</span>
{% endif %}
</th>
{% endfor %}
</tr>
{% endblock table_head %}
</thead>
<tbody>
{% block table_body %}
{% for entity in entities %}
{% if not entity.isAccessible %}
{% set some_results_are_hidden = true %}
{% else %}
<tr data-id="{{ entity.primaryKeyValueAsString }}">
{% for field in entity.fields %}
<td class="{{ field.property == sort_field_name ? 'sorted' }} text-{{ field.textAlign }} {{ field.cssClass }}" dir="{{ admin_context.i18n.textDirection }}">
{{ include(field.templatePath, { field: field, entity: entity, admin_context: admin_context }, with_context = false) }}
</td>
{% endfor %}
</tr>
{% endif %}
{% else %}
<tr>
<td class="no-results" colspan="100">
{{ 'datagrid.no_results'|trans(admin_context.i18n.translationParameters, 'EasyAdminBundle') }}
</td>
</tr>
{% endfor %}
{% if some_results_are_hidden %}
<tr class="datagrid-row-empty">
<td class="text-center" colspan="{{ entities|first.fields|length + 1 }}">
<span class="datagrid-row-empty-message"><i class="fa fa-lock mr-1"></i> {{ 'datagrid.hidden_results'|trans({}, 'EasyAdminBundle') }}</span>
</td>
</tr>
{% endif %}
{% endblock table_body %}
</tbody>
</table>
</div>
{% if entities|length > 0 %}
<div class="content-panel-footer without-padding without-border">
{% block paginator %}
{{ include(admin_context.templatePath('crud/paginator')) }}
{% endblock paginator %}
</div>
{% endif %}
</div>
<?php
namespace App\Controller\Admin;
use App\Controller\Admin\AbstractAdminCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use App\Entity\UserActivity;
class UserActivityController extends AbstractAdminCrudController
{
public static function getEntityFqcn(): string
{
return UserActivity::class;
}
public function configureFields(string $pageName): iterable
{
return [
$this->newField('id'),
$this->newField('user'),
$this->newField('message'),
];
}
public function configureFilters(Filters $filters): Filters
{
$filters = parent::configureFilters($filters);
return $filters
->add('id')
->add(AssociationFilter::new(
'user_id',
'User id',
'user'
))
;
}
}
<?php
namespace App\Controller\Admin;
use App\Controller\Admin\AbstractAdminCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use App\Entity\User;
class UserController extends AbstractAdminCrudController
{
public static function getEntityFqcn(): string
{
return User::class;
}
public function configureFields(string $pageName): iterable
{
$adminContext = $this->adminContextProvider->getContext();
$userId = $adminContext->getEntity()->getPrimaryKeyValue();
$field = [
$this->newField('id'),
$this->newField('loginName'),
$this->newField('name'),
];
if ($this->isDetailPage($pageName)) {
$fields[] = FormField::addPanel('User activity')
->onlyOnDetail();
$fileds[] = $this->createUserActivityListField($userId)
->onlyOnDetail();
}
return $fields;
}
private function createUserActivityListField(int $userId): ControllerIndexField
{
return ControllerIndexField::new('user_activity_list')
->setControllerFqcn(UserActivityController::class)
->setFilter('user_id', $userId)
->setSort('id', 'DESC');
;
}
}
@ganti
Copy link

ganti commented Apr 4, 2021

Hi @daffoxdev
I checked multiple times, to me it looks fine, but i cant get your code running. any idea what I miss?
I get the following error:
The autoloader expected class "App\Controller\Admin\Field\ControllerIndexConfigurator" to be defined in file "/app/vendor/composer/../../src/Controller/Admin/Field/ControllerIndexConfigurator.php". The file was found but the class was not in it, the class name or namespace probably has a typo.

@daffoxdev
Copy link
Author

daffoxdev commented Apr 6, 2021

Hi @ganti
Maybe the reason is that your application has little different namespace? And in this file where your class located need to change its namespace little to fit you application, could be this a reason?

Other thing is /app/vendor/composer/../../src/Controller/Admin/Field/ControllerIndexConfigurator.php. It composer vendor folder or it is your custom folder? All this code I have placed in my src of symfony project. I'm using symfony 5+. But you looks like moved all this code to the vendor folder. All this code is as scratch, it's not as 3td party project/extension/bundle, you need to inject this code to you project. I just shared part how I have done this in my project.

@ganti
Copy link

ganti commented Apr 8, 2021

Hi @daffoxdev

Thanks for your answer. Now I can run it, basically. But i face two other issues:

  1. The function addDisplayOn in AbstractAdminCrudController is not defined in AbstractCrudController.
  2. Also the file admin/field/html_field.html.twig is missing.

Is there something missing in this gist ? Do you have a repo somewhere where it works?

@daffoxdev
Copy link
Author

Thanks you that you noticed this things. Updated gist. If you will find anything else, please write it here. I'm using this in private project, so can't show how it works. Here is the first time where I mentioned this gist EasyCorp/EasyAdminBundle#4126

Here is image how it looks. Data are hidden, but you can see that new block appeared on single record page and this new block have list of records.
image

@jmans
Copy link

jmans commented Nov 7, 2023

Nice project but i can get it to work.

Did modify $this->get( to $this->container->get( in AbstractAdminCrudController.php but now I'm stuck to this error-message:
"There is currenly no session available." (HttpKernel.php line 78) (from public/index.php line 5 autoload_runtime.)

Idea what is wrong. I use sf 6.0.16 and EA 4.2.0

@daffoxdev
Copy link
Author

daffoxdev commented Nov 8, 2023

Hmh, strange. Maybe related to new version of EA. We're using 3.5.* in our project, with symfony 5.4. Not know when we will migrate to new version and I would check it. So if you will find-out the reason, write it here, please.

Some thoughts:

Take a look to this method

public static function getSubscribedServices(): array
    {
        return array_merge(parent::getSubscribedServices(), [
            Environment::class => '?'.Environment::class,
        ]);
    }

you can autowire you own service by overload this method in your controller

public static function getSubscribedServices(): array
    {
        return array_merge(parent::getSubscribedServices(), [
            MySomeService::class => '?'.MySomeService::class,
            MySomeService2::class => '?'.MySomeService2::class,
        ]);
    }

public function someMethod()
{
     $service = $this->get(MySomeService::class);
    $service2 = $this->get(MySomeService2::class);
}

Don't use whole container in you services if it is possible. Best practice is to inject each service separately. I think problem is in this, not with this gist files.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment