Skip to content

Commit 2f6c8dc

Browse files
Add method to modify the link in AssociationField
1 parent 717b132 commit 2f6c8dc

8 files changed

Lines changed: 123 additions & 4 deletions

File tree

doc/fields/AssociationField.rst

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,52 @@ to allow HTML rendering::
134134
When ``renderAsHtml`` is ``true``, you must handle escaping yourself
135135
in the template to prevent XSS attacks.
136136

137+
``linkToUrl``
138+
~~~~~~~~~~~~~
139+
140+
By default, to-one associations link to the ``detail`` page of the related entity
141+
and to-many associations are rendered as a plain badge with a count. Use this
142+
option to replace that behaviour with a custom URL.
143+
144+
Pass a static string when the URL is known at configuration time::
145+
146+
yield AssociationField::new('category')->linkToUrl('https://example.com/categories');
147+
148+
Pass a callable when the URL depends on the current entity or the admin context.
149+
The callable receives the ``EntityDto`` and the ``AdminContext`` as arguments::
150+
151+
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
152+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
153+
154+
yield AssociationField::new('products')
155+
->linkToUrl(fn(EntityDto $entityDto): string => $this->generateUrl('admin_product_index', [
156+
'filters' => [
157+
'category' => [
158+
'comparison' => '=',
159+
'value' => [
160+
'autocomplete' => $entityDto->getPrimaryKeyValue(),
161+
],
162+
]
163+
]
164+
]))
165+
;
166+
167+
**Rendering depending on association type**
168+
169+
For **to-one** associations, the value is rendered as a plain ``<a>`` tag pointing
170+
to the given URL. This URL takes priority over the auto-generated CRUD link::
171+
172+
// to-one: renders <a href="https://example.com">CategoryName</a>
173+
yield AssociationField::new('category')->linkToUrl('https://example.com');
174+
175+
For **to-many** associations, the count badge becomes clickable (rendered as an
176+
``<a>`` tag instead of a ``<span>``)::
177+
178+
// to-many: renders a clickable badge with the count
179+
yield AssociationField::new('tags')->linkToUrl('https://example.com/tags');
180+
181+
182+
137183
``renderAsNativeWidget``
138184
~~~~~~~~~~~~~~~~~~~~~~~~
139185

src/Field/AssociationField.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ final class AssociationField implements FieldInterface
1919
public const OPTION_EMBEDDED_CRUD_FORM_CONTROLLER = 'crudControllerFqcn';
2020
public const OPTION_WIDGET = 'widget';
2121
public const OPTION_QUERY_BUILDER_CALLABLE = 'queryBuilderCallable';
22+
public const OPTION_URL = 'url';
2223
/** @internal this option is intended for internal use only */
2324
public const OPTION_RELATED_URL = 'relatedUrl';
2425
/** @internal this option is intended for internal use only */
@@ -55,6 +56,7 @@ public static function new(string $propertyName, TranslatableInterface|string|bo
5556
->setCustomOption(self::OPTION_EMBEDDED_CRUD_FORM_CONTROLLER, null)
5657
->setCustomOption(self::OPTION_WIDGET, self::WIDGET_AUTOCOMPLETE)
5758
->setCustomOption(self::OPTION_QUERY_BUILDER_CALLABLE, null)
59+
->setCustomOption(self::OPTION_URL, null)
5860
->setCustomOption(self::OPTION_RELATED_URL, null)
5961
->setCustomOption(self::OPTION_DOCTRINE_ASSOCIATION_TYPE, null)
6062
->setCustomOption(self::OPTION_RENDER_AS_EMBEDDED_FORM, false)
@@ -107,6 +109,13 @@ public function setQueryBuilder(\Closure $queryBuilderCallable): self
107109
return $this;
108110
}
109111

112+
public function linkToUrl(string|callable $url): self
113+
{
114+
$this->setCustomOption(self::OPTION_URL, $url);
115+
116+
return $this;
117+
}
118+
110119
public function renderAsEmbeddedForm(?string $crudControllerFqcn = null, ?string $crudNewPageName = null, ?string $crudEditPageName = null): self
111120
{
112121
$this->setCustomOption(self::OPTION_RENDER_AS_EMBEDDED_FORM, true);

src/Field/Configurator/AssociationConfigurator.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,12 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c
215215
return $queryBuilder;
216216
});
217217
}
218+
219+
$url = $field->getCustomOption(AssociationField::OPTION_URL);
220+
if (is_callable($url)) {
221+
$url = $url($entityDto, $context);
222+
$field->setCustomOption(AssociationField::OPTION_URL, $url);
223+
}
218224
}
219225

