diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 99b0b355467..7ced70a2901 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -54,19 +54,19 @@ jobs: cd src/LazyImage composer update --prefer-lowest --prefer-dist --no-interaction --no-ansi --no-progress php vendor/bin/simple-phpunit - - name: TwigComponent - run: | - cd src/TwigComponent - composer update --prefer-lowest --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit - tests-php-low-deps-74: + tests-php8-low-deps: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - uses: shivammathur/setup-php@v2 with: - php-version: '7.4' + php-version: '8.0' + - name: TwigComponent + run: | + cd src/TwigComponent + composer update --prefer-lowest --prefer-dist --no-interaction --no-ansi --no-progress + php vendor/bin/simple-phpunit - name: LiveComponent run: | cd src/LiveComponent @@ -108,14 +108,12 @@ jobs: - name: TwigComponent run: | cd src/TwigComponent - composer config platform.php 7.4.99 composer update --prefer-dist --no-interaction --no-ansi --no-progress php vendor/bin/simple-phpunit - name: LiveComponent run: | cd src/LiveComponent php ../../.github/build-packages.php - composer config platform.php 7.4.99 composer update --prefer-dist --no-interaction --no-ansi --no-progress php vendor/bin/simple-phpunit diff --git a/src/LiveComponent/README.md b/src/LiveComponent/README.md index 685c9a7bcca..28925b55eb9 100644 --- a/src/LiveComponent/README.md +++ b/src/LiveComponent/README.md @@ -15,9 +15,10 @@ A real-time product search component might look like this: // src/Components/ProductSearchComponent.php namespace App\Components; -use Symfony\UX\LiveComponent\LiveComponentInterface; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; -class ProductSearchComponent implements LiveComponentInterface +#[AsLiveComponent('product_search')] +class ProductSearchComponent { public string $query = ''; @@ -33,11 +34,6 @@ class ProductSearchComponent implements LiveComponentInterface // example method that returns an array of Products return $this->productRepository->search($this->query); } - - public static function getComponentName(): string - { - return 'product_search'; - } } ``` @@ -103,19 +99,15 @@ Suppose you've already built a basic Twig component: // src/Components/RandomNumberComponent.php namespace App\Components; -use Symfony\UX\TwigComponent\ComponentInterface; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; -class RandomNumberComponent implements ComponentInterface +#[AsTwigComponent('random_number')] +class RandomNumberComponent { public function getRandomNumber(): string { return rand(0, 1000); } - - public static function getComponentName(): string - { - return 'random_number'; - } } ``` @@ -127,16 +119,18 @@ class RandomNumberComponent implements ComponentInterface ``` To transform this into a "live" component (i.e. one that -can be re-rendered live on the frontend), change your -component's interface to `LiveComponentInterface`: +can be re-rendered live on the frontend), replace the +component's `AsTwigComponent` attribute with `AsLiveComponent`: ```diff // src/Components/RandomNumberComponent.php -+use Symfony\UX\LiveComponent\LiveComponentInterface; +-use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; ++use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; --class RandomNumberComponent implements ComponentInterface -+class RandomNumberComponent implements LiveComponentInterface +-#[AsTwigComponent('random_number')] +-#[AsLiveComponent('random_number')] +class RandomNumberComponent { } ``` @@ -183,11 +177,13 @@ namespace App\Components; // ... use Symfony\UX\LiveComponent\Attribute\LiveProp; -class RandomNumberComponent implements LiveComponentInterface +#[AsLiveComponent('random_number')] +class RandomNumberComponent { - /** @LiveProp */ + #[LiveProp] public int $min = 0; - /** @LiveProp */ + + #[LiveProp] public int $max = 1000; public function getRandomNumber(): string @@ -206,14 +202,14 @@ when rendering the component: {{ component('random_number', { min: 5, max: 500 }) }} ``` -But what's up with those `@LiveProp` annotations? A property with -the `@LiveProp` annotation (or `LiveProp` PHP 8 attribute) becomes -a "stateful" property for this component. In other words, each time -we click the "Generate a new number!" button, when the component -re-renders, it will _remember_ the original values for the `$min` and -`$max` properties and generate a random number between 5 and 500. -If you forgot to add `@LiveProp`, when the component re-rendered, -those two values would _not_ be set on the object. +But what's up with those `LiveProp` attributes? A property with +the `LiveProp` attribute becomes a "stateful" property for this +component. In other words, each time we click the "Generate a +new number!" button, when the component re-renders, it will +_remember_ the original values for the `$min` and `$max` properties +and generate a random number between 5 and 500. If you forgot to +add `LiveProp`, when the component re-rendered, those two values +would _not_ be set on the object. In short: LiveProps are "stateful properties": they will always be set when rendering. Most properties will be LiveProps, with @@ -267,13 +263,14 @@ the `writable=true` option: // src/Components/RandomNumberComponent.php // ... -class RandomNumberComponent implements LiveComponentInterface +class RandomNumberComponent { -- /** @LiveProp() */ -+ /** @LiveProp(writable=true) */ +- #[LiveProp] ++ #[LiveProp(writable: true)] public int $min = 0; -- /** @LiveProp() */ -+ /** @LiveProp(writable=true) */ + +- #[LiveProp] ++ #[LiveProp(writable: true)] public int $max = 1000; // ... @@ -428,8 +425,8 @@ want to add a "Reset Min/Max" button to our "random number" component that, when clicked, sets the min/max numbers back to a default value. -First, add a method with a `LiveAction` annotation (or PHP 8 attribute) -above it that does the work: +First, add a method with a `LiveAction` attribute above it that +does the work: ```php // src/Components/RandomNumberComponent.php @@ -438,13 +435,11 @@ namespace App\Components; // ... use Symfony\UX\LiveComponent\Attribute\LiveAction; -class RandomNumberComponent implements LiveComponentInterface +class RandomNumberComponent { // ... - /** - * @LiveAction - */ + #[LiveAction] public function resetMinMax() { $this->min = 0; @@ -503,13 +498,11 @@ namespace App\Components; // ... use Psr\Log\LoggerInterface; -class RandomNumberComponent implements LiveComponentInterface +class RandomNumberComponent { // ... - /** - * @LiveAction - */ + #[LiveAction] public function resetMinMax(LoggerInterface $logger) { $this->min = 0; @@ -548,13 +541,11 @@ namespace App\Components; // ... use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -class RandomNumberComponent extends AbstractController implements LiveComponentInterface +class RandomNumberComponent extends AbstractController { // ... - /** - * @LiveAction - */ + #[LiveAction] public function resetMinMax() { // ... @@ -684,11 +675,12 @@ use App\Entity\Post; use App\Form\PostType; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\FormInterface; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveProp; -use Symfony\UX\LiveComponent\LiveComponentInterface; use Symfony\UX\LiveComponent\ComponentWithFormTrait; -class PostFormComponent extends AbstractController implements LiveComponentInterface +#[AsLiveComponent('post_form')] +class PostFormComponent extends AbstractController { use ComponentWithFormTrait; @@ -698,13 +690,12 @@ class PostFormComponent extends AbstractController implements LiveComponentInter * Needed so the same form can be re-created * when the component is re-rendered via Ajax. * - * The fieldName="" option is needed in this situation because + * The `fieldName` option is needed in this situation because * the form renders fields with names like `name="post[title]"`. - * We set fieldName="" so that this live prop doesn't collide + * We set `fieldName: ''` so that this live prop doesn't collide * with that data. The value - initialFormData - could be anything. - * - * @LiveProp(fieldName="initialFormData") */ + #[LiveProp(fieldName: 'initialFormData')] public ?Post $post = null; /** @@ -715,11 +706,6 @@ class PostFormComponent extends AbstractController implements LiveComponentInter // we can extend AbstractController to get the normal shortcuts return $this->createForm(PostType::class, $this->post); } - - public static function getComponentName(): string - { - return 'post_form'; - } } ``` @@ -875,13 +861,11 @@ action to the component: use Doctrine\ORM\EntityManagerInterface; use Symfony\UX\LiveComponent\Attribute\LiveAction; -class PostFormComponent extends AbstractController implements LiveComponentInterface +class PostFormComponent extends AbstractController { // ... - /** - * @LiveAction() - */ + #[LiveAction] public function save(EntityManagerInterface $entityManager) { // shortcut to submit the form with form values @@ -932,20 +916,14 @@ that is being edited: namespace App\Twig\Components; use App\Entity\Post; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveProp; -use Symfony\UX\LiveComponent\LiveComponentInterface; -class EditPostComponent implements LiveComponentInterface +#[AsLiveComponent('edit_post')] +class EditPostComponent { - /** - * @LiveProp() - */ + #[LiveProp] public Post $post; - - public static function getComponentName(): string - { - return 'edit_post'; - } } ``` @@ -985,12 +963,10 @@ you can enable it via the `exposed` option: ```diff // ... -class EditPostComponent implements LiveComponentInterface +class EditPostComponent { - /** -- * @LiveProp(exposed={}) -+ * @LiveProp(exposed={"title", "content"}) - */ +- #[LiveProp] ++ #[LiveProp(exposed: ['title', 'content'])] public Post $post; // ... @@ -1020,36 +996,28 @@ First use the `ValidatableComponentTrait` and add any constraints you need: ```php use App\Entity\User; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveProp; -use Symfony\UX\LiveComponent\LiveComponentInterface; use Symfony\UX\LiveComponent\ValidatableComponentTrait; use Symfony\Component\Validator\Constraints as Assert; -class EditUserComponent implements LiveComponentInterface +#[AsLiveComponent('edit_user')] +class EditUserComponent { use ValidatableComponentTrait; - /** - * @LiveProp(exposed={"email", "plainPassword"}) - * @Assert\Valid() - */ + #[LiveProp(exposed: ['email', 'plainPassword'])] + #[Assert\Valid] public User $user; - /** - * @LiveProp() - * @Assert\IsTrue() - */ + #[LiveProp] + #[Assert\IsTrue] public bool $agreeToTerms = false; - - public static function getComponentName() : string - { - return 'edit_user'; - } } ``` -Be sure to add the `@Assert\IsValid` to any property where you want -the object on that property to also be validated. +Be sure to add the `IsValid` attribute/annotation to any property where +you want the object on that property to also be validated. Thanks to this setup, the component will now be automatically validated on each render, but in a smart way: a property will only be validated @@ -1063,13 +1031,12 @@ in an action: ```php use Symfony\UX\LiveComponent\Attribute\LiveAction; -class EditUserComponent implements LiveComponentInterface +#[AsLiveComponent('edit_user')] +class EditUserComponent { // ... - /** - * @LiveAction() - */ + #[LiveAction] public function save() { // this will throw an exception if validation fails diff --git a/src/LiveComponent/composer.json b/src/LiveComponent/composer.json index ce3a8926c28..d9a1429b2d5 100644 --- a/src/LiveComponent/composer.json +++ b/src/LiveComponent/composer.json @@ -26,7 +26,7 @@ } }, "require": { - "php": ">=7.4", + "php": ">=8.0", "symfony/ux-twig-component": "^1.4" }, "require-dev": { @@ -40,7 +40,8 @@ "doctrine/doctrine-bundle": "^2.0", "doctrine/orm": "^2.7", "zenstruck/foundry": "^1.10", - "zenstruck/browser": "^0.5.0" + "zenstruck/browser": "^0.5.0", + "doctrine/annotations": "^1.0" }, "extra": { "branch-alias": { diff --git a/src/LiveComponent/src/Attribute/AsLiveComponent.php b/src/LiveComponent/src/Attribute/AsLiveComponent.php new file mode 100644 index 00000000000..c5fa0c24211 --- /dev/null +++ b/src/LiveComponent/src/Attribute/AsLiveComponent.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Attribute; + +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; + +/** + * @author Kevin Bond + * + * @experimental + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +final class AsLiveComponent extends AsTwigComponent +{ + /** + * @internal + * + * @return LivePropContext[] + */ + public static function liveProps(object $component): \Traversable + { + $properties = []; + + foreach (self::propertiesFor($component) as $property) { + if (!$attribute = $property->getAttributes(LiveProp::class)[0] ?? null) { + continue; + } + + if (\in_array($property->getName(), $properties, true)) { + // property name was already used + continue; + } + + $properties[] = $property->getName(); + + yield new LivePropContext($attribute->newInstance(), $property); + } + } + + /** + * @internal + */ + public static function isActionAllowed(object $component, string $action): bool + { + foreach (self::attributeMethodsFor(LiveAction::class, $component) as $method) { + if ($action === $method->getName()) { + return true; + } + } + + return false; + } + + /** + * @internal + * + * @return \ReflectionMethod[] + */ + public static function beforeReRenderMethods(object $component): \Traversable + { + yield from self::attributeMethodsFor(BeforeReRender::class, $component); + } + + /** + * @internal + * + * @return \ReflectionMethod[] + */ + public static function postHydrateMethods(object $component): \Traversable + { + yield from self::attributeMethodsFor(PostHydrate::class, $component); + } + + /** + * @internal + * + * @return \ReflectionMethod[] + */ + public static function preDehydrateMethods(object $component): \Traversable + { + yield from self::attributeMethodsFor(PreDehydrate::class, $component); + } + + /** + * @param string|object $classOrObject + * + * @return \ReflectionMethod[] + */ + private static function attributeMethodsFor(string $attribute, object $component): \Traversable + { + foreach ((new \ReflectionClass($component))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getAttributes($attribute)[0] ?? null) { + yield $method; + } + } + } + + /** + * @return \ReflectionProperty[] + */ + private static function propertiesFor(object $object): \Traversable + { + $class = $object instanceof \ReflectionClass ? $object : new \ReflectionClass($object); + + foreach ($class->getProperties() as $property) { + yield $property; + } + + if ($parent = $class->getParentClass()) { + yield from self::propertiesFor($parent); + } + } +} diff --git a/src/LiveComponent/src/Attribute/BeforeReRender.php b/src/LiveComponent/src/Attribute/BeforeReRender.php index 9709231bc27..637e03e2744 100644 --- a/src/LiveComponent/src/Attribute/BeforeReRender.php +++ b/src/LiveComponent/src/Attribute/BeforeReRender.php @@ -17,11 +17,9 @@ * This hook ONLY happens when rendering via HTTP: it does * not happen during the initial render of a component. * - * @Annotation - * @Target("METHOD") - * * @experimental */ +#[\Attribute(\Attribute::TARGET_METHOD)] final class BeforeReRender { } diff --git a/src/LiveComponent/src/Attribute/LiveAction.php b/src/LiveComponent/src/Attribute/LiveAction.php index 01986ebf6ee..5a49ba4f3de 100644 --- a/src/LiveComponent/src/Attribute/LiveAction.php +++ b/src/LiveComponent/src/Attribute/LiveAction.php @@ -12,11 +12,9 @@ namespace Symfony\UX\LiveComponent\Attribute; /** - * @Annotation - * @Target("METHOD") - * * @experimental */ +#[\Attribute(\Attribute::TARGET_METHOD)] final class LiveAction { } diff --git a/src/LiveComponent/src/Attribute/LiveProp.php b/src/LiveComponent/src/Attribute/LiveProp.php index 01af54801f1..c547ed2ad7c 100644 --- a/src/LiveComponent/src/Attribute/LiveProp.php +++ b/src/LiveComponent/src/Attribute/LiveProp.php @@ -11,27 +11,20 @@ namespace Symfony\UX\LiveComponent\Attribute; -use Symfony\UX\LiveComponent\LiveComponentInterface; - /** - * @Annotation - * @Target("PROPERTY") - * * @experimental */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] final class LiveProp { - /** @var bool */ - private $writable = false; + private bool $writable; /** @var string[] */ - private $exposed = []; + private array $exposed; - /** @var string|null */ - private $hydrateWith = null; + private ?string $hydrateWith; - /** @var string|null */ - private $dehydrateWith = null; + private ?string $dehydrateWith; /** *The "frontend" field name that should be used for this property. @@ -41,22 +34,21 @@ final class LiveProp * * If you pass a string that ends in () - like "getFieldName()" - that * method on the component will be called to determine this. - * - * @var string|null */ - private $fieldName = null; - - public function __construct(array $values) - { - $validOptions = ['writable', 'exposed', 'hydrateWith', 'dehydrateWith', 'fieldName']; - - foreach ($values as $name => $value) { - if (!\in_array($name, $validOptions)) { - throw new \InvalidArgumentException(sprintf('Unknown option "%s" passed to LiveProp. Valid options are: %s.', $name, implode(', ', $validOptions))); - } - - $this->$name = $value; - } + private ?string $fieldName; + + public function __construct( + bool $writable = false, + array $exposed = [], + ?string $hydrateWith = null, + ?string $dehydrateWith = null, + ?string $fieldName = null + ) { + $this->writable = $writable; + $this->exposed = $exposed; + $this->hydrateWith = $hydrateWith; + $this->dehydrateWith = $dehydrateWith; + $this->fieldName = $fieldName; } public function isReadonly(): bool @@ -79,7 +71,7 @@ public function dehydrateMethod(): ?string return $this->dehydrateWith ? trim($this->dehydrateWith, '()') : null; } - public function calculateFieldName(LiveComponentInterface $component, string $fallback): string + public function calculateFieldName(object $component, string $fallback): string { if (!$this->fieldName) { return $fallback; diff --git a/src/LiveComponent/src/Attribute/LivePropContext.php b/src/LiveComponent/src/Attribute/LivePropContext.php new file mode 100644 index 00000000000..a66a39ddf82 --- /dev/null +++ b/src/LiveComponent/src/Attribute/LivePropContext.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Attribute; + +/** + * @author Kevin Bond + * + * @experimental + * + * @internal + */ +final class LivePropContext +{ + private LiveProp $liveProp; + private \ReflectionProperty $reflectionProperty; + + public function __construct(LiveProp $liveProp, \ReflectionProperty $reflectionProperty) + { + $this->liveProp = $liveProp; + $this->reflectionProperty = $reflectionProperty; + } + + public function liveProp(): LiveProp + { + return $this->liveProp; + } + + public function reflectionProperty(): \ReflectionProperty + { + return $this->reflectionProperty; + } +} diff --git a/src/LiveComponent/src/Attribute/PostHydrate.php b/src/LiveComponent/src/Attribute/PostHydrate.php index 6f6da92fb5a..254933b10fe 100644 --- a/src/LiveComponent/src/Attribute/PostHydrate.php +++ b/src/LiveComponent/src/Attribute/PostHydrate.php @@ -12,11 +12,9 @@ namespace Symfony\UX\LiveComponent\Attribute; /** - * @Annotation - * @Target("METHOD") - * * @experimental */ +#[\Attribute(\Attribute::TARGET_METHOD)] final class PostHydrate { } diff --git a/src/LiveComponent/src/Attribute/PreDehydrate.php b/src/LiveComponent/src/Attribute/PreDehydrate.php index 254a60b9fb1..fc779dc984a 100644 --- a/src/LiveComponent/src/Attribute/PreDehydrate.php +++ b/src/LiveComponent/src/Attribute/PreDehydrate.php @@ -12,11 +12,9 @@ namespace Symfony\UX\LiveComponent\Attribute; /** - * @Annotation - * @Target("METHOD") - * * @experimental */ +#[\Attribute(\Attribute::TARGET_METHOD)] final class PreDehydrate { } diff --git a/src/LiveComponent/src/ComponentValidator.php b/src/LiveComponent/src/ComponentValidator.php index 7301ce06202..d65223c2b3f 100644 --- a/src/LiveComponent/src/ComponentValidator.php +++ b/src/LiveComponent/src/ComponentValidator.php @@ -12,7 +12,6 @@ namespace Symfony\UX\LiveComponent; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; @@ -26,8 +25,7 @@ */ class ComponentValidator implements ComponentValidatorInterface, ServiceSubscriberInterface { - /** @var ContainerInterface */ - private $container; + private ContainerInterface $container; public function __construct(ContainerInterface $container) { @@ -88,16 +86,10 @@ private function getValidator(): ValidatorInterface return $this->container->get('validator'); } - private function getPropertyAccessor(): PropertyAccessorInterface - { - return $this->container->get('property_accessor'); - } - public static function getSubscribedServices(): array { return [ 'validator' => ValidatorInterface::class, - 'property_accessor' => PropertyAccessorInterface::class, ]; } } diff --git a/src/LiveComponent/src/ComponentWithFormTrait.php b/src/LiveComponent/src/ComponentWithFormTrait.php index 50ee34729b0..bcd0f20092e 100644 --- a/src/LiveComponent/src/ComponentWithFormTrait.php +++ b/src/LiveComponent/src/ComponentWithFormTrait.php @@ -30,16 +30,14 @@ trait ComponentWithFormTrait /** * Holds the name prefix the form uses. - * - * @LiveProp() */ + #[LiveProp] public ?string $formName = null; /** * Holds the raw form values. - * - * @LiveProp(writable=true, fieldName="getFormName()") */ + #[LiveProp(writable: true, fieldName: 'getFormName()')] public ?array $formValues = null; /** @@ -47,24 +45,18 @@ trait ComponentWithFormTrait * * This is used to know if validation should be automatically applied * when rendering. - * - * @LiveProp(writable=true) - * - * @var bool */ - public $isValidated = false; + #[LiveProp(writable: true)] + public bool $isValidated = false; /** * Tracks which specific fields have been validated. * * Instead of validating the entire object (isValidated), * the component can be validated, field-by-field. - * - * @LiveProp(writable=true) - * - * @var array */ - public $validatedFields = []; + #[LiveProp(writable: true)] + public array $validatedFields = []; /** * Return the full, top-level, Form object that this component uses. @@ -90,10 +82,9 @@ public function mount(?FormView $form = null) * This primarily applies to a re-render where $actionName is null. * But, in the event that there is an action and the form was * not submitted manually, it will be submitted here. - * - * @BeforeReRender() */ - public function submitFormOnRender() + #[BeforeReRender] + public function submitFormOnRender(): void { if (!$this->getFormInstance()->isSubmitted()) { $this->submitForm(false); @@ -119,7 +110,7 @@ public function getForm(): FormView * don't need to call this directly: the form will be set for * you from your instantiateForm() method. */ - public function setForm(FormView $form) + public function setForm(FormView $form): void { $this->formView = $form; } @@ -142,7 +133,7 @@ public function getFormValues(): array return $this->formValues; } - private function submitForm(bool $validateAll = true) + private function submitForm(bool $validateAll = true): void { $this->getFormInstance()->submit($this->formValues); @@ -201,7 +192,7 @@ private function getFormInstance(): FormInterface return $this->formInstance; } - private function clearErrorsForNonValidatedFields(Form $form, $currentPath = '') + private function clearErrorsForNonValidatedFields(Form $form, $currentPath = ''): void { if (!$currentPath || !\in_array($currentPath, $this->validatedFields, true)) { $form->clearErrors(); diff --git a/src/LiveComponent/src/DefaultComponentController.php b/src/LiveComponent/src/DefaultComponentController.php index 6ac9097208f..235a613e5f9 100644 --- a/src/LiveComponent/src/DefaultComponentController.php +++ b/src/LiveComponent/src/DefaultComponentController.php @@ -11,8 +11,6 @@ namespace Symfony\UX\LiveComponent; -use Symfony\UX\TwigComponent\ComponentInterface; - /** * @author Kevin Bond * @@ -22,15 +20,10 @@ */ final class DefaultComponentController { - /** @var LiveComponentInterface */ - private $component; + private object $component; - public function __construct(ComponentInterface $component) + public function __construct(object $component) { - if (!$component instanceof LiveComponentInterface) { - throw new \InvalidArgumentException('Not an instance of LiveComponentInterface.'); - } - $this->component = $component; } @@ -38,7 +31,7 @@ public function __invoke(): void { } - public function getComponent(): LiveComponentInterface + public function getComponent(): object { return $this->component; } diff --git a/src/LiveComponent/src/DependencyInjection/Compiler/LiveComponentPass.php b/src/LiveComponent/src/DependencyInjection/Compiler/LiveComponentPass.php new file mode 100644 index 00000000000..dc0541b4ade --- /dev/null +++ b/src/LiveComponent/src/DependencyInjection/Compiler/LiveComponentPass.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class LiveComponentPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + $componentServiceMap = []; + + foreach (array_keys($container->findTaggedServiceIds('twig.component')) as $id) { + try { + $attribute = AsLiveComponent::forClass($container->findDefinition($id)->getClass()); + } catch (\InvalidArgumentException $e) { + continue; + } + + $componentServiceMap[$attribute->getName()] = $id; + } + + $container->findDefinition('ux.live_component.event_subscriber')->setArgument(0, $componentServiceMap); + } +} diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 797c706d073..fd6d7680069 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -1,70 +1,96 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\LiveComponent\DependencyInjection; +use Symfony\Component\DependencyInjection\Argument\AbstractArgument; use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\ComponentValidator; use Symfony\UX\LiveComponent\ComponentValidatorInterface; +use Symfony\UX\LiveComponent\DependencyInjection\Compiler\LiveComponentPass; use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber; use Symfony\UX\LiveComponent\Hydrator\DoctrineEntityPropertyHydrator; use Symfony\UX\LiveComponent\Hydrator\NormalizerBridgePropertyHydrator; use Symfony\UX\LiveComponent\LiveComponentHydrator; -use Symfony\UX\LiveComponent\LiveComponentInterface; use Symfony\UX\LiveComponent\PropertyHydratorInterface; use Symfony\UX\LiveComponent\Twig\LiveComponentExtension as LiveComponentTwigExtension; use Symfony\UX\LiveComponent\Twig\LiveComponentRuntime; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentRenderer; /** * @author Kevin Bond + * + * @experimental */ final class LiveComponentExtension extends Extension { public function load(array $configs, ContainerBuilder $container): void { - $container->registerForAutoconfiguration(LiveComponentInterface::class) - ->addTag('controller.service_arguments') - ; + if (method_exists($container, 'registerAttributeForAutoconfiguration')) { + $container->registerAttributeForAutoconfiguration(AsLiveComponent::class, function (ChildDefinition $definition) { + $definition + ->addTag('twig.component') + ->addTag('controller.service_arguments') + ; + }); + } $container->registerForAutoconfiguration(PropertyHydratorInterface::class) ->addTag('twig.component.property_hydrator') ; - $container->register(DoctrineEntityPropertyHydrator::class) + $container->register('ux.live_component.doctrine_entity_property_hydrator', DoctrineEntityPropertyHydrator::class) ->setArguments([[new Reference('doctrine')]]) ->addTag('twig.component.property_hydrator', ['priority' => -200]) ; - $container->register('twig.component.datetime_property_hydrator', NormalizerBridgePropertyHydrator::class) + $container->register('ux.live_component.datetime_property_hydrator', NormalizerBridgePropertyHydrator::class) ->setArguments([new Reference('serializer.normalizer.datetime')]) ->addTag('twig.component.property_hydrator', ['priority' => -100]) ; - $container->register(LiveComponentHydrator::class) + $container->register('ux.live_component.component_hydrator', LiveComponentHydrator::class) ->setArguments([ new TaggedIteratorArgument('twig.component.property_hydrator'), new Reference('property_accessor'), - new Reference('annotation_reader'), '%kernel.secret%', ]) ; - $container->register(LiveComponentSubscriber::class) + $container->register('ux.live_component.event_subscriber', LiveComponentSubscriber::class) + ->setArguments([ + class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %s.', LiveComponentPass::class)) : [], + ]) ->addTag('kernel.event_subscriber') + ->addTag('container.service_subscriber', ['key' => ComponentFactory::class, 'id' => 'ux.twig_component.component_factory']) + ->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer']) + ->addTag('container.service_subscriber', ['key' => LiveComponentHydrator::class, 'id' => 'ux.live_component.component_hydrator']) ->addTag('container.service_subscriber') ; - $container->register(LiveComponentTwigExtension::class) + $container->register('ux.live_component.twig.component_extension', LiveComponentTwigExtension::class) ->addTag('twig.extension') ; - $container->register(LiveComponentRuntime::class) + $container->register('ux.live_component.twig.component_runtime', LiveComponentRuntime::class) ->setArguments([ - new Reference(LiveComponentHydrator::class), + new Reference('ux.live_component.component_hydrator'), new Reference(UrlGeneratorInterface::class), new Reference(CsrfTokenManagerInterface::class, ContainerBuilder::NULL_ON_INVALID_REFERENCE), ]) @@ -73,7 +99,6 @@ public function load(array $configs, ContainerBuilder $container): void $container->register(ComponentValidator::class) ->addTag('container.service_subscriber', ['key' => 'validator', 'id' => 'validator']) - ->addTag('container.service_subscriber', ['key' => 'property_accessor', 'id' => 'property_accessor']) ; $container->setAlias(ComponentValidatorInterface::class, ComponentValidator::class); diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 25439131435..ad9ff01f7e2 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -11,7 +11,6 @@ namespace Symfony\UX\LiveComponent\EventListener; -use Doctrine\Common\Annotations\Reader; use Psr\Container\ContainerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\JsonResponse; @@ -29,10 +28,9 @@ use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; -use Symfony\UX\LiveComponent\Attribute\BeforeReRender; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\DefaultComponentController; use Symfony\UX\LiveComponent\LiveComponentHydrator; -use Symfony\UX\LiveComponent\LiveComponentInterface; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; @@ -47,11 +45,13 @@ class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscr private const JSON_FORMAT = 'live-component-json'; private const JSON_CONTENT_TYPE = 'application/vnd.live-component+json'; - /** @var ContainerInterface */ - private $container; + /** @var array */ + private array $componentServiceMap; + private ContainerInterface $container; - public function __construct(ContainerInterface $container) + public function __construct(array $componentServiceMap, ContainerInterface $container) { + $this->componentServiceMap = $componentServiceMap; $this->container = $container; } @@ -61,14 +61,14 @@ public static function getSubscribedServices(): array ComponentFactory::class, ComponentRenderer::class, LiveComponentHydrator::class, - Reader::class, '?'.CsrfTokenManagerInterface::class, ]; } - public function onKernelRequest(RequestEvent $event) + public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); + if (!$this->isLiveComponentRequest($request)) { return; } @@ -99,16 +99,14 @@ public function onKernelRequest(RequestEvent $event) throw new BadRequestHttpException('Invalid CSRF token.'); } - try { - $componentServiceId = $this->container->get(ComponentFactory::class)->serviceIdFor($componentName); - } catch (\InvalidArgumentException $e) { - throw new NotFoundHttpException('Component not found.'); + if (!\array_key_exists($componentName, $this->componentServiceMap)) { + throw new NotFoundHttpException(sprintf('Component "%s" not found.', $componentName)); } - $request->attributes->set('_controller', sprintf('%s::%s', $componentServiceId, $action)); + $request->attributes->set('_controller', sprintf('%s::%s', $this->componentServiceMap[$componentName], $action)); } - public function onKernelController(ControllerEvent $event) + public function onKernelController(ControllerEvent $event): void { $request = $event->getRequest(); @@ -134,12 +132,8 @@ public function onKernelController(ControllerEvent $event) $component = $component->getComponent(); } - if (!$component instanceof LiveComponentInterface) { - throw new NotFoundHttpException(sprintf('A request has been made for a component, but the component - "%s" does not implement LiveComponentInterface.', \get_class($component))); - } - - if (null !== $action && !$this->container->get(LiveComponentHydrator::class)->isActionAllowed($component, $action)) { - throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveProp attribute/annotation above it.', $action, \get_class($component))); + if (null !== $action && !AsLiveComponent::isActionAllowed($component, $action)) { + throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveAction attribute above it.', $action, \get_class($component))); } $this->container->get(LiveComponentHydrator::class)->hydrate($component, $data); @@ -151,28 +145,22 @@ public function onKernelController(ControllerEvent $event) $request->attributes->set('_component', $component); } - public function onKernelView(ViewEvent $event) + public function onKernelView(ViewEvent $event): void { $request = $event->getRequest(); if (!$this->isLiveComponentRequest($request)) { return; } - /** @var LiveComponentInterface $component */ - $component = $request->attributes->get('_component'); - - if (!$component instanceof LiveComponentInterface) { - throw new \InvalidArgumentException('Somehow we are missing the _component attribute'); - } - - $response = $this->createResponse($component, $request); + $response = $this->createResponse($request->attributes->get('_component'), $request); $event->setResponse($response); } - public function onKernelException(ExceptionEvent $event) + public function onKernelException(ExceptionEvent $event): void { $request = $event->getRequest(); + if (!$this->isLiveComponentRequest($request)) { return; } @@ -214,7 +202,7 @@ public function onKernelResponse(ResponseEvent $event): void ])); } - public static function getSubscribedEvents() + public static function getSubscribedEvents(): array { return [ RequestEvent::class => 'onKernelRequest', @@ -225,9 +213,9 @@ public static function getSubscribedEvents() ]; } - private function createResponse(LiveComponentInterface $component, Request $request): Response + private function createResponse(object $component, Request $request): Response { - foreach ($this->beforeReRenderMethods($component) as $method) { + foreach (AsLiveComponent::beforeReRenderMethods($component) as $method) { $component->{$method->name}(); } @@ -256,16 +244,4 @@ private function isLiveComponentJsonRequest(Request $request): bool { return \in_array($request->getPreferredFormat(), [self::JSON_FORMAT, 'json'], true); } - - /** - * @return \ReflectionMethod[] - */ - private function beforeReRenderMethods(LiveComponentInterface $component): iterable - { - foreach ((new \ReflectionClass($component))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - if ($this->container->get(Reader::class)->getMethodAnnotation($method, BeforeReRender::class)) { - yield $method; - } - } - } } diff --git a/src/LiveComponent/src/Hydrator/DoctrineEntityPropertyHydrator.php b/src/LiveComponent/src/Hydrator/DoctrineEntityPropertyHydrator.php index 1f0c7183a76..789c0b606ec 100644 --- a/src/LiveComponent/src/Hydrator/DoctrineEntityPropertyHydrator.php +++ b/src/LiveComponent/src/Hydrator/DoctrineEntityPropertyHydrator.php @@ -24,7 +24,7 @@ final class DoctrineEntityPropertyHydrator implements PropertyHydratorInterface { /** @var ManagerRegistry[] */ - private $managerRegistries; + private iterable $managerRegistries; /** * @param ManagerRegistry[] $managerRegistries diff --git a/src/LiveComponent/src/Hydrator/NormalizerBridgePropertyHydrator.php b/src/LiveComponent/src/Hydrator/NormalizerBridgePropertyHydrator.php index 36efc6b5694..0b471ad12e3 100644 --- a/src/LiveComponent/src/Hydrator/NormalizerBridgePropertyHydrator.php +++ b/src/LiveComponent/src/Hydrator/NormalizerBridgePropertyHydrator.php @@ -24,8 +24,11 @@ final class NormalizerBridgePropertyHydrator implements PropertyHydratorInterface { /** @var NormalizerInterface|DenormalizerInterface */ - private $normalizer; + private NormalizerInterface $normalizer; + /** + * @param NormalizerInterface|DenormalizerInterface $normalizer + */ public function __construct(NormalizerInterface $normalizer) { if (!$normalizer instanceof DenormalizerInterface) { diff --git a/src/LiveComponent/src/LiveComponentBundle.php b/src/LiveComponent/src/LiveComponentBundle.php index e1fd8c6878f..2efef33ed78 100644 --- a/src/LiveComponent/src/LiveComponentBundle.php +++ b/src/LiveComponent/src/LiveComponentBundle.php @@ -11,7 +11,9 @@ namespace Symfony\UX\LiveComponent; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; +use Symfony\UX\LiveComponent\DependencyInjection\Compiler\LiveComponentPass; /** * @author Kevin Bond @@ -20,4 +22,8 @@ */ final class LiveComponentBundle extends Bundle { + public function build(ContainerBuilder $container): void + { + $container->addCompilerPass(new LiveComponentPass()); + } } diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php index 9c084660a00..189cb4da32b 100644 --- a/src/LiveComponent/src/LiveComponentHydrator.php +++ b/src/LiveComponent/src/LiveComponentHydrator.php @@ -11,14 +11,11 @@ namespace Symfony\UX\LiveComponent; -use Doctrine\Common\Annotations\Reader; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\UX\LiveComponent\Attribute\LiveAction; -use Symfony\UX\LiveComponent\Attribute\LiveProp; -use Symfony\UX\LiveComponent\Attribute\PostHydrate; -use Symfony\UX\LiveComponent\Attribute\PreDehydrate; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LivePropContext; use Symfony\UX\LiveComponent\Exception\UnsupportedHydrationException; /** @@ -34,53 +31,35 @@ final class LiveComponentHydrator private const EXPOSED_PROP_KEY = 'id'; /** @var PropertyHydratorInterface[] */ - private $propertyHydrators; - - /** @var PropertyAccessorInterface */ - private $propertyAccessor; - - /** @var Reader */ - private $annotationReader; - - /** @var string */ - private $secret; + private iterable $propertyHydrators; + private PropertyAccessorInterface $propertyAccessor; + private string $secret; /** * @param PropertyHydratorInterface[] $propertyHydrators */ - public function __construct(iterable $propertyHydrators, PropertyAccessorInterface $propertyAccessor, Reader $annotationReader, string $secret) + public function __construct(iterable $propertyHydrators, PropertyAccessorInterface $propertyAccessor, string $secret) { $this->propertyHydrators = $propertyHydrators; $this->propertyAccessor = $propertyAccessor; - $this->annotationReader = $annotationReader; $this->secret = $secret; } - public function isActionAllowed(LiveComponentInterface $component, string $action): bool - { - foreach ((new \ReflectionClass($component))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - if ($action === $method->name && $this->annotationReader->getMethodAnnotation($method, LiveAction::class)) { - return true; - } - } - - return false; - } - - public function dehydrate(LiveComponentInterface $component): array + public function dehydrate(object $component): array { - foreach ($this->preDehydrateMethods($component) as $method) { + foreach (AsLiveComponent::preDehydrateMethods($component) as $method) { $component->{$method->name}(); } $data = []; $readonlyProperties = []; - $frontendPropertyNames = []; - foreach ($this->reflectionProperties($component) as $property) { - $liveProp = $this->livePropFor($property); + + foreach (AsLiveComponent::liveProps($component) as $context) { + $property = $context->reflectionProperty(); + $liveProp = $context->liveProp(); $name = $property->getName(); - $frontendName = $this->getFrontendFieldName($liveProp, $component, $property); + $frontendName = $liveProp->calculateFieldName($component, $property->getName()); if (isset($frontendPropertyNames[$frontendName])) { $message = sprintf('The field name "%s" cannot be used by multiple LiveProp properties in a component. Currently, both "%s" and "%s" are trying to use it in "%s".', $frontendName, $frontendPropertyNames[$frontendName], $name, \get_class($component)); @@ -126,20 +105,25 @@ public function dehydrate(LiveComponentInterface $component): array return $data; } - public function hydrate(LiveComponentInterface $component, array $data): void + public function hydrate(object $component, array $data): void { $readonlyProperties = []; + /** @var LivePropContext[] $propertyContexts */ + $propertyContexts = iterator_to_array(AsLiveComponent::liveProps($component)); + /* * Determine readonly properties for checksum verification. We need to do this * before setting properties on the component. It is unlikely but there could * be security implications to doing it after (component setter's could have * side effects). */ - foreach ($this->reflectionProperties($component) as $property) { - $liveProp = $this->livePropFor($property); + foreach ($propertyContexts as $context) { + $liveProp = $context->liveProp(); + $name = $context->reflectionProperty()->getName(); + if ($liveProp->isReadonly()) { - $readonlyProperties[] = $this->getFrontendFieldName($liveProp, $component, $property); + $readonlyProperties[] = $liveProp->calculateFieldName($component, $name); } } @@ -147,10 +131,11 @@ public function hydrate(LiveComponentInterface $component, array $data): void unset($data[self::CHECKSUM_KEY]); - foreach ($this->reflectionProperties($component) as $property) { - $liveProp = $this->livePropFor($property); + foreach ($propertyContexts as $context) { + $property = $context->reflectionProperty(); + $liveProp = $context->liveProp(); $name = $property->getName(); - $frontendName = $this->getFrontendFieldName($liveProp, $component, $property); + $frontendName = $liveProp->calculateFieldName($component, $name); if (!\array_key_exists($frontendName, $data)) { // this property was not sent @@ -199,7 +184,7 @@ public function hydrate(LiveComponentInterface $component, array $data): void $this->propertyAccessor->setValue($component, $name, $value); } - foreach ($this->postHydrateMethods($component) as $method) { + foreach (AsLiveComponent::postHydrateMethods($component) as $method) { $component->{$method->name}(); } } @@ -241,7 +226,6 @@ private function verifyChecksum(array $data, array $readonlyProperties): void */ private function hydrateProperty(\ReflectionProperty $property, $value) { - // TODO: make compatible with PHP 7.2 if (!$property->getType() || !$property->getType() instanceof \ReflectionNamedType || $property->getType()->isBuiltin()) { return $value; } @@ -262,7 +246,7 @@ private function hydrateProperty(\ReflectionProperty $property, $value) * * @return scalar|array|null */ - private function dehydrateProperty($value, string $name, LiveComponentInterface $component) + private function dehydrateProperty($value, string $name, object $component) { if (is_scalar($value) || \is_array($value) || null === $value) { // nothing to dehydrate... @@ -286,31 +270,6 @@ private function dehydrateProperty($value, string $name, LiveComponentInterface return $value; } - /** - * @param \ReflectionClass|object $object - * - * @return \ReflectionProperty[] - */ - private function reflectionProperties(object $object): iterable - { - $class = $object instanceof \ReflectionClass ? $object : new \ReflectionClass($object); - - foreach ($class->getProperties() as $property) { - if (null !== $this->livePropFor($property)) { - yield $property; - } - } - - if ($parent = $class->getParentClass()) { - yield from $this->reflectionProperties($parent); - } - } - - private function livePropFor(\ReflectionProperty $property): ?LiveProp - { - return $this->annotationReader->getPropertyAnnotation($property, LiveProp::class); - } - /** * Transforms a path like `post.name` into `[post][name]`. * @@ -320,41 +279,12 @@ private function livePropFor(\ReflectionProperty $property): ?LiveProp private function transformToArrayPath(string $propertyPath): string { $parts = explode('.', $propertyPath); - $path = ''; + foreach ($parts as $part) { $path .= "[{$part}]"; } return $path; } - - /** - * @return \ReflectionMethod[] - */ - private function preDehydrateMethods(LiveComponentInterface $component): iterable - { - foreach ((new \ReflectionClass($component))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - if ($this->annotationReader->getMethodAnnotation($method, PreDehydrate::class)) { - yield $method; - } - } - } - - /** - * @return \ReflectionMethod[] - */ - private function postHydrateMethods(LiveComponentInterface $component): iterable - { - foreach ((new \ReflectionClass($component))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - if ($this->annotationReader->getMethodAnnotation($method, PostHydrate::class)) { - yield $method; - } - } - } - - private function getFrontendFieldName(LiveProp $liveProp, LiveComponentInterface $component, \ReflectionProperty $property): string - { - return $liveProp->calculateFieldName($component, $property->getName()); - } } diff --git a/src/LiveComponent/src/LiveComponentInterface.php b/src/LiveComponent/src/LiveComponentInterface.php deleted file mode 100644 index f78e6b8ebce..00000000000 --- a/src/LiveComponent/src/LiveComponentInterface.php +++ /dev/null @@ -1,23 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\LiveComponent; - -use Symfony\UX\TwigComponent\ComponentInterface; - -/** - * @author Kevin Bond - * - * @experimental - */ -interface LiveComponentInterface extends ComponentInterface -{ -} diff --git a/src/LiveComponent/src/Twig/LiveComponentRuntime.php b/src/LiveComponent/src/Twig/LiveComponentRuntime.php index e0339d8d0af..50f68f11e09 100644 --- a/src/LiveComponent/src/Twig/LiveComponentRuntime.php +++ b/src/LiveComponent/src/Twig/LiveComponentRuntime.php @@ -13,9 +13,8 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\LiveComponentHydrator; -use Symfony\UX\LiveComponent\LiveComponentInterface; -use Symfony\UX\TwigComponent\ComponentInterface; use Twig\Environment; /** @@ -25,14 +24,9 @@ */ final class LiveComponentRuntime { - /** @var LiveComponentHydrator */ - private $hydrator; - - /** @var UrlGeneratorInterface */ - private $urlGenerator; - - /** @var CsrfTokenManagerInterface|null */ - private $csrfTokenManager; + private LiveComponentHydrator $hydrator; + private UrlGeneratorInterface $urlGenerator; + private ?CsrfTokenManagerInterface $csrfTokenManager; public function __construct(LiveComponentHydrator $hydrator, UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager = null) { @@ -41,13 +35,9 @@ public function __construct(LiveComponentHydrator $hydrator, UrlGeneratorInterfa $this->csrfTokenManager = $csrfTokenManager; } - public function renderLiveAttributes(Environment $env, ComponentInterface $component): string + public function renderLiveAttributes(Environment $env, object $component): string { - if (!$component instanceof LiveComponentInterface) { - throw new \InvalidArgumentException(sprintf('The "%s" component (%s) is not a LiveComponent. Don\'t forget to implement LiveComponentInterface', $component::getComponentName(), \get_class($component))); - } - - $url = $this->urlGenerator->generate('live_component', ['component' => $component::getComponentName()]); + $url = $this->urlGenerator->generate('live_component', ['component' => AsLiveComponent::forClass($component::class)->getName()]); $data = $this->hydrator->dehydrate($component); $ret = sprintf( @@ -62,14 +52,14 @@ public function renderLiveAttributes(Environment $env, ComponentInterface $compo return sprintf('%s data-live-csrf-value="%s"', $ret, - $this->csrfTokenManager->getToken($component::getComponentName())->getValue() + $this->csrfTokenManager->getToken(AsLiveComponent::forClass($component::class)->getName())->getValue() ); } - public function getComponentUrl(LiveComponentInterface $component): string + public function getComponentUrl(object $component): string { $data = $this->hydrator->dehydrate($component); - $params = ['component' => $component::getComponentName()] + $data; + $params = ['component' => AsLiveComponent::forClass($component::class)->getName()] + $data; return $this->urlGenerator->generate('live_component', $params); } diff --git a/src/LiveComponent/src/ValidatableComponentTrait.php b/src/LiveComponent/src/ValidatableComponentTrait.php index a8e6bc5ffba..bf1a4068e55 100644 --- a/src/LiveComponent/src/ValidatableComponentTrait.php +++ b/src/LiveComponent/src/ValidatableComponentTrait.php @@ -23,35 +23,26 @@ */ trait ValidatableComponentTrait { - /** @var ComponentValidatorInterface|null */ - private $componentValidator; - - /** @var array */ - private $validationErrors = []; + private ?ComponentValidatorInterface $componentValidator = null; + private array $validationErrors = []; /** * Tracks whether this entire component has been validated. * * This is used to know if validation should be automatically applied * when rendering. - * - * @LiveProp(writable=true) - * - * @var bool */ - public $isValidated = false; + #[LiveProp(writable: true)] + public bool $isValidated = false; /** * Tracks which specific fields have been validated. * * Instead of validating the entire object (isValidated), * the component can be validated, field-by-field. - * - * @LiveProp(writable=true) - * - * @var array */ - public $validatedFields = []; + #[LiveProp(writable: true)] + public array $validatedFields = []; /** * Validate the entire component. @@ -123,9 +114,7 @@ public function clearValidation(): void $this->validationErrors = []; } - /** - * @PostHydrate() - */ + #[PostHydrate] public function validateAfterHydration() { if ($this->isValidated) { diff --git a/src/LiveComponent/tests/Fixture/Component/Component1.php b/src/LiveComponent/tests/Fixture/Component/Component1.php index 0b7eaac84eb..dba319bc25a 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component1.php +++ b/src/LiveComponent/tests/Fixture/Component/Component1.php @@ -11,41 +11,29 @@ namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveProp; -use Symfony\UX\LiveComponent\LiveComponentInterface; use Symfony\UX\LiveComponent\Tests\Fixture\Entity\Entity1; /** * @author Kevin Bond */ -final class Component1 implements LiveComponentInterface +#[AsLiveComponent('component1')] +final class Component1 { - /** - * @LiveProp - */ + #[LiveProp] public ?Entity1 $prop1; - /** - * @LiveProp - */ + #[LiveProp] public \DateTimeInterface $prop2; - /** - * @LiveProp(writable=true) - */ + #[LiveProp(writable: true)] public $prop3; public $prop4; - public static function getComponentName(): string - { - return 'component1'; - } - - /** - * @LiveAction - */ + #[LiveAction] public function method1() { } diff --git a/src/LiveComponent/tests/Fixture/Component/Component2.php b/src/LiveComponent/tests/Fixture/Component/Component2.php index eb4134c7ffa..5755aa02b60 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component2.php +++ b/src/LiveComponent/tests/Fixture/Component/Component2.php @@ -12,21 +12,21 @@ namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\BeforeReRender; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Attribute\PostHydrate; use Symfony\UX\LiveComponent\Attribute\PreDehydrate; -use Symfony\UX\LiveComponent\LiveComponentInterface; /** * @author Kevin Bond */ -final class Component2 implements LiveComponentInterface +#[AsLiveComponent('component2')] +final class Component2 { - /** - * @LiveProp - */ + #[LiveProp] public int $count = 1; public bool $preDehydrateCalled = false; @@ -35,46 +35,31 @@ final class Component2 implements LiveComponentInterface public bool $beforeReRenderCalled = false; - /** - * @LiveAction - */ + #[LiveAction] public function increase(): void { ++$this->count; } - /** - * @LiveAction - */ - public function redirect(): RedirectResponse + #[LiveAction] + public function redirect(UrlGeneratorInterface $urlGenerator): RedirectResponse { - return new RedirectResponse('/'); + return new RedirectResponse($urlGenerator->generate('homepage')); } - public static function getComponentName(): string - { - return 'component2'; - } - - /** - * @PreDehydrate() - */ + #[PreDehydrate] public function preDehydrateMethod(): void { $this->preDehydrateCalled = true; } - /** - * @PostHydrate() - */ + #[PostHydrate] public function postHydrateMethod(): void { $this->postHydrateCalled = true; } - /** - * @BeforeReRender() - */ + #[BeforeReRender] public function beforeReRenderMethod(): void { $this->beforeReRenderCalled = true; diff --git a/src/LiveComponent/tests/Fixture/Component/Component3.php b/src/LiveComponent/tests/Fixture/Component/Component3.php index e818fa144c4..cbd44fe97f4 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component3.php +++ b/src/LiveComponent/tests/Fixture/Component/Component3.php @@ -11,29 +11,21 @@ namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveProp; -use Symfony\UX\LiveComponent\LiveComponentInterface; /** * @author Kevin Bond */ -final class Component3 implements LiveComponentInterface +#[AsLiveComponent('component3')] +final class Component3 { - /** - * @LiveProp(fieldName="myProp1") - */ + #[LiveProp(fieldName: 'myProp1')] public $prop1; - /** - * @LiveProp(fieldName="getProp2Name()") - */ + #[LiveProp(fieldName: 'getProp2Name()')] public $prop2; - public static function getComponentName(): string - { - return 'component_3'; - } - public function getProp2Name(): string { return 'myProp2'; diff --git a/src/LiveComponent/tests/Fixture/Component/Component4.php b/src/LiveComponent/tests/Fixture/Component/Component4.php new file mode 100644 index 00000000000..0461b69c9b6 --- /dev/null +++ b/src/LiveComponent/tests/Fixture/Component/Component4.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; + +use Symfony\UX\LiveComponent\Attribute\BeforeReRender; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\Attribute\PostHydrate; +use Symfony\UX\LiveComponent\Attribute\PreDehydrate; + +/** + * @author Kevin Bond + */ +class Component4 +{ + #[LiveProp] + public $prop1; + + public $prop2; + + #[LiveProp] + private $prop3; + + #[LiveAction] + public function method1() + { + } + + public function method2() + { + } + + #[BeforeReRender] + public function method3() + { + } + + #[PreDehydrate] + public function method4() + { + } + + #[PostHydrate] + public function method5() + { + } +} diff --git a/src/TwigComponent/src/ComponentInterface.php b/src/LiveComponent/tests/Fixture/Component/Component5.php similarity index 67% rename from src/TwigComponent/src/ComponentInterface.php rename to src/LiveComponent/tests/Fixture/Component/Component5.php index bc808bcbede..b1e47a7f565 100644 --- a/src/TwigComponent/src/ComponentInterface.php +++ b/src/LiveComponent/tests/Fixture/Component/Component5.php @@ -9,14 +9,11 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\TwigComponent; +namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; /** * @author Kevin Bond - * - * @experimental */ -interface ComponentInterface +final class Component5 extends Component4 { - public static function getComponentName(): string; } diff --git a/src/LiveComponent/tests/Fixture/Kernel.php b/src/LiveComponent/tests/Fixture/Kernel.php index 365a1e6cf10..e9bb3cc2ca6 100644 --- a/src/LiveComponent/tests/Fixture/Kernel.php +++ b/src/LiveComponent/tests/Fixture/Kernel.php @@ -20,6 +20,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Kernel as BaseKernel; +use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; use Symfony\Component\Routing\RouteCollectionBuilder; use Symfony\UX\LiveComponent\LiveComponentBundle; use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1; @@ -61,9 +62,16 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load // disable logging errors to the console $c->register('logger', NullLogger::class); - $c->register(Component1::class)->setAutoconfigured(true)->setAutowired(true); - $c->register(Component2::class)->setAutoconfigured(true)->setAutowired(true); - $c->register(Component3::class)->setAutoconfigured(true)->setAutowired(true); + $componentA = $c->register(Component1::class)->setAutoconfigured(true)->setAutowired(true); + $componentB = $c->register(Component2::class)->setAutoconfigured(true)->setAutowired(true); + $componentC = $c->register(Component3::class)->setAutoconfigured(true)->setAutowired(true); + + if (self::VERSION_ID < 50300) { + // add tag manually + $componentA->addTag('twig.component')->addTag('controller.service_arguments'); + $componentB->addTag('twig.component')->addTag('controller.service_arguments'); + $componentC->addTag('twig.component')->addTag('controller.service_arguments'); + } $sessionConfig = self::VERSION_ID < 50300 ? ['storage_id' => 'session.storage.mock_file'] : ['storage_factory_id' => 'session.storage.factory.mock_file']; @@ -97,11 +105,21 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load ]); } - protected function configureRoutes(RouteCollectionBuilder $routes): void + /** + * @param RoutingConfigurator|RouteCollectionBuilder $routes + */ + protected function configureRoutes($routes): void { $routes->import('@LiveComponentBundle/Resources/config/routing/live_component.xml'); - $routes->add('/render-template/{template}', 'kernel::renderTemplate'); - $routes->add('/', 'kernel::index'); + if ($routes instanceof RoutingConfigurator) { + $routes->add('template', '/render-template/{template}')->controller('kernel::renderTemplate'); + $routes->add('homepage', '/')->controller('kernel::index'); + + return; + } + + $routes->add('/render-template/{template}', 'kernel::renderTemplate', 'template'); + $routes->add('/', 'kernel::index', 'homepage'); } } diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index 617af0609ad..1c652df12ad 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -12,6 +12,7 @@ namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\UX\LiveComponent\LiveComponentHydrator; use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1; use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component2; @@ -37,13 +38,13 @@ public function testCanRenderComponentAsHtmlOrJson(): void self::bootKernel(); /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); + $hydrator = self::$container->get('ux.live_component.component_hydrator'); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var Component1 $component */ - $component = $factory->create(Component1::getComponentName(), [ + $component = $factory->create('component1', [ 'prop1' => $entity = create(Entity1::class)->object(), 'prop2' => $date = new \DateTime('2021-03-05 9:23'), 'prop3' => 'value3', @@ -82,13 +83,13 @@ public function testCanExecuteComponentAction(): void self::bootKernel(); /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); + $hydrator = self::$container->get('ux.live_component.component_hydrator'); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var Component2 $component */ - $component = $factory->create(Component2::getComponentName()); + $component = $factory->create('component2'); $dehydrated = $hydrator->dehydrate($component); $token = null; @@ -140,6 +141,19 @@ public function testMissingCsrfTokenForComponentActionFails(): void ->post('/_components/component2/increase') ->assertStatus(400) ; + + try { + $this->browser() + ->throwExceptions() + ->post('/_components/component2/increase') + ; + } catch (BadRequestHttpException $e) { + $this->assertSame('Invalid CSRF token.', $e->getMessage()); + + return; + } + + $this->fail('Expected exception not thrown.'); } public function testInvalidCsrfTokenForComponentActionFails(): void @@ -150,6 +164,21 @@ public function testInvalidCsrfTokenForComponentActionFails(): void ]) ->assertStatus(400) ; + + try { + $this->browser() + ->throwExceptions() + ->post('/_components/component2/increase', [ + 'headers' => ['X-CSRF-TOKEN' => 'invalid'], + ]) + ; + } catch (BadRequestHttpException $e) { + $this->assertSame('Invalid CSRF token.', $e->getMessage()); + + return; + } + + $this->fail('Expected exception not thrown.'); } public function testBeforeReRenderHookOnlyExecutedDuringAjax(): void @@ -157,13 +186,13 @@ public function testBeforeReRenderHookOnlyExecutedDuringAjax(): void self::bootKernel(); /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); + $hydrator = self::$container->get('ux.live_component.component_hydrator'); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var Component2 $component */ - $component = $factory->create(Component2::getComponentName()); + $component = $factory->create('component2'); $dehydrated = $hydrator->dehydrate($component); @@ -182,13 +211,13 @@ public function testCanRedirectFromComponentAction(): void self::bootKernel(); /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); + $hydrator = self::$container->get('ux.live_component.component_hydrator'); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var Component2 $component */ - $component = $factory->create(Component2::getComponentName()); + $component = $factory->create('component2'); $dehydrated = $hydrator->dehydrate($component); $token = null; diff --git a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php index 5ca337d8e23..c6215b3606a 100644 --- a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php +++ b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php @@ -35,13 +35,13 @@ public function testCanDehydrateAndHydrateLiveComponent(): void self::bootKernel(); /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); + $hydrator = self::$container->get('ux.live_component.component_hydrator'); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var Component1 $component */ - $component = $factory->create(Component1::getComponentName(), [ + $component = $factory->create('component1', [ 'prop1' => $prop1 = create(Entity1::class)->object(), 'prop2' => $prop2 = new \DateTime('2021-03-05 9:23'), 'prop3' => $prop3 = 'value3', @@ -61,7 +61,7 @@ public function testCanDehydrateAndHydrateLiveComponent(): void $this->assertArrayHasKey('_checksum', $dehydrated); $this->assertArrayNotHasKey('prop4', $dehydrated); - $component = $factory->get(Component1::getComponentName()); + $component = $factory->get('component1'); $hydrator->hydrate($component, $dehydrated); @@ -76,13 +76,13 @@ public function testCanModifyWritableProps(): void self::bootKernel(); /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); + $hydrator = self::$container->get('ux.live_component.component_hydrator'); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var Component1 $component */ - $component = $factory->create(Component1::getComponentName(), [ + $component = $factory->create('component1', [ 'prop1' => create(Entity1::class)->object(), 'prop2' => new \DateTime('2021-03-05 9:23'), 'prop3' => 'value3', @@ -91,7 +91,7 @@ public function testCanModifyWritableProps(): void $dehydrated = $hydrator->dehydrate($component); $dehydrated['prop3'] = 'new value'; - $component = $factory->get(Component1::getComponentName()); + $component = $factory->get('component1'); $hydrator->hydrate($component, $dehydrated); @@ -103,13 +103,13 @@ public function testCannotModifyReadonlyProps(): void self::bootKernel(); /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); + $hydrator = self::$container->get('ux.live_component.component_hydrator'); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var Component1 $component */ - $component = $factory->create(Component1::getComponentName(), [ + $component = $factory->create('component1', [ 'prop1' => create(Entity1::class)->object(), 'prop2' => new \DateTime('2021-03-05 9:23'), 'prop3' => 'value3', @@ -118,7 +118,7 @@ public function testCannotModifyReadonlyProps(): void $dehydrated = $hydrator->dehydrate($component); $dehydrated['prop2'] = (new \DateTime())->format('c'); - $component = $factory->get(Component1::getComponentName()); + $component = $factory->get('component1'); $this->expectException(\RuntimeException::class); $hydrator->hydrate($component, $dehydrated); @@ -129,13 +129,13 @@ public function testHydrationFailsIfChecksumMissing(): void self::bootKernel(); /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); + $hydrator = self::$container->get('ux.live_component.component_hydrator'); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); $this->expectException(\RuntimeException::class); - $hydrator->hydrate($factory->get(Component1::getComponentName()), []); + $hydrator->hydrate($factory->get('component1'), []); } public function testHydrationFailsOnChecksumMismatch(): void @@ -143,29 +143,13 @@ public function testHydrationFailsOnChecksumMismatch(): void self::bootKernel(); /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); + $hydrator = self::$container->get('ux.live_component.component_hydrator'); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); $this->expectException(\RuntimeException::class); - $hydrator->hydrate($factory->get(Component1::getComponentName()), ['_checksum' => 'invalid']); - } - - public function testCanCheckIfActionIsAllowed(): void - { - self::bootKernel(); - - /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); - - /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); - - $component = $factory->get(Component1::getComponentName()); - - $this->assertTrue($hydrator->isActionAllowed($component, 'method1')); - $this->assertFalse($hydrator->isActionAllowed($component, 'method2')); + $hydrator->hydrate($factory->get('component1'), ['_checksum' => 'invalid']); } public function testPreDehydrateAndPostHydrateHooksCalled(): void @@ -173,13 +157,13 @@ public function testPreDehydrateAndPostHydrateHooksCalled(): void self::bootKernel(); /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); + $hydrator = self::$container->get('ux.live_component.component_hydrator'); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var Component2 $component */ - $component = $factory->create(Component2::getComponentName()); + $component = $factory->create('component2'); $this->assertFalse($component->preDehydrateCalled); $this->assertFalse($component->postHydrateCalled); @@ -190,7 +174,7 @@ public function testPreDehydrateAndPostHydrateHooksCalled(): void $this->assertFalse($component->postHydrateCalled); /** @var Component2 $component */ - $component = $factory->get(Component2::getComponentName()); + $component = $factory->get('component2'); $this->assertFalse($component->preDehydrateCalled); $this->assertFalse($component->postHydrateCalled); @@ -206,15 +190,15 @@ public function testDeletingEntityBetweenDehydrationAndHydrationSetsItToNull(): self::bootKernel(); /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); + $hydrator = self::$container->get('ux.live_component.component_hydrator'); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); $entity = create(Entity1::class); /** @var Component1 $component */ - $component = $factory->create(Component1::getComponentName(), [ + $component = $factory->create('component1', [ 'prop1' => $entity->object(), 'prop2' => new \DateTime('2021-03-05 9:23'), ]); @@ -228,7 +212,7 @@ public function testDeletingEntityBetweenDehydrationAndHydrationSetsItToNull(): $entity->remove(); /** @var Component1 $component */ - $component = $factory->get(Component1::getComponentName()); + $component = $factory->get('component1'); $hydrator->hydrate($component, $data); @@ -244,13 +228,13 @@ public function testCorrectlyUsesCustomFrontendNameInDehydrateAndHydrate(): void self::bootKernel(); /** @var LiveComponentHydrator $hydrator */ - $hydrator = self::$container->get(LiveComponentHydrator::class); + $hydrator = self::$container->get('ux.live_component.component_hydrator'); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var Component3 $component */ - $component = $factory->create('component_3', ['prop1' => 'value1', 'prop2' => 'value2']); + $component = $factory->create('component3', ['prop1' => 'value1', 'prop2' => 'value2']); $dehydrated = $hydrator->dehydrate($component); @@ -262,7 +246,7 @@ public function testCorrectlyUsesCustomFrontendNameInDehydrateAndHydrate(): void $this->assertSame('value2', $dehydrated['myProp2']); /** @var Component3 $component */ - $component = $factory->get('component_3'); + $component = $factory->get('component3'); $hydrator->hydrate($component, $dehydrated); diff --git a/src/LiveComponent/tests/Unit/Attribute/AsLiveComponentTest.php b/src/LiveComponent/tests/Unit/Attribute/AsLiveComponentTest.php new file mode 100644 index 00000000000..cbcc79b75cc --- /dev/null +++ b/src/LiveComponent/tests/Unit/Attribute/AsLiveComponentTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Unit\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component5; + +/** + * @author Kevin Bond + */ +final class AsLiveComponentTest extends TestCase +{ + public function testCanGetLiveProps(): void + { + $props = iterator_to_array(AsLiveComponent::liveProps(new Component5())); + + $this->assertCount(2, $props); + $this->assertSame('prop1', $props[0]->reflectionProperty()->getName()); + $this->assertSame('prop3', $props[1]->reflectionProperty()->getName()); + } + + public function testCanGetPreDehydrateMethods(): void + { + $methods = iterator_to_array(AsLiveComponent::preDehydrateMethods(new Component5())); + + $this->assertCount(1, $methods); + $this->assertSame('method4', $methods[0]->getName()); + } + + public function testCanGetPostHydrateMethods(): void + { + $methods = iterator_to_array(AsLiveComponent::postHydrateMethods(new Component5())); + + $this->assertCount(1, $methods); + $this->assertSame('method5', $methods[0]->getName()); + } + + public function testCanGetBeforeReRenderMethods(): void + { + $methods = iterator_to_array(AsLiveComponent::beforeReRenderMethods(new Component5())); + + $this->assertCount(1, $methods); + $this->assertSame('method3', $methods[0]->getName()); + } + + public function testCanCheckIfMethodIsAllowed(): void + { + $component = new Component5(); + + $this->assertTrue(AsLiveComponent::isActionAllowed($component, 'method1')); + $this->assertFalse(AsLiveComponent::isActionAllowed($component, 'method2')); + } +} diff --git a/src/LiveComponent/tests/Unit/Attribute/LivePropTest.php b/src/LiveComponent/tests/Unit/Attribute/LivePropTest.php index 1dc7f871fa6..b745964c548 100644 --- a/src/LiveComponent/tests/Unit/Attribute/LivePropTest.php +++ b/src/LiveComponent/tests/Unit/Attribute/LivePropTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\TestCase; use Symfony\UX\LiveComponent\Attribute\LiveProp; -use Symfony\UX\LiveComponent\LiveComponentInterface; /** * @author Kevin Bond @@ -22,54 +21,39 @@ final class LivePropTest extends TestCase { public function testHydrateWithMethod(): void { - $this->assertSame('someMethod', (new LiveProp(['hydrateWith' => 'someMethod']))->hydrateMethod()); - $this->assertSame('someMethod', (new LiveProp(['hydrateWith' => 'someMethod()']))->hydrateMethod()); + $this->assertSame('someMethod', (new LiveProp(false, [], 'someMethod'))->hydrateMethod()); + $this->assertSame('someMethod', (new LiveProp(false, [], 'someMethod()'))->hydrateMethod()); } public function testDehydrateWithMethod(): void { - $this->assertSame('someMethod', (new LiveProp(['dehydrateWith' => 'someMethod']))->dehydrateMethod()); - $this->assertSame('someMethod', (new LiveProp(['dehydrateWith' => 'someMethod()']))->dehydrateMethod()); + $this->assertSame('someMethod', (new LiveProp(false, [], null, 'someMethod'))->dehydrateMethod()); + $this->assertSame('someMethod', (new LiveProp(false, [], null, 'someMethod()'))->dehydrateMethod()); } public function testCanCallCalculateFieldNameAsString(): void { - $component = new class() implements LiveComponentInterface { - public static function getComponentName(): string - { - return 'name'; - } - }; + $component = new class() {}; - $this->assertSame('field', (new LiveProp(['fieldName' => 'field']))->calculateFieldName($component, 'fallback')); + $this->assertSame('field', (new LiveProp(false, [], null, null, 'field'))->calculateFieldName($component, 'fallback')); } public function testCanCallCalculateFieldNameAsMethod(): void { - $component = new class() implements LiveComponentInterface { - public static function getComponentName(): string - { - return 'name'; - } - + $component = new class() { public function fieldName(): string { return 'foo'; } }; - $this->assertSame('foo', (new LiveProp(['fieldName' => 'fieldName()']))->calculateFieldName($component, 'fallback')); + $this->assertSame('foo', (new LiveProp(false, [], null, null, 'fieldName()'))->calculateFieldName($component, 'fallback')); } public function testCanCallCalculateFieldNameWhenNotSet(): void { - $component = new class() implements LiveComponentInterface { - public static function getComponentName(): string - { - return 'name'; - } - }; + $component = new class() {}; - $this->assertSame('fallback', (new LiveProp([]))->calculateFieldName($component, 'fallback')); + $this->assertSame('fallback', (new LiveProp())->calculateFieldName($component, 'fallback')); } } diff --git a/src/TwigComponent/README.md b/src/TwigComponent/README.md index dba56817fa5..c86f571117c 100644 --- a/src/TwigComponent/README.md +++ b/src/TwigComponent/README.md @@ -13,17 +13,13 @@ Every component consists of (1) a class: // src/Components/AlertComponent.php namespace App\Components; -use Symfony\UX\TwigComponent\ComponentInterface; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; -class AlertComponent implements ComponentInterface +#[AsTwigComponent('alert')] +class AlertComponent { public string $type = 'success'; public string $message; - - public static function getComponentName(): string - { - return 'alert'; - } } ``` @@ -64,28 +60,29 @@ That's it! We're ready to go! Let's create a reusable "alert" element that we can use to show success or error messages across our site. Step 1 is always to create -a component that implements `ComponentInterface`. Let's start as simple -as possible: +a component that has an `AsTwigComponent` class attribute. Let's start as +simple as possible: ```php // src/Components/AlertComponent.php namespace App\Components; -use Symfony\UX\TwigComponent\ComponentInterface; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; -class AlertComponent implements ComponentInterface +#[AsTwigComponent('alert')] +class AlertComponent { - public static function getComponentName(): string - { - return 'alert'; - } } ``` -Step 2 is to create a template for this component. Templates live -in `templates/components/{Component Name}.html.twig`, where -`{Component Name}` is whatever you return from the `getComponentName()` -method: +**Note:** If this class is auto-configured, _and_ you're using Symfony 5.3+, +then you're all set. Otherwise, register the service and tag it with +`twig.component`. + +Step 2 is to create a template for this component. By default, +templates live in `templates/components/{Component Name}.html.twig`, +where `{Component Name}` is whatever you passed as the first argument +to the `AsTwigComponent` class attribute: ```twig {# templates/components/alert.html.twig #} @@ -116,7 +113,8 @@ that, create a public property for each: // src/Components/AlertComponent.php // ... -class AlertComponent implements ComponentInterface +#[AsTwigComponent('alert')] +class AlertComponent { + public string $message; @@ -153,6 +151,23 @@ property of the object. Then, the component is rendered! If a property has a setter method (e.g. `setMessage()`), that will be called instead of setting the property directly. +### Customize the Twig Template + +You can customize the template used to render the template by +passing it as the second argument to the `AsTwigComponent` attribute: + +```diff +// src/Components/AlertComponent.php +// ... + +-#[AsTwigComponent('alert')] ++#[AsTwigComponent('alert', 'my/custom/template.html.twig')] +class AlertComponent +{ + // ... +} +``` + ### The mount() Method If, for some reason, you don't want an option to the `component()` @@ -163,7 +178,8 @@ a `mount()` method in your component: // src/Components/AlertComponent.php // ... -class AlertComponent implements ComponentInterface +#[AsTwigComponent('alert')] +class AlertComponent { public string $message; public string $type = 'success'; @@ -209,9 +225,10 @@ Doctrine entity and `ProductRepository`: namespace App\Components; use App\Repository\ProductRepository; -use Symfony\UX\TwigComponent\ComponentInterface; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; -class FeaturedProductsComponent implements ComponentInterface +#[AsTwigComponent('featured_products')] +class FeaturedProductsComponent { private ProductRepository $productRepository; @@ -225,11 +242,6 @@ class FeaturedProductsComponent implements ComponentInterface // an example method that returns an array of Products return $this->productRepository->findFeatured(); } - - public static function getComponentName() : string - { - return 'featured_products'; - } } ``` @@ -289,7 +301,8 @@ method), you can store its result on a private property: namespace App\Components; // ... -class FeaturedProductsComponent implements ComponentInterface +#[AsTwigComponent('featured_products')] +class FeaturedProductsComponent { private ProductRepository $productRepository; diff --git a/src/TwigComponent/composer.json b/src/TwigComponent/composer.json index 48fa0a532f8..c58fef95030 100644 --- a/src/TwigComponent/composer.json +++ b/src/TwigComponent/composer.json @@ -26,7 +26,7 @@ } }, "require": { - "php": ">=7.2.5", + "php": ">=8.0", "twig/twig": "^2.0|^3.0", "symfony/property-access": "^4.4|^5.0", "symfony/dependency-injection": "^4.4|^5.0" diff --git a/src/TwigComponent/src/Attribute/AsTwigComponent.php b/src/TwigComponent/src/Attribute/AsTwigComponent.php new file mode 100644 index 00000000000..3e0019984e5 --- /dev/null +++ b/src/TwigComponent/src/Attribute/AsTwigComponent.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Attribute; + +/** + * @author Kevin Bond + * + * @experimental + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +class AsTwigComponent +{ + private string $name; + private ?string $template; + + public function __construct(string $name, ?string $template = null) + { + $this->name = $name; + $this->template = $template; + } + + final public function getName(): string + { + return $this->name; + } + + final public function getTemplate(): string + { + return $this->template ?? "components/{$this->name}.html.twig"; + } + + /** + * @internal + * + * @return static + */ + final public static function forClass(string $class): self + { + $class = new \ReflectionClass($class); + + if (!$attribute = $class->getAttributes(static::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null) { + throw new \InvalidArgumentException(sprintf('"%s" is not a Twig Component, did you forget to add the "%s" attribute?', $class, static::class)); + } + + return $attribute->newInstance(); + } +} diff --git a/src/TwigComponent/src/ComponentFactory.php b/src/TwigComponent/src/ComponentFactory.php index 931db633b9c..cafaf4ac272 100644 --- a/src/TwigComponent/src/ComponentFactory.php +++ b/src/TwigComponent/src/ComponentFactory.php @@ -21,24 +21,19 @@ */ final class ComponentFactory { - private $components; - private $propertyAccessor; - private $serviceIdMap; + private ServiceLocator $components; + private PropertyAccessorInterface $propertyAccessor; - /** - * @param ServiceLocator|ComponentInterface[] $components - */ - public function __construct(ServiceLocator $components, PropertyAccessorInterface $propertyAccessor, array $serviceIdMap) + public function __construct(ServiceLocator $components, PropertyAccessorInterface $propertyAccessor) { $this->components = $components; $this->propertyAccessor = $propertyAccessor; - $this->serviceIdMap = $serviceIdMap; } /** * Creates the component and "mounts" it with the passed data. */ - public function create(string $name, array $data = []): ComponentInterface + public function create(string $name, array $data = []): object { $component = $this->getComponent($name); @@ -59,21 +54,12 @@ public function create(string $name, array $data = []): ComponentInterface /** * Returns the "unmounted" component. */ - public function get(string $name): ComponentInterface + public function get(string $name): object { return $this->getComponent($name); } - public function serviceIdFor(string $name): string - { - if (!isset($this->serviceIdMap[$name])) { - throw new \InvalidArgumentException('Component not found.'); - } - - return $this->serviceIdMap[$name]; - } - - private function mount(ComponentInterface $component, array &$data): void + private function mount(object $component, array &$data): void { try { $method = (new \ReflectionClass($component))->getMethod('mount'); @@ -102,10 +88,10 @@ private function mount(ComponentInterface $component, array &$data): void $component->mount(...$parameters); } - private function getComponent(string $name): ComponentInterface + private function getComponent(string $name): object { if (!$this->components->has($name)) { - throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s', $name, implode(', ', array_keys($this->serviceIdMap)))); + throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s', $name, implode(', ', array_keys($this->components->getProvidedServices())))); } return $this->components->get($name); diff --git a/src/TwigComponent/src/ComponentRenderer.php b/src/TwigComponent/src/ComponentRenderer.php index 11a578dfb2e..538f3a48ff6 100644 --- a/src/TwigComponent/src/ComponentRenderer.php +++ b/src/TwigComponent/src/ComponentRenderer.php @@ -11,6 +11,7 @@ namespace Symfony\UX\TwigComponent; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Twig\Environment; /** @@ -20,19 +21,18 @@ */ final class ComponentRenderer { - private $twig; + private Environment $twig; public function __construct(Environment $twig) { $this->twig = $twig; } - public function render(ComponentInterface $component): string + public function render(object $component): string { - // TODO: Template attribute/annotation/interface to customize // TODO: Self-Rendering components? - $templateName = sprintf('components/%s.html.twig', $component::getComponentName()); + $attribute = AsTwigComponent::forClass($component::class); - return $this->twig->render($templateName, ['this' => $component]); + return $this->twig->render($attribute->getTemplate(), ['this' => $component]); } } diff --git a/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php b/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php index 195bc075d16..729c688ec74 100644 --- a/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php +++ b/src/TwigComponent/src/DependencyInjection/Compiler/TwigComponentPass.php @@ -11,10 +11,12 @@ namespace Symfony\UX\TwigComponent\DependencyInjection\Compiler; +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\LogicException; -use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; /** * @author Kevin Bond @@ -25,25 +27,25 @@ final class TwigComponentPass implements CompilerPassInterface { public function process(ContainerBuilder $container): void { - $serviceIdMap = []; + $componentMap = []; - foreach (array_keys($container->findTaggedServiceIds('twig.component')) as $serviceId) { - $definition = $container->getDefinition($serviceId); + foreach (array_keys($container->findTaggedServiceIds('twig.component')) as $id) { + $componentDefinition = $container->findDefinition($id); - // make all component services non-shared - $definition->setShared(false); - - $name = $definition->getClass()::getComponentName(); - - // ensure component not already defined - if (\array_key_exists($name, $serviceIdMap)) { - throw new LogicException(sprintf('Component "%s" is already registered as "%s", components cannot be registered more than once.', $definition->getClass(), $serviceIdMap[$name])); + try { + $attribute = AsTwigComponent::forClass($componentDefinition->getClass()); + } catch (\InvalidArgumentException $e) { + throw new LogicException(sprintf('Service "%s" is tagged as a "twig.component" but does not have a "%s" class attribute.', $id, AsTwigComponent::class), 0, $e); } - // add to service id map for ComponentFactory - $serviceIdMap[$name] = $serviceId; + $componentMap[$attribute->getName()] = new Reference($id); + + // component services must not be shared + $componentDefinition->setShared(false); } - $container->getDefinition(ComponentFactory::class)->setArgument(2, $serviceIdMap); + $container->findDefinition('ux.twig_component.component_factory') + ->setArgument(0, new ServiceLocatorArgument($componentMap)) + ; } } diff --git a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php index b1f9464c0f0..acfab563b7d 100644 --- a/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php +++ b/src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php @@ -11,14 +11,16 @@ namespace Symfony\UX\TwigComponent\DependencyInjection; +use Symfony\Component\DependencyInjection\Argument\AbstractArgument; use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; -use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Reference; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\ComponentFactory; -use Symfony\UX\TwigComponent\ComponentInterface; use Symfony\UX\TwigComponent\ComponentRenderer; +use Symfony\UX\TwigComponent\DependencyInjection\Compiler\TwigComponentPass; use Symfony\UX\TwigComponent\Twig\ComponentExtension; use Symfony\UX\TwigComponent\Twig\ComponentRuntime; @@ -31,31 +33,36 @@ final class TwigComponentExtension extends Extension { public function load(array $configs, ContainerBuilder $container): void { - $container->registerForAutoconfiguration(ComponentInterface::class) - ->addTag('twig.component') - ; + if (method_exists($container, 'registerAttributeForAutoconfiguration')) { + $container->registerAttributeForAutoconfiguration( + AsTwigComponent::class, + static function (ChildDefinition $definition) { + $definition->addTag('twig.component'); + } + ); + } - $container->register(ComponentFactory::class) + $container->register('ux.twig_component.component_factory', ComponentFactory::class) ->setArguments([ - new ServiceLocatorArgument(new TaggedIteratorArgument('twig.component', null, 'getComponentName')), + class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %s.', TwigComponentPass::class)) : new ServiceLocatorArgument(), new Reference('property_accessor'), ]) ; - $container->register(ComponentRenderer::class) + $container->register('ux.twig_component.component_renderer', ComponentRenderer::class) ->setArguments([ new Reference('twig'), ]) ; - $container->register(ComponentExtension::class) + $container->register('ux.twig_component.twig.component_extension', ComponentExtension::class) ->addTag('twig.extension') ; - $container->register(ComponentRuntime::class) + $container->register('ux.twig_component.twig.component_runtime', ComponentRuntime::class) ->setArguments([ - new Reference(ComponentFactory::class), - new Reference(ComponentRenderer::class), + new Reference('ux.twig_component.component_factory'), + new Reference('ux.twig_component.component_renderer'), ]) ->addTag('twig.runtime') ; diff --git a/src/TwigComponent/src/Twig/ComponentRuntime.php b/src/TwigComponent/src/Twig/ComponentRuntime.php index ec8a534ee5f..1be5dce2979 100644 --- a/src/TwigComponent/src/Twig/ComponentRuntime.php +++ b/src/TwigComponent/src/Twig/ComponentRuntime.php @@ -21,8 +21,8 @@ */ final class ComponentRuntime { - private $componentFactory; - private $componentRenderer; + private ComponentFactory $componentFactory; + private ComponentRenderer $componentRenderer; public function __construct(ComponentFactory $componentFactory, ComponentRenderer $componentRenderer) { diff --git a/src/TwigComponent/tests/Fixture/Component/ComponentA.php b/src/TwigComponent/tests/Fixture/Component/ComponentA.php index 70720f69690..33fe0bb2045 100644 --- a/src/TwigComponent/tests/Fixture/Component/ComponentA.php +++ b/src/TwigComponent/tests/Fixture/Component/ComponentA.php @@ -11,13 +11,14 @@ namespace Symfony\UX\TwigComponent\Tests\Fixture\Component; -use Symfony\UX\TwigComponent\ComponentInterface; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Tests\Fixture\Service\ServiceA; /** * @author Kevin Bond */ -final class ComponentA implements ComponentInterface +#[AsTwigComponent('component_a')] +final class ComponentA { public $propA; @@ -29,11 +30,6 @@ public function __construct(ServiceA $service) $this->service = $service; } - public static function getComponentName(): string - { - return 'component_a'; - } - public function getService() { return $this->service; diff --git a/src/TwigComponent/tests/Fixture/Component/ComponentB.php b/src/TwigComponent/tests/Fixture/Component/ComponentB.php index d35aa3afa73..c5b28b8d812 100644 --- a/src/TwigComponent/tests/Fixture/Component/ComponentB.php +++ b/src/TwigComponent/tests/Fixture/Component/ComponentB.php @@ -11,15 +11,12 @@ namespace Symfony\UX\TwigComponent\Tests\Fixture\Component; -use Symfony\UX\TwigComponent\ComponentInterface; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; /** * @author Kevin Bond */ -final class ComponentB implements ComponentInterface +#[AsTwigComponent('component_b')] +final class ComponentB { - public static function getComponentName(): string - { - return 'component_b'; - } } diff --git a/src/TwigComponent/tests/Fixture/Component/ComponentC.php b/src/TwigComponent/tests/Fixture/Component/ComponentC.php index 9cb1e28cf90..d3ee31cac7e 100644 --- a/src/TwigComponent/tests/Fixture/Component/ComponentC.php +++ b/src/TwigComponent/tests/Fixture/Component/ComponentC.php @@ -11,22 +11,18 @@ namespace Symfony\UX\TwigComponent\Tests\Fixture\Component; -use Symfony\UX\TwigComponent\ComponentInterface; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; /** * @author Kevin Bond */ -final class ComponentC implements ComponentInterface +#[AsTwigComponent('component_c')] +final class ComponentC { public $propA; public $propB; public $propC; - public static function getComponentName(): string - { - return 'component_c'; - } - public function mount($propA, $propB = null, $propC = 'default') { $this->propA = $propA; diff --git a/src/TwigComponent/tests/Fixture/Kernel.php b/src/TwigComponent/tests/Fixture/Kernel.php index e0350e97c25..3c7a5251811 100644 --- a/src/TwigComponent/tests/Fixture/Kernel.php +++ b/src/TwigComponent/tests/Fixture/Kernel.php @@ -50,13 +50,21 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'default_path' => '%kernel.project_dir%/tests/Fixture/templates', ]); - $c->register(ServiceA::class)->setAutoconfigured(true)->setAutowired(true); - $c->register(ComponentA::class)->setAutoconfigured(true)->setAutowired(true); - $c->register('component_b', ComponentB::class)->setAutoconfigured(true)->setAutowired(true); - $c->register(ComponentC::class)->setAutoconfigured(true)->setAutowired(true); + $service = $c->register(ServiceA::class)->setAutoconfigured(true)->setAutowired(true); - if ('multiple_component_b' === $this->environment) { - $c->register('different_component_b', ComponentB::class)->setAutoconfigured(true)->setAutowired(true); + $componentA = $c->register(ComponentA::class)->setAutoconfigured(true)->setAutowired(true); + $componentB = $c->register('component_b', ComponentB::class)->setAutoconfigured(true)->setAutowired(true); + $componentC = $c->register(ComponentC::class)->setAutoconfigured(true)->setAutowired(true); + + if (self::VERSION_ID < 50300) { + // add tag manually + $componentA->addTag('twig.component'); + $componentB->addTag('twig.component'); + $componentC->addTag('twig.component'); + } + + if ('missing_attribute' === $this->environment) { + $service->addTag('twig.component'); } } diff --git a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php index ccbb85b7343..8fc9061dfb6 100644 --- a/src/TwigComponent/tests/Integration/ComponentFactoryTest.php +++ b/src/TwigComponent/tests/Integration/ComponentFactoryTest.php @@ -28,7 +28,7 @@ public function testCreatedComponentsAreNotShared(): void self::bootKernel(); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var ComponentA $componentA */ $componentA = $factory->create('component_a', ['propA' => 'A', 'propB' => 'B']); @@ -49,7 +49,7 @@ public function testNonAutoConfiguredCreatedComponentsAreNotShared(): void self::bootKernel(); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var ComponentB $componentA */ $componentA = $factory->create('component_b'); @@ -60,31 +60,12 @@ public function testNonAutoConfiguredCreatedComponentsAreNotShared(): void $this->assertNotSame(spl_object_id($componentA), spl_object_id($componentB)); } - public function testShortNameCannotBeDifferentThanComponentName(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Component "Symfony\UX\TwigComponent\Tests\Fixture\Component\ComponentB" is already registered as "component_b", components cannot be registered more than once.'); - - self::bootKernel(['environment' => 'multiple_component_b']); - } - - public function testCanGetServiceId(): void - { - self::bootKernel(); - - /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); - - $this->assertSame(ComponentA::class, $factory->serviceIdFor('component_a')); - $this->assertSame('component_b', $factory->serviceIdFor('component_b')); - } - public function testCanGetUnmountedComponent(): void { self::bootKernel(); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var ComponentA $component */ $component = $factory->get('component_a'); @@ -98,7 +79,7 @@ public function testMountCanHaveOptionalParameters(): void self::bootKernel(); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); /** @var ComponentC $component */ $component = $factory->create('component_c', [ @@ -126,7 +107,7 @@ public function testExceptionThrownIfRequiredMountParameterIsMissingFromPassedDa self::bootKernel(); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); $this->expectException(\LogicException::class); $this->expectExceptionMessage('Symfony\UX\TwigComponent\Tests\Fixture\Component\ComponentC::mount() has a required $propA parameter. Make sure this is passed or make give a default value.'); @@ -139,11 +120,19 @@ public function testExceptionThrownIfUnableToWritePassedDataToProperty(): void self::bootKernel(); /** @var ComponentFactory $factory */ - $factory = self::$container->get(ComponentFactory::class); + $factory = self::$container->get('ux.twig_component.component_factory'); $this->expectException(\LogicException::class); $this->expectExceptionMessage('Unable to write "service" to component "Symfony\UX\TwigComponent\Tests\Fixture\Component\ComponentA". Make sure this is a writable property or create a mount() with a $service argument.'); $factory->create('component_a', ['propB' => 'B', 'service' => 'invalid']); } + + public function testTwigComponentServiceMustHaveAttribute(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Service "Symfony\UX\TwigComponent\Tests\Fixture\Service\ServiceA" is tagged as a "twig.component" but does not have a "Symfony\UX\TwigComponent\Attribute\AsTwigComponent" class attribute.'); + + self::bootKernel(['environment' => 'missing_attribute']); + } }