diff --git a/doc/fields/ChoiceField.rst b/doc/fields/ChoiceField.rst index 5449cb4302..10d90f2fbe 100644 --- a/doc/fields/ChoiceField.rst +++ b/doc/fields/ChoiceField.rst @@ -90,7 +90,15 @@ pages (``index`` and ``detail``):: The built-in badge styles are the same as Bootstrap: ``'success'``, ``'warning'``, ``'danger'``, ``'info'``, ``'primary'``, ``'secondary'``, -``'light'``, ``'dark'``. +``'light'``, ``'dark'``, but you can also pass a custom +``EasyCorp\Bundle\EasyAdminBundle\Field\Style\BadgeStyle`` instance:: + + yield ChoiceField::new('...')->renderAsBadges([ + // $value => $badgeStyleName + 'paid' => BadgeStyle::new()->withBgColor('#00FF00'), + 'pending' => BadgeStyle::new()->withBgColor('#FFFF00'), + 'refunded' => BadgeStyle::new()->withBgColor('#FF0000'), + ]); renderAsNativeWidget ~~~~~~~~~~~~~~~~~~~~ diff --git a/src/Field/ChoiceField.php b/src/Field/ChoiceField.php index 77123da384..25877ff4cd 100644 --- a/src/Field/ChoiceField.php +++ b/src/Field/ChoiceField.php @@ -3,6 +3,7 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Field; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; +use EasyCorp\Bundle\EasyAdminBundle\Field\Style\BadgeStyle; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Contracts\Translation\TranslatableInterface; @@ -22,7 +23,8 @@ final class ChoiceField implements FieldInterface public const OPTION_WIDGET = 'widget'; public const OPTION_ESCAPE_HTML_CONTENTS = 'escapeHtml'; - public const VALID_BADGE_TYPES = ['success', 'warning', 'danger', 'info', 'primary', 'secondary', 'light', 'dark']; + /** @deprecated use BadgeStyle::VALID_BADGE_TYPES instead */ + public const VALID_BADGE_TYPES = BadgeStyle::VALID_BADGE_TYPES; public const WIDGET_AUTOCOMPLETE = 'autocomplete'; public const WIDGET_NATIVE = 'native'; @@ -116,7 +118,7 @@ public function setTranslatableChoices($choiceGenerator): self * * Possible badge types: 'success', 'warning', 'danger', 'info', 'primary', 'secondary', 'light', 'dark' * - * @param array|bool|callable $badgeSelector + * @param array|bool|callable $badgeSelector */ public function renderAsBadges($badgeSelector = true): self { @@ -125,11 +127,17 @@ public function renderAsBadges($badgeSelector = true): self } if (\is_array($badgeSelector)) { - foreach ($badgeSelector as $badgeType) { - if (!\in_array($badgeType, self::VALID_BADGE_TYPES, true)) { - throw new \InvalidArgumentException(sprintf('The values of the array passed to the "%s" method must be one of the following valid badge types: "%s" ("%s" given).', __METHOD__, implode(', ', self::VALID_BADGE_TYPES), $badgeType)); + $badges = []; + foreach ($badgeSelector as $key => $badge) { + if ($badge instanceof BadgeStyle) { + $badges[$key] = $badge; + } elseif (\in_array($badge, BadgeStyle::VALID_BADGE_TYPES, true)) { + $badges[$key] = BadgeStyle::new()->withType($badge); + } else { + throw new \InvalidArgumentException(sprintf('The values of the array passed to the "%s" method must be an instance of "%s" or one of the following valid badge types: "%s" ("%s" given).', __METHOD__, BadgeStyle::class, implode(', ', BadgeStyle::VALID_BADGE_TYPES), $badge)); } } + $badgeSelector = $badges; } $this->setCustomOption(self::OPTION_RENDER_AS_BADGES, $badgeSelector); diff --git a/src/Field/Configurator/ChoiceConfigurator.php b/src/Field/Configurator/ChoiceConfigurator.php index 8be7628b48..686249b50a 100644 --- a/src/Field/Configurator/ChoiceConfigurator.php +++ b/src/Field/Configurator/ChoiceConfigurator.php @@ -8,11 +8,11 @@ use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; +use EasyCorp\Bundle\EasyAdminBundle\Field\Style\BadgeStyle; use EasyCorp\Bundle\EasyAdminBundle\Translation\TranslatableChoiceMessage; use EasyCorp\Bundle\EasyAdminBundle\Translation\TranslatableChoiceMessageCollection; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\EnumType; -use function Symfony\Component\String\u; use function Symfony\Component\Translation\t; use Symfony\Component\Translation\TranslatableMessage; use Symfony\Contracts\Translation\TranslatableInterface; @@ -141,10 +141,13 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c ); } + $badge = $isRenderedAsBadge ? $this->getBadgeStyle($badgeSelector, $selectedValue, $field) : null; + /** @var TranslatableMessage $choiceMessage */ $choiceMessages[] = new TranslatableChoiceMessage( $choiceMessage, - $isRenderedAsBadge ? $this->getBadgeCssClass($badgeSelector, $selectedValue, $field) : null + $badge?->getCssClasses(), + $badge?->getStyle(), ); } } @@ -170,27 +173,27 @@ private function getChoices(array|callable|null $choiceGenerator, EntityDto $ent } /** - * @param array|bool|callable|null $badgeSelector + * @param array|bool|callable|null $badgeSelector */ - private function getBadgeCssClass(array|bool|callable|null $badgeSelector, mixed $value, FieldDto $field): string + private function getBadgeStyle(array|bool|callable|null $badgeSelector, mixed $value, FieldDto $field): ?BadgeStyle { - $commonBadgeCssClass = 'badge'; - - $badgeType = ''; + $badge = null; if (true === $badgeSelector) { - $badgeType = 'badge-secondary'; + $badge = BadgeStyle::new()->withType('secondary'); } elseif (\is_array($badgeSelector)) { - $badgeType = $badgeSelector[$value] ?? 'badge-secondary'; + $badge = $badgeSelector[$value] ?? BadgeStyle::new()->withType('secondary'); } elseif (\is_callable($badgeSelector)) { - $badgeType = $badgeSelector($value, $field); - if (!\in_array($badgeType, ChoiceField::VALID_BADGE_TYPES, true)) { - throw new \RuntimeException(sprintf('The value returned by the callable passed to the "renderAsBadges()" method must be one of the following valid badge types: "%s" ("%s" given).', implode(', ', ChoiceField::VALID_BADGE_TYPES), $badgeType)); + $result = $badgeSelector($value, $field); + if ($result instanceof BadgeStyle) { + $badge = $result; + } elseif (\in_array($result, BadgeStyle::VALID_BADGE_TYPES, true)) { + $badge = BadgeStyle::new()->withType($result); + } else { + throw new \RuntimeException(sprintf('The value returned by the callable passed to the "renderAsBadges()" method must be an instance of "%s" or one of the following valid badge types: "%s" ("%s" given).', BadgeStyle::class, implode(', ', BadgeStyle::VALID_BADGE_TYPES), $result)); } } - $badgeTypeCssClass = '' === $badgeType ? '' : u($badgeType)->ensureStart('badge-')->toString(); - - return $commonBadgeCssClass.' '.$badgeTypeCssClass; + return $badge; } /** diff --git a/src/Field/Style/BadgeStyle.php b/src/Field/Style/BadgeStyle.php new file mode 100644 index 0000000000..6deecaeafb --- /dev/null +++ b/src/Field/Style/BadgeStyle.php @@ -0,0 +1,106 @@ + $cssClasses + * @param array $style + */ + private function __construct(private array $cssClasses, private array $style) + { + } + + public function addCssClass(string $cssClass): self + { + $this->cssClasses[] = $cssClass; + + return $this; + } + + public function addStyle(string $key, string $value): self + { + $this->style[$key] = $value; + + return $this; + } + + public static function new(): self + { + return new self(['badge'], []); + } + + public function withBgColor(string $backgroundColor, bool $autoTextContrast = true): self + { + if ($autoTextContrast) { + $this->addCssClass(self::generateTextClassFromBackgroundColor($backgroundColor)); + } + + return $this->addStyle('background-color', $backgroundColor); + } + + public function withTextColor(string $textColor): self + { + return $this->addStyle('color', $textColor); + } + + /** + * @param value-of $type + */ + public function withType(string $type): self + { + if (!\in_array($type, self::VALID_BADGE_TYPES, true)) { + throw new \InvalidArgumentException(sprintf('Invalid badge type "%s". Allowed types are: "%s".', $type, implode(', ', self::VALID_BADGE_TYPES))); + } + + return $this->addCssClass('badge-'.$type); + } + + public function asPill(): self + { + return $this->addCssClass('badge-pill'); + } + + public function getCssClasses(): ?string + { + if ([] === $this->cssClasses) { + return null; + } + + return implode(' ', $this->cssClasses); + } + + public function getStyle(): ?string + { + if ([] === $this->style) { + return null; + } + + $style = []; + foreach ($this->style as $key => $value) { + $style[] = sprintf('%s:%s;', $key, $value); + } + + return implode(' ', $style); + } + + private static function generateTextClassFromBackgroundColor(string $backgroundColor): string + { + if (1 !== preg_match('/^#[0-9a-f]{6}$/iD', $backgroundColor)) { + throw new \InvalidArgumentException(sprintf('Only full 6-digit hexadecimal color are supported to generate the appropriate text color ("%s" given).', $backgroundColor)); + } + + [$r, $g, $b] = [ + hexdec(substr($backgroundColor, 1, 2)), + hexdec(substr($backgroundColor, 3, 2)), + hexdec(substr($backgroundColor, 5, 2)), + ]; + + $luminance = (0.299 * $r + 0.587 * $g + 0.114 * $b) / 255; + + return $luminance > 0.5 ? 'text-dark' : 'text-light'; + } +} diff --git a/src/Translation/TranslatableChoiceMessage.php b/src/Translation/TranslatableChoiceMessage.php index 9672bb80e4..60f6d21587 100644 --- a/src/Translation/TranslatableChoiceMessage.php +++ b/src/Translation/TranslatableChoiceMessage.php @@ -18,7 +18,8 @@ final class TranslatableChoiceMessage implements TranslatableInterface */ public function __construct( private TranslatableInterface $message, - private ?string $cssClass, + private ?string $cssClasses, + private ?string $style = null, ) { } @@ -26,19 +27,26 @@ public function trans(TranslatorInterface $translator, ?string $locale = null): { $message = $this->message->trans($translator, $locale); - if (null !== $this->cssClass) { - return sprintf('%s', $this->cssClass, $message); - } - - return $message; + return $this->generateHtml($message); } public function __toString(): string { - if (null !== $this->cssClass) { - return sprintf('%s', $this->cssClass, $this->message); + return $this->generateHtml((string) $this->message); + } + + private function generateHtml(string $message): string + { + if (null !== $this->cssClasses || null !== $this->style) { + return sprintf( + '%s', + null !== $this->cssClasses ? sprintf('class="%s"', $this->cssClasses) : '', + null !== $this->cssClasses && null !== $this->style ? ' ' : '', + null !== $this->style ? sprintf('style="%s"', $this->style) : '', + $message + ); } - return (string) $this->message; + return $message; } } diff --git a/tests/Field/ChoiceFieldTest.php b/tests/Field/ChoiceFieldTest.php index 680ae6104f..3e4dc46598 100644 --- a/tests/Field/ChoiceFieldTest.php +++ b/tests/Field/ChoiceFieldTest.php @@ -4,6 +4,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\ChoiceConfigurator; +use EasyCorp\Bundle\EasyAdminBundle\Field\Style\BadgeStyle; use EasyCorp\Bundle\EasyAdminBundle\Tests\Field\Fixtures\ChoiceField\PriorityUnitEnum; use EasyCorp\Bundle\EasyAdminBundle\Tests\Field\Fixtures\ChoiceField\StatusBackedEnum; use function Symfony\Component\Translation\t; @@ -171,5 +172,17 @@ public function testBadges() $field->setValue([1, 3])->renderAsBadges(function ($value) { return $value > 1 ? 'success' : 'primary'; }); self::assertSame('ac', (string) $this->configure($field)->getFormattedValue()); + + $field->setValue(1)->renderAsBadges([1 => BadgeStyle::new()->withBgColor('#123456'), '3' => BadgeStyle::new()->withBgColor('#AAAAAA')]); + self::assertSame('a', (string) $this->configure($field)->getFormattedValue()); + + $field->setValue([1, 3])->renderAsBadges([1 => BadgeStyle::new()->withBgColor('#123456'), '3' => BadgeStyle::new()->withBgColor('#AAAAAA')]); + self::assertSame('ac', (string) $this->configure($field)->getFormattedValue()); + + $field->setValue(1)->renderAsBadges(function ($value) { return $value > 1 ? BadgeStyle::new()->withBgColor('#AAAAAA') : BadgeStyle::new()->withBgColor('#123456'); }); + self::assertSame('a', (string) $this->configure($field)->getFormattedValue()); + + $field->setValue([1, 3])->renderAsBadges(function ($value) { return $value > 1 ? BadgeStyle::new()->withBgColor('#AAAAAA') : BadgeStyle::new()->withBgColor('#123456'); }); + self::assertSame('ac', (string) $this->configure($field)->getFormattedValue()); } }