220226
/**
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
<span {{- attributes.defaults({class: this.defaultCssClass()}) }}>
1+
{% if attributes.has('href') %}<a {{- attributes.defaults({class: this.defaultCssClass()}) }}>{% else %}<span {{- attributes.defaults({class: this.defaultCssClass()}) }}>{% endif %}
22
{%- if this.icon %}<twig:ea:Icon name="{{ this.icon }}" />{% endif -%}
33
<twig:block name="content"></twig:block>
44
{%- if this.endIcon %}<twig:ea:Icon name="{{ this.endIcon }}" />{% endif -%}
5-
</span>{#--#}
5+
{% if attributes.has('href') %}</a>{% else %}</span>{% endif %}{#--#}

templates/crud/field/association.html.twig

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22
{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #}
33
{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
44
{% if 'toMany' == field.customOptions.get('associationType') %}
5-
<twig:ea:Badge variant="secondary">{{ field.formattedValue }}</twig:ea:Badge>
5+
{% if field.customOptions.get('url') is not null %}
6+
<twig:ea:Badge variant="info" href="{{ field.customOptions.get('url') }}">{{ field.formattedValue }}</twig:ea:Badge>
7+
{% else %}
8+
<twig:ea:Badge variant="secondary">{{ field.formattedValue }}</twig:ea:Badge>
9+
{% endif %}
610
{% else %}
7-
{% if field.customOptions.get('relatedUrl') is not null %}
11+
{% if field.customOptions.get('url') is not null %}
12+
<a href="{{ field.customOptions.get('url') }}">{{ field.formattedValue }}</a>
13+
{% elseif field.customOptions.get('relatedUrl') is not null %}
814
<a href="{{ field.customOptions.get('relatedUrl') }}">{{ field.formattedValue }}</a>
915
{% else %}
1016
{{ field.formattedValue }}

tests/Functional/Component/BadgeTest.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,12 @@ public function testTrailingIconIsRenderedAfterLabel(): void
9898
self::assertStringContainsString('class="icon"', $html);
9999
self::assertGreaterThan(strpos($html, 'Label'), strpos($html, 'class="icon"'));
100100
}
101+
102+
public function testLink(): void
103+
{
104+
self::assertSame(
105+
'<a class="badge badge-secondary" href="https://example.com">x</a>',
106+
$this->renderBadge('<twig:ea:Badge href="https://example.com">x</twig:ea:Badge>')
107+
);
108+
}
101109
}

tests/Unit/Field/AssociationFieldTest.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public function testDefaultOptions(): void
4040
self::assertNull($fieldDto->getCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_CONTROLLER));
4141
self::assertSame(AssociationField::WIDGET_AUTOCOMPLETE, $fieldDto->getCustomOption(AssociationField::OPTION_WIDGET));
4242
self::assertNull($fieldDto->getCustomOption(AssociationField::OPTION_QUERY_BUILDER_CALLABLE));
43+
self::assertNull($fieldDto->getCustomOption(AssociationField::OPTION_URL));
4344
self::assertFalse($fieldDto->getCustomOption(AssociationField::OPTION_RENDER_AS_EMBEDDED_FORM));
4445
self::assertTrue($fieldDto->getCustomOption(AssociationField::OPTION_ESCAPE_HTML_CONTENTS));
4546
self::assertSame(EntityType::class, $fieldDto->getFormType());
@@ -102,6 +103,25 @@ public function testSetQueryBuilder(): void
102103
self::assertSame($queryBuilder, $fieldDto->getCustomOption(AssociationField::OPTION_QUERY_BUILDER_CALLABLE));
103104
}
104105

106+
public function testLinktToUrl(): void
107+
{
108+
$field = AssociationField::new('category');
109+
$field->linkToUrl('https://example.com');
110+
$fieldDto = $this->configure($field);
111+
112+
self::assertSame('https://example.com', $fieldDto->getCustomOption(AssociationField::OPTION_URL));
113+
}
114+
115+
public function testLinkToUrlCallable(): void
116+
{
117+
$callable = static fn (EntityDto $entityDto): string => 'https://example.com/category/'.$entityDto->getPrimaryKeyValueAsString();
118+
$field = AssociationField::new('category');
119+
$field->linkToUrl($callable);
120+
$fieldDto = $this->configure($field);
121+
122+
self::assertSame($callable, $fieldDto->getCustomOption(AssociationField::OPTION_URL));
123+
}
124+
105125
public function testRenderAsEmbeddedForm(): void
106126
{
107127
$field = AssociationField::new('category');

tests/Unit/Field/Configurator/AssociationConfiguratorTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,30 @@ public function testAssociationLinkIsHiddenWhenTargetActionPermissionDenies(): v
243243
$this->assertNull($fieldDto->getCustomOption(AssociationField::OPTION_RELATED_URL));
244244
}
245245

246+
public function testLinkToUrl(): void
247+
{
248+
$field = $this->buildLeadDeveloperField()
249+
->linkToUrl('https://example.com')
250+
;
251+
252+
$fieldDto = $this->configure($field);
253+
254+
$this->assertSame('https://example.com', $fieldDto->getCustomOption(AssociationField::OPTION_URL));
255+
}
256+
257+
public function testLinkToUrlCallable(): void
258+
{
259+
$field = $this->buildLeadDeveloperField()
260+
->linkToUrl(function(EntityDto $entityDto, AdminContextInterface $context) {
261+
return sprintf('https://example.com/%s/%s', $context->getRequest()->getLocale(), $entityDto->getName());
262+
})
263+
;
264+
265+
$fieldDto = $this->configure($field);
266+
267+
$this->assertSame('https://example.com/en/Project', $fieldDto->getCustomOption(AssociationField::OPTION_URL));
268+
}
269+
246270
private function buildLeadDeveloperField(): AssociationField
247271
{
248272
$field = AssociationField::new('leadDeveloper');

0 commit comments

Comments
 (0)