diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 14944b29b7c..8f4f600594f 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -422,6 +422,7 @@ LiveProp for Entities & More Complex Data like ``DateTime`` objects, enums & Doctrine entity objects. When ``LiveProp``s are sent to the frontend, they are "dehydrated". When Ajax requests are sent to the frontend, the dehydrated data is then "hydrated" back into the original. + Doctrine entity objects are a special case for ``LiveProp``:: use App\Entity\Post; diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index f0f85f36c40..27b8a7c16d4 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -42,7 +42,6 @@ use Symfony\UX\LiveComponent\Util\TwigAttributeHelperFactory; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; - use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; /** diff --git a/ux.symfony.com/composer.json b/ux.symfony.com/composer.json index 36c263c0414..e297a13ac8f 100644 --- a/ux.symfony.com/composer.json +++ b/ux.symfony.com/composer.json @@ -26,6 +26,7 @@ "symfony/notifier": "6.2.*", "symfony/proxy-manager-bridge": "6.2.*", "symfony/runtime": "6.2.*", + "symfony/translation": "6.2.*", "symfony/twig-bundle": "6.2.*", "symfony/ux-autocomplete": "2.x-dev", "symfony/ux-chartjs": "2.x-dev", @@ -44,6 +45,7 @@ "symfony/webpack-encore-bundle": "^1.14", "symfony/yaml": "6.2.*", "twig/extra-bundle": "^2.12|^3.0", + "twig/intl-extra": "^3.5", "twig/markdown-extra": "^3.4", "twig/twig": "^2.12|^3.0" }, diff --git a/ux.symfony.com/composer.lock b/ux.symfony.com/composer.lock index dd26054bc87..fc6bf0d27d9 100644 --- a/ux.symfony.com/composer.lock +++ b/ux.symfony.com/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6aaefe88e6e269212b3f7a45fd77fc18", + "content-hash": "18e8e13f0053c219e7530d4e6f245cce", "packages": [ { "name": "babdev/pagerfanta-bundle", @@ -4807,6 +4807,87 @@ ], "time": "2023-02-28T13:26:41+00:00" }, + { + "name": "symfony/intl", + "version": "v6.2.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/intl.git", + "reference": "e7346ea6d88ae22e1b5d489b7a60135e72527cec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/intl/zipball/e7346ea6d88ae22e1b5d489b7a60135e72527cec", + "reference": "e7346ea6d88ae22e1b5d489b7a60135e72527cec", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "symfony/filesystem": "^5.4|^6.0", + "symfony/finder": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Eriksen Costa", + "email": "eriksen.costa@infranology.com.br" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a PHP replacement layer for the C intl extension that includes additional data from the ICU library", + "homepage": "https://symfony.com", + "keywords": [ + "i18n", + "icu", + "internationalization", + "intl", + "l10n", + "localization" + ], + "support": { + "source": "https://github.com/symfony/intl/tree/v6.2.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-02-21T10:54:55+00:00" + }, { "name": "symfony/mercure", "version": "v0.6.3", @@ -6418,6 +6499,104 @@ ], "time": "2023-02-24T10:42:00+00:00" }, + { + "name": "symfony/translation", + "version": "v6.2.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "90db1c6138c90527917671cd9ffa9e8b359e3a73" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/90db1c6138c90527917671cd9ffa9e8b359e3a73", + "reference": "90db1c6138c90527917671cd9ffa9e8b359e3a73", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.3|^3.0" + }, + "conflict": { + "symfony/config": "<5.4", + "symfony/console": "<5.4", + "symfony/dependency-injection": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/twig-bundle": "<5.4", + "symfony/yaml": "<5.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^4.13", + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0", + "symfony/console": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/finder": "^5.4|^6.0", + "symfony/http-client-contracts": "^1.1|^2.0|^3.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/intl": "^5.4|^6.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^5.4|^6.0", + "symfony/service-contracts": "^1.1.2|^2|^3", + "symfony/yaml": "^5.4|^6.0" + }, + "suggest": { + "nikic/php-parser": "To use PhpAstExtractor", + "psr/log-implementation": "To use logging capability in translator", + "symfony/config": "", + "symfony/yaml": "" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v6.2.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-02-24T10:42:00+00:00" + }, { "name": "symfony/translation-contracts", "version": "v3.2.1", @@ -8323,6 +8502,75 @@ ], "time": "2023-02-08T07:44:55+00:00" }, + { + "name": "twig/intl-extra", + "version": "v3.5.1", + "source": { + "type": "git", + "url": "https://github.com/twigphp/intl-extra.git", + "reference": "c3ebfac1624228c0556de57a34af6b7d83a1a408" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/c3ebfac1624228c0556de57a34af6b7d83a1a408", + "reference": "c3ebfac1624228c0556de57a34af6b7d83a1a408", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/intl": "^4.4|^5.0|^6.0", + "twig/twig": "^2.7|^3.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.5-dev" + } + }, + "autoload": { + "psr-4": { + "Twig\\Extra\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Twig extension for Intl", + "homepage": "https://twig.symfony.com", + "keywords": [ + "intl", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/intl-extra/tree/v3.5.1" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2023-02-08T07:44:55+00:00" + }, { "name": "twig/markdown-extra", "version": "v3.5.1", diff --git a/ux.symfony.com/config/packages/translation.yaml b/ux.symfony.com/config/packages/translation.yaml new file mode 100644 index 00000000000..abb76aae82d --- /dev/null +++ b/ux.symfony.com/config/packages/translation.yaml @@ -0,0 +1,13 @@ +framework: + default_locale: en + translator: + default_path: '%kernel.project_dir%/translations' + fallbacks: + - en +# providers: +# crowdin: +# dsn: '%env(CROWDIN_DSN)%' +# loco: +# dsn: '%env(LOCO_DSN)%' +# lokalise: +# dsn: '%env(LOKALISE_DSN)%' diff --git a/ux.symfony.com/migrations/Version20230322181630.php b/ux.symfony.com/migrations/Version20230322181630.php new file mode 100644 index 00000000000..c60961f4975 --- /dev/null +++ b/ux.symfony.com/migrations/Version20230322181630.php @@ -0,0 +1,59 @@ +addSql('CREATE TABLE invoice (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, customer_name VARCHAR(255) NOT NULL, customer_email VARCHAR(255) NOT NULL, tax_rate INTEGER NOT NULL)'); + $this->addSql('CREATE TABLE invoice_item (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, invoice_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL, CONSTRAINT FK_1DDE477B2989F1FD FOREIGN KEY (invoice_id) REFERENCES invoice (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1DDE477B4584665A FOREIGN KEY (product_id) REFERENCES product (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('CREATE INDEX IDX_1DDE477B2989F1FD ON invoice_item (invoice_id)'); + $this->addSql('CREATE INDEX IDX_1DDE477B4584665A ON invoice_item (product_id)'); + $this->addSql('CREATE TABLE product (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, price INTEGER NOT NULL)'); + $this->addSql('CREATE TEMPORARY TABLE __temp__food AS SELECT id, name, votes FROM food'); + $this->addSql('DROP TABLE food'); + $this->addSql('CREATE TABLE food (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, votes INTEGER NOT NULL)'); + $this->addSql('INSERT INTO food (id, name, votes) SELECT id, name, votes FROM __temp__food'); + $this->addSql('DROP TABLE __temp__food'); + $this->addSql('CREATE TEMPORARY TABLE __temp__todo_item AS SELECT id, todo_list_id, description, priority FROM todo_item'); + $this->addSql('DROP TABLE todo_item'); + $this->addSql('CREATE TABLE todo_item (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, todo_list_id INTEGER NOT NULL, description VARCHAR(255) NOT NULL, priority INTEGER NOT NULL, CONSTRAINT FK_40CA4301E8A7DCFA FOREIGN KEY (todo_list_id) REFERENCES todo_list (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO todo_item (id, todo_list_id, description, priority) SELECT id, todo_list_id, description, priority FROM __temp__todo_item'); + $this->addSql('DROP TABLE __temp__todo_item'); + $this->addSql('CREATE INDEX IDX_40CA4301E8A7DCFA ON todo_item (todo_list_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE invoice'); + $this->addSql('DROP TABLE invoice_item'); + $this->addSql('DROP TABLE product'); + $this->addSql('CREATE TEMPORARY TABLE __temp__food AS SELECT id, name, votes FROM food'); + $this->addSql('DROP TABLE food'); + $this->addSql('CREATE TABLE food (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, votes INTEGER DEFAULT 0 NOT NULL)'); + $this->addSql('INSERT INTO food (id, name, votes) SELECT id, name, votes FROM __temp__food'); + $this->addSql('DROP TABLE __temp__food'); + $this->addSql('CREATE TEMPORARY TABLE __temp__todo_item AS SELECT id, todo_list_id, description, priority FROM todo_item'); + $this->addSql('DROP TABLE todo_item'); + $this->addSql('CREATE TABLE todo_item (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, todo_list_id INTEGER NOT NULL, description VARCHAR(255) NOT NULL, priority INTEGER NOT NULL)'); + $this->addSql('INSERT INTO todo_item (id, todo_list_id, description, priority) SELECT id, todo_list_id, description, priority FROM __temp__todo_item'); + $this->addSql('DROP TABLE __temp__todo_item'); + $this->addSql('CREATE INDEX IDX_40CA4301E8A7DCFA ON todo_item (todo_list_id)'); + } +} diff --git a/ux.symfony.com/src/Command/LoadDataCommand.php b/ux.symfony.com/src/Command/LoadDataCommand.php index 9c912dc51c6..7ec2df39d15 100644 --- a/ux.symfony.com/src/Command/LoadDataCommand.php +++ b/ux.symfony.com/src/Command/LoadDataCommand.php @@ -4,6 +4,9 @@ use App\Entity\Chat; use App\Entity\Food; +use App\Entity\Invoice; +use App\Entity\InvoiceItem; +use App\Entity\Product; use App\Entity\TodoItem; use App\Entity\TodoList; use Doctrine\ORM\EntityManagerInterface; @@ -28,7 +31,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->clearEntity(Chat::class, $io); $this->clearEntity(TodoItem::class, $io); $this->clearEntity(TodoList::class, $io); + $this->clearEntity(InvoiceItem::class, $io); + $this->clearEntity(Invoice::class, $io); $this->growFood($io); + $this->manufactureProducts($io); return Command::SUCCESS; } @@ -67,6 +73,39 @@ private function growFood(SymfonyStyle $io): void $this->entityManager->flush(); } + private function manufactureProducts(SymfonyStyle $io): void + { + // an array of funny products that aliens might use + $products = [ + 'Spare tire for the flying saucer', + 'Glorp-o-Matic 3000', + 'Giant laser', + 'Space helmet', + 'Space boots', + 'Interstellar Snack Pack', + 'UFO Parking Permit', + 'Anti-Gravity Propulsion System', + 'Tractor Beam Emitter', + 'Temporal Flux Regulator', + 'Space Heater', + 'Faster-Than-Light Communicator', + ]; + + $this->clearEntity(Product::class, $io); + $io->info('Manufacturing space products...'); + foreach ($products as $product) { + $entity = new Product(); + $entity->setName($product); + $entity->setPrice(rand(10, 200) * 100); + $io->write([$product, ' ']); + + $this->entityManager->persist($entity); + } + $io->writeln(''); + + $this->entityManager->flush(); + } + private function clearEntity(string $className, SymfonyStyle $io): void { $io->writeln(sprintf('Clearing %s', $className)); diff --git a/ux.symfony.com/src/Controller/LiveComponentDemoController.php b/ux.symfony.com/src/Controller/LiveComponentDemoController.php index 03070cb99ff..946fe1bd04f 100644 --- a/ux.symfony.com/src/Controller/LiveComponentDemoController.php +++ b/ux.symfony.com/src/Controller/LiveComponentDemoController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Entity\Invoice; use App\Entity\TodoItem; use App\Entity\TodoList; use App\Form\TodoListForm; @@ -12,6 +13,10 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\UX\LiveComponent\LiveComponentHydrator; #[Route('/live-component/demos')] class LiveComponentDemoController extends AbstractController @@ -83,4 +88,15 @@ public function chartJs(LiveDemoRepository $liveDemoRepository): Response 'demo' => $liveDemoRepository->find('chartjs_updating'), ]); } + + #[Route('/invoice/{id}', name: 'app_live_components_invoice', defaults: ['id' => null])] + public function invoice(LiveDemoRepository $liveDemoRepository, Invoice $invoice = null): Response + { + $invoice = $invoice ?? new Invoice(); + + return $this->render('live_component_demo/invoice.html.twig', parameters: [ + 'demo' => $liveDemoRepository->find('invoice'), + 'invoice' => $invoice, + ]); + } } diff --git a/ux.symfony.com/src/Entity/Invoice.php b/ux.symfony.com/src/Entity/Invoice.php new file mode 100644 index 00000000000..e651f7bbd22 --- /dev/null +++ b/ux.symfony.com/src/Entity/Invoice.php @@ -0,0 +1,120 @@ +invoiceItems = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCustomerName(): ?string + { + return $this->customerName; + } + + public function setCustomerName(string $customerName): self + { + $this->customerName = $customerName; + + return $this; + } + + public function getCustomerEmail(): ?string + { + return $this->customerEmail; + } + + public function setCustomerEmail(string $customerEmail): self + { + $this->customerEmail = $customerEmail; + + return $this; + } + + public function getTaxRate(): int + { + return $this->taxRate; + } + + public function setTaxRate(int $taxRate): self + { + $this->taxRate = $taxRate; + + return $this; + } + + /** + * @return Collection + */ + public function getInvoiceItems(): Collection + { + return $this->invoiceItems; + } + + public function addInvoiceItem(InvoiceItem $invoiceItem): self + { + if (!$this->invoiceItems->contains($invoiceItem)) { + $this->invoiceItems->add($invoiceItem); + $invoiceItem->setInvoice($this); + } + + return $this; + } + + public function removeInvoiceItem(InvoiceItem $invoiceItem): self + { + if ($this->invoiceItems->removeElement($invoiceItem)) { + // set the owning side to null (unless already changed) + if ($invoiceItem->getInvoice() === $this) { + $invoiceItem->setInvoice(null); + } + } + + return $this; + } + + public function getSubtotal(): int + { + $subtotal = 0; + foreach ($this->getInvoiceItems() as $invoiceItem) { + $subtotal += $invoiceItem->getSubtotal(); + } + + return $subtotal; + } +} diff --git a/ux.symfony.com/src/Entity/InvoiceItem.php b/ux.symfony.com/src/Entity/InvoiceItem.php new file mode 100644 index 00000000000..65a5e217e5f --- /dev/null +++ b/ux.symfony.com/src/Entity/InvoiceItem.php @@ -0,0 +1,76 @@ +id; + } + + public function getInvoice(): ?Invoice + { + return $this->invoice; + } + + public function setInvoice(?Invoice $invoice): self + { + $this->invoice = $invoice; + + return $this; + } + + public function getProduct(): ?Product + { + return $this->product; + } + + public function setProduct(?Product $product): self + { + $this->product = $product; + + return $this; + } + + public function getQuantity(): int + { + return $this->quantity; + } + + public function setQuantity(int $quantity): self + { + $this->quantity = $quantity; + + return $this; + } + + public function getSubtotal(): int + { + if (!$this->product) { + return 0; + } + + return $this->product->getPrice() * $this->quantity; + } +} diff --git a/ux.symfony.com/src/Entity/Product.php b/ux.symfony.com/src/Entity/Product.php new file mode 100644 index 00000000000..4d5402b7348 --- /dev/null +++ b/ux.symfony.com/src/Entity/Product.php @@ -0,0 +1,55 @@ +id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getPrice(): ?int + { + return $this->price; + } + + public function setPrice(int $price): self + { + $this->price = $price; + + return $this; + } + + public function getPriceInCents(): float + { + return $this->price / 100; + } +} diff --git a/ux.symfony.com/src/Repository/InvoiceItemRepository.php b/ux.symfony.com/src/Repository/InvoiceItemRepository.php new file mode 100644 index 00000000000..63ae7490a11 --- /dev/null +++ b/ux.symfony.com/src/Repository/InvoiceItemRepository.php @@ -0,0 +1,66 @@ + + * + * @method InvoiceItem|null find($id, $lockMode = null, $lockVersion = null) + * @method InvoiceItem|null findOneBy(array $criteria, array $orderBy = null) + * @method InvoiceItem[] findAll() + * @method InvoiceItem[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class InvoiceItemRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, InvoiceItem::class); + } + + public function save(InvoiceItem $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(InvoiceItem $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + +// /** +// * @return InvoiceItem[] Returns an array of InvoiceItem objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('i') +// ->andWhere('i.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('i.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?InvoiceItem +// { +// return $this->createQueryBuilder('i') +// ->andWhere('i.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/ux.symfony.com/src/Repository/InvoiceRepository.php b/ux.symfony.com/src/Repository/InvoiceRepository.php new file mode 100644 index 00000000000..cc2bf090a6f --- /dev/null +++ b/ux.symfony.com/src/Repository/InvoiceRepository.php @@ -0,0 +1,66 @@ + + * + * @method Invoice|null find($id, $lockMode = null, $lockVersion = null) + * @method Invoice|null findOneBy(array $criteria, array $orderBy = null) + * @method Invoice[] findAll() + * @method Invoice[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class InvoiceRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Invoice::class); + } + + public function save(Invoice $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(Invoice $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + +// /** +// * @return Invoice[] Returns an array of Invoice objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('i') +// ->andWhere('i.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('i.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?Invoice +// { +// return $this->createQueryBuilder('i') +// ->andWhere('i.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/ux.symfony.com/src/Repository/ProductRepository.php b/ux.symfony.com/src/Repository/ProductRepository.php new file mode 100644 index 00000000000..9e8c42dfabc --- /dev/null +++ b/ux.symfony.com/src/Repository/ProductRepository.php @@ -0,0 +1,66 @@ + + * + * @method Product|null find($id, $lockMode = null, $lockVersion = null) + * @method Product|null findOneBy(array $criteria, array $orderBy = null) + * @method Product[] findAll() + * @method Product[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class ProductRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Product::class); + } + + public function save(Product $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(Product $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + +// /** +// * @return Product[] Returns an array of Product objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('p') +// ->andWhere('p.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('p.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?Product +// { +// return $this->createQueryBuilder('p') +// ->andWhere('p.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/ux.symfony.com/src/Service/LiveDemoRepository.php b/ux.symfony.com/src/Service/LiveDemoRepository.php index c9d567a456a..43b3a2842d0 100644 --- a/ux.symfony.com/src/Service/LiveDemoRepository.php +++ b/ux.symfony.com/src/Service/LiveDemoRepository.php @@ -80,6 +80,18 @@ public function findAll(): array An auto-updating chart that you will ❤️. EOF ), + new LiveDemo( + 'invoice', + name: 'Invoice Creator', + description: 'Create an invoice + line items that updates as you type.', + route: 'app_live_components_invoice', + longDescription: << +Children emit events to communicate to the parent. +EOF + + ), ]; } diff --git a/ux.symfony.com/src/Twig/InvoiceComponent.php b/ux.symfony.com/src/Twig/InvoiceComponent.php new file mode 100644 index 00000000000..a23715b6a08 --- /dev/null +++ b/ux.symfony.com/src/Twig/InvoiceComponent.php @@ -0,0 +1,194 @@ +invoice = $invoice; + $this->lineItems = $this->populateLineItems($invoice); + } + + #[LiveAction] + public function addLineItem(): void + { + $this->lineItems[] = [ + 'productId' => null, + 'quantity' => 1, + 'isEditing' => true, + ]; + } + + #[LiveListener('removeLineItem')] + public function removeLineItem(#[LiveArg] int $key): void + { + unset($this->lineItems[$key]); + } + + #[LiveListener('line_item:change_edit_mode')] + public function onLineItemEditModeChange(#[LiveArg] int $key, #[LiveArg] $isEditing): void + { + $this->lineItems[$key]['isEditing'] = $isEditing; + } + + #[LiveListener('line_item:save')] + public function saveLineItem(#[LiveArg] int $key, #[LiveArg] Product $product, #[LiveArg] int $quantity): void + { + if (!isset($this->lineItems[$key])) { + // shouldn't happen + return; + } + + $this->lineItems[$key]['productId'] = $product->getId(); + $this->lineItems[$key]['quantity'] = $quantity; + } + + #[LiveAction] + public function saveInvoice(EntityManagerInterface $entityManager) + { + $this->saveFailed = true; + $this->validate(); + $this->saveFailed = false; + + // TODO: do we check for `isSaved` here... and throw an error? + + // remove any items that no longer exist + foreach ($this->invoice->getInvoiceItems() as $key => $item) { + if (!isset($this->lineItems[$key])) { + // orphanRemoval will cause these to be deleted + $this->invoice->removeInvoiceItem($item); + } + } + + foreach ($this->lineItems as $key => $lineItem) { + $invoiceItem = $this->invoice->getInvoiceItems()->get($key); + if (null === $invoiceItem) { + // this is a new item! Welcome! + $invoiceItem = new InvoiceItem(); + $entityManager->persist($invoiceItem); + $this->invoice->addInvoiceItem($invoiceItem); + } + + $product = $this->findProduct($lineItem['productId']); + $invoiceItem->setProduct($product); + $invoiceItem->setQuantity($lineItem['quantity']); + } + + $isNew = null === $this->invoice->getId(); + $entityManager->persist($this->invoice); + $entityManager->flush(); + + if ($isNew) { + // it's new! Let's redirect to the edit page + $this->addFlash('live_demo_success', 'Invoice saved!'); + + return $this->redirectToRoute('app_live_components_invoice', [ + 'id' => $this->invoice->getId() + ]); + } + + // it's not new! We should already be on the edit page, so let's + // just let the component stay rendered. + $this->savedSuccessfully = true; + + // Keep the lineItems in sync with the invoice: new InvoiceItems may + // not have been given the same key as the original lineItems + $this->lineItems = $this->populateLineItems($this->invoice); + } + + public function getSubtotal(): float + { + $subTotal = 0; + + foreach ($this->lineItems as $lineItem) { + if (!($lineItem['productId'])) { + continue; + } + + $product = $this->findProduct($lineItem['productId']); + + $subTotal += ($product->getPrice() * $lineItem['quantity']); + } + + return $subTotal / 100; + } + + public function getTotal(): float + { + $taxMultiplier = 1 + ($this->invoice->getTaxRate() / 100); + + return $this->getSubtotal() * $taxMultiplier; + } + + #[ExposeInTemplate] + public function areAnyLineItemsEditing(): bool + { + foreach ($this->lineItems as $lineItem) { + if ($lineItem['isEditing']) { + return true; + } + } + + return false; + } + + private function populateLineItems(Invoice $invoice): array + { + $lineItems = []; + foreach ($invoice->getInvoiceItems() as $item) { + $lineItems[] = [ + 'productId' => $item->getProduct()->getId(), + 'quantity' => $item->getQuantity(), + 'isEditing' => false, + ]; + } + + return $lineItems; + } + + private function findProduct(int $id): Product + { + return $this->productRepository->find($id); + } +} diff --git a/ux.symfony.com/src/Twig/InvoiceItemComponent.php b/ux.symfony.com/src/Twig/InvoiceItemComponent.php new file mode 100644 index 00000000000..10c8b2f61d6 --- /dev/null +++ b/ux.symfony.com/src/Twig/InvoiceItemComponent.php @@ -0,0 +1,83 @@ +product = $this->productRepository->find($productId); + } + } + + #[LiveAction] + public function save(LiveResponder $responder): void + { + $this->validate(); + + $responder->emitUp('line_item:save', [ + 'key' => $this->key, + 'product' => $this->product->getId(), + 'quantity' => $this->quantity, + ]); + + $this->changeEditMode(false, $responder); + } + + #[LiveAction] + public function edit(LiveResponder $responder): void + { + $this->changeEditMode(true, $responder); + } + + #[ExposeInTemplate] + public function getProducts(): array + { + return $this->productRepository->findAll(); + } + + private function changeEditMode(bool $isEditing, LiveResponder $responder): void + { + $this->isEditing = $isEditing; + + // emit to InvoiceComponent so it can track which items are being edited + $responder->emitUp('line_item:change_edit_mode', [ + 'key' => $this->key, + 'isEditing' => $this->isEditing, + ]); + } +} diff --git a/ux.symfony.com/symfony.lock b/ux.symfony.com/symfony.lock index b6678e67125..a3d5c8643f2 100644 --- a/ux.symfony.com/symfony.lock +++ b/ux.symfony.com/symfony.lock @@ -483,6 +483,19 @@ "symfony/string": { "version": "v6.0.3" }, + "symfony/translation": { + "version": "6.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.3", + "ref": "da64f5a2b6d96f5dc24914517c0350a5f91dee43" + }, + "files": [ + "config/packages/translation.yaml", + "translations/.gitignore" + ] + }, "symfony/translation-contracts": { "version": "v3.0.1" }, diff --git a/ux.symfony.com/templates/components/invoice.html.twig b/ux.symfony.com/templates/components/invoice.html.twig new file mode 100644 index 00000000000..bdae33c9014 --- /dev/null +++ b/ux.symfony.com/templates/components/invoice.html.twig @@ -0,0 +1,119 @@ + + + + Customer name: + + {% if _errors.has('invoice.customerName') %} + + {{ _errors.get('invoice.customerName') }} + + {% endif %} + + + + Billing Email: + + {% if _errors.has('invoice.customerEmail') %} + + {{ _errors.get('invoice.customerEmail') }} + + {% endif %} + + + + Invoice Items + + + + + Product + Price + Quantity + + + + + {% for key, line in lineItems %} + {{ component('invoice_item', { + key: key, + productId: line.productId, + quantity: line.quantity, + isEditing: line.isEditing, + }) }} + {% endfor %} + + + + Add Item + + + + + + + + Subtotal: + {{ this.subtotal|format_currency('USD') }} + + + Tax rate: + + + + % + + {% if _errors.has('invoice.taxRate') %} + + {{ _errors.get('invoice.taxRate') }} + + {% endif %} + + + + Total: + {{ this.total|format_currency('USD') }} + + + + + + + + {% if savedSuccessfully %} + + {% endif %} + {% if saveFailed %} + + {% endif %} + Save Invoice + + {% if saveFailed %} + Check above for errors + {% endif %} + {% if areAnyLineItemsEditing %} + Save all line items before continuing. + {% endif %} + + diff --git a/ux.symfony.com/templates/components/invoice_item.html.twig b/ux.symfony.com/templates/components/invoice_item.html.twig new file mode 100644 index 00000000000..cd4596fa765 --- /dev/null +++ b/ux.symfony.com/templates/components/invoice_item.html.twig @@ -0,0 +1,74 @@ + + + {% if isEditing %} + + Choose a Product + {% for productOption in products %} + + {{ productOption.name }} ({{ productOption.priceInCents|format_currency('USD') }}) + + {% endfor %} + + {% if _errors.has('product') %} + + {{ _errors.get('product') }} + + {% endif %} + + {% else %} + {{ product.name }} + {% endif %} + + + + {% if not isEditing %} + {{ product.priceInCents|format_currency('USD') }} + {% endif %} + + + + {% if isEditing %} + + {% if _errors.has('quantity') %} + + {{ _errors.get('quantity') }} + + {% endif %} + {% else %} + {{ quantity }} + {% endif %} + + + {% if isEditing %} + Save + {% else %} + Edit + {% endif %} + + + + diff --git a/ux.symfony.com/templates/live_component_demo/invoice.html.twig b/ux.symfony.com/templates/live_component_demo/invoice.html.twig new file mode 100644 index 00000000000..5d0fa90c53b --- /dev/null +++ b/ux.symfony.com/templates/live_component_demo/invoice.html.twig @@ -0,0 +1,11 @@ +{% extends 'liveDemoBase.html.twig' %} + +{% block demo_content %} + + + {{ component('invoice', { + invoice: invoice + }) }} + + +{% endblock %} diff --git a/ux.symfony.com/translations/.gitignore b/ux.symfony.com/translations/.gitignore new file mode 100644 index 00000000000..e69de29bb2d