From c8f0e3cdd181da6e366140f38ef8ef210d525f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Fr=C3=A9mont?= Date: Thu, 20 Jun 2024 10:13:52 +0200 Subject: [PATCH] Init Admin UI component --- .env | 10 ++ app/DataFixtures/AppFixtures.php | 26 +++ app/Entity/.gitignore | 0 app/Entity/Book.php | 87 ++++++++++ app/Factory/BookFactory.php | 61 +++++++ app/Repository/.gitignore | 0 app/Repository/BookRepository.php | 29 ++++ app/Story/DefaultBooksStory.php | 21 +++ compose.override.yaml | 6 + compose.yaml | 19 +++ composer.json | 13 +- config/bundles.php | 10 ++ config/packages/doctrine.yaml | 50 ++++++ config/packages/doctrine_migrations.yaml | 6 + config/packages/sylius_resource.yaml | 15 ++ config/packages/translation.yaml | 13 ++ config/packages/validator.yaml | 13 ++ config/packages/zenstruck_foundry.yaml | 7 + config/routes/sylius_resource.yaml | 7 + migrations/.gitignore | 0 phpunit.xml.dist | 5 +- src/AdminUi/composer.json | 14 ++ src/AdminUi/config/services.php | 36 ++++ src/AdminUi/src/Menu/MenuBuilder.php | 29 ++++ src/AdminUi/src/Menu/MenuBuilderInterface.php | 21 +++ src/AdminUi/src/Symfony/AdminUiBundle.php | 45 +++++ .../RoutingHookableMetadataFactory.php | 27 +++ src/AdminUi/templates/crud/create.html.twig | 26 +++ src/AdminUi/templates/crud/index.html.twig | 26 +++ src/AdminUi/templates/crud/update.html.twig | 26 +++ src/AdminUi/templates/layout/base.html.twig | 26 +++ src/AdminUi/translations/messages.en.yaml | 29 ++++ src/TwigExtra/composer.json | 33 ++++ src/TwigExtra/config/services.php | 33 ++++ .../TwigExtraExtension.php | 23 +++ src/TwigExtra/src/Symfony/TwigExtraBundle.php | 11 ++ src/TwigExtra/src/Twig/SortByExtension.php | 56 +++++++ .../src/Twig/TestFormAttributeExtension.php | 35 ++++ .../src/Twig/TestHtmlAttributeExtension.php | 35 ++++ .../Twig/Extension/SortByExtensionTest.php | 21 +++ .../TestFormAttributeExtensionTest.php | 21 +++ .../TestHtmlAttributeExtensionTest.php | 21 +++ .../Twig/Extension/SortByExtensionTest.php | 155 ++++++++++++++++++ .../TestFormAttributeExtensionTest.php | 59 +++++++ .../TestHtmlAttributeExtensionTest.php | 68 ++++++++ src/TwigHooks/composer.json | 2 +- symfony.lock | 94 +++++++++++ translations/messages.en.yaml | 3 + 48 files changed, 1370 insertions(+), 3 deletions(-) create mode 100644 app/DataFixtures/AppFixtures.php create mode 100644 app/Entity/.gitignore create mode 100644 app/Entity/Book.php create mode 100644 app/Factory/BookFactory.php create mode 100644 app/Repository/.gitignore create mode 100644 app/Repository/BookRepository.php create mode 100644 app/Story/DefaultBooksStory.php create mode 100644 compose.override.yaml create mode 100644 compose.yaml create mode 100644 config/packages/doctrine.yaml create mode 100644 config/packages/doctrine_migrations.yaml create mode 100644 config/packages/sylius_resource.yaml create mode 100644 config/packages/translation.yaml create mode 100644 config/packages/validator.yaml create mode 100644 config/packages/zenstruck_foundry.yaml create mode 100644 config/routes/sylius_resource.yaml create mode 100644 migrations/.gitignore create mode 100644 src/AdminUi/composer.json create mode 100644 src/AdminUi/config/services.php create mode 100644 src/AdminUi/src/Menu/MenuBuilder.php create mode 100644 src/AdminUi/src/Menu/MenuBuilderInterface.php create mode 100644 src/AdminUi/src/Symfony/AdminUiBundle.php create mode 100644 src/AdminUi/src/TwigHooks/Hookable/Metadata/RoutingHookableMetadataFactory.php create mode 100644 src/AdminUi/templates/crud/create.html.twig create mode 100644 src/AdminUi/templates/crud/index.html.twig create mode 100644 src/AdminUi/templates/crud/update.html.twig create mode 100644 src/AdminUi/templates/layout/base.html.twig create mode 100644 src/AdminUi/translations/messages.en.yaml create mode 100644 src/TwigExtra/composer.json create mode 100644 src/TwigExtra/config/services.php create mode 100644 src/TwigExtra/src/Symfony/DependencyInjection/TwigExtraExtension.php create mode 100644 src/TwigExtra/src/Symfony/TwigExtraBundle.php create mode 100644 src/TwigExtra/src/Twig/SortByExtension.php create mode 100644 src/TwigExtra/src/Twig/TestFormAttributeExtension.php create mode 100644 src/TwigExtra/src/Twig/TestHtmlAttributeExtension.php create mode 100644 src/TwigExtra/tests/Functional/Twig/Extension/SortByExtensionTest.php create mode 100644 src/TwigExtra/tests/Functional/Twig/Extension/TestFormAttributeExtensionTest.php create mode 100644 src/TwigExtra/tests/Functional/Twig/Extension/TestHtmlAttributeExtensionTest.php create mode 100644 src/TwigExtra/tests/Unit/Twig/Extension/SortByExtensionTest.php create mode 100644 src/TwigExtra/tests/Unit/Twig/Extension/TestFormAttributeExtensionTest.php create mode 100644 src/TwigExtra/tests/Unit/Twig/Extension/TestHtmlAttributeExtensionTest.php create mode 100644 translations/messages.en.yaml diff --git a/.env b/.env index 2c5fda79..4f8bf59c 100644 --- a/.env +++ b/.env @@ -18,3 +18,13 @@ APP_ENV=dev APP_SECRET=28de35e787e86cb435f0312627623283 ###< symfony/framework-bundle ### + +###> doctrine/doctrine-bundle ### +# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url +# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml +# +# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" +# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" +# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" +DATABASE_URL="postgresql://sylius_stack:sylius_stack@127.0.0.1:5432/sylius_stack?serverVersion=16&charset=utf8" +###< doctrine/doctrine-bundle ### diff --git a/app/DataFixtures/AppFixtures.php b/app/DataFixtures/AppFixtures.php new file mode 100644 index 00000000..b37fb3c7 --- /dev/null +++ b/app/DataFixtures/AppFixtures.php @@ -0,0 +1,26 @@ + [ + 'subheader' => 'app.ui.manage_your_books', + ], + ], +)] +class Book implements ResourceInterface +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private ?string $title = null; + + #[ORM\Column(type: 'string', length: 255)] + private ?string $authorName = null; + + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $createdAt; + + public function __construct() + { + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getAuthorName(): ?string + { + return $this->authorName; + } + + public function setAuthorName(string $authorName): self + { + $this->authorName = $authorName; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } +} diff --git a/app/Factory/BookFactory.php b/app/Factory/BookFactory.php new file mode 100644 index 00000000..2618fa76 --- /dev/null +++ b/app/Factory/BookFactory.php @@ -0,0 +1,61 @@ + + * + * @method static Book|Proxy createOne(array $attributes = []) + * @method static Book[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static Book[]|Proxy[] createSequence(array|callable $sequence) + * @method static Book|Proxy find(object|array|mixed $criteria) + * @method static Book|Proxy findOrCreate(array $attributes) + * @method static Book|Proxy first(string $sortedField = 'id') + * @method static Book|Proxy last(string $sortedField = 'id') + * @method static Book|Proxy random(array $attributes = []) + * @method static Book|Proxy randomOrCreate(array $attributes = []) + * @method static Book[]|Proxy[] all() + * @method static Book[]|Proxy[] findBy(array $attributes) + * @method static Book[]|Proxy[] randomSet(int $number, array $attributes = []) + * @method static Book[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static BookRepository|ProxyRepositoryDecorator repository() + * @method Book|Proxy create(array|callable $attributes = []) + */ +final class BookFactory extends PersistentProxyObjectFactory +{ + public function withTitle(string $title): self + { + return $this->with(['title' => $title]); + } + + public function withAuthorName(string $authorName): self + { + return $this->with(['authorName' => $authorName]); + } + + public static function class(): string + { + return Book::class; + } + + /** + * @return array|callable + */ + protected function defaults(): array|callable + { + return [ + 'title' => ucfirst(self::faker()->words(3, true)), + 'authorName' => self::faker()->firstName() . ' ' . self::faker()->lastName(), + ]; + } +} diff --git a/app/Repository/.gitignore b/app/Repository/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/app/Repository/BookRepository.php b/app/Repository/BookRepository.php new file mode 100644 index 00000000..7f531af9 --- /dev/null +++ b/app/Repository/BookRepository.php @@ -0,0 +1,29 @@ + + * + * @method Book|null find($id, $lockMode = null, $lockVersion = null) + * @method Book|null findOneBy(array $criteria, array $orderBy = null) + * @method Book[] findAll() + * @method Book[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class BookRepository extends ServiceEntityRepository implements RepositoryInterface +{ + use ResourceRepositoryTrait; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Book::class); + } +} diff --git a/app/Story/DefaultBooksStory.php b/app/Story/DefaultBooksStory.php new file mode 100644 index 00000000..b41d6970 --- /dev/null +++ b/app/Story/DefaultBooksStory.php @@ -0,0 +1,21 @@ +withTitle('1984') + ->withAuthorName('George Orwell') + ; + + BookFactory::createMany(20); + } +} diff --git a/compose.override.yaml b/compose.override.yaml new file mode 100644 index 00000000..a5092af3 --- /dev/null +++ b/compose.override.yaml @@ -0,0 +1,6 @@ +services: +###> doctrine/doctrine-bundle ### + database: + ports: + - "${POSTGRES_PORT:-5432}:5432" +###< doctrine/doctrine-bundle ### diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..724e9802 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,19 @@ +services: +###> doctrine/doctrine-bundle ### + database: + image: postgres:${POSTGRES_VERSION:-16}-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-sylius_stack} + # You should definitely change the password in production + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sylius_stack} + POSTGRES_USER: ${POSTGRES_USER:-sylius_stack} + volumes: + - database_data:/var/lib/postgresql/data:rw + # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! + # - ./docker/db/data:/var/lib/postgresql/data:rw +###< doctrine/doctrine-bundle ### + +volumes: +###> doctrine/doctrine-bundle ### + database_data: +###< doctrine/doctrine-bundle ### diff --git a/composer.json b/composer.json index ef5b14d9..f9b7fe35 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,11 @@ ], "require": { "php": "^8.1", + "doctrine/dbal": "^3", + "doctrine/doctrine-bundle": "^2.12", + "doctrine/doctrine-migrations-bundle": "^3.3", + "doctrine/orm": "^2.19", + "knplabs/knp-menu-bundle": "^3.0", "laminas/laminas-stdlib": "^3.18", "symfony/config": "^6.4 || ^7.0", "symfony/dependency-injection": "^6.4 || ^7.0", @@ -22,6 +27,8 @@ "symfony/http-kernel": "^6.4 || ^7.0", "symfony/stopwatch": "^6.4 || ^7.0", "symfony/twig-bundle": "^6.4 || ^7.0", + "pagerfanta/doctrine-orm-adapter": "^4.6", + "sylius/resource-bundle": "dev-symfony-7", "symfony/ux-live-component": "^2.17", "symfony/ux-twig-component": "^2.17", "twig/twig": "^2.15 || ^3.0" @@ -29,6 +36,7 @@ "require-dev": { "matthiasnoback/symfony-config-test": "^5.1", "matthiasnoback/symfony-dependency-injection-test": "^5.1", + "doctrine/doctrine-fixtures-bundle": "^3.6", "phpstan/phpstan": "^1.10", "phpstan/phpstan-symfony": "^1.3", "phpunit/phpunit": "^9.6", @@ -42,10 +50,13 @@ "symfony/translation": "^6.4 || ^7.0", "symfony/web-profiler-bundle": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0", - "symplify/monorepo-builder": "11.2.*" + "symplify/monorepo-builder": "11.2.*", + "zenstruck/foundry": "^2.0" }, "autoload": { "psr-4": { + "Sylius\\AdminUi\\": "src/AdminUi/src/", + "Sylius\\TwigExtra\\": "src/TwigExtra/src/", "Sylius\\TwigHooks\\": "src/TwigHooks/src/" } }, diff --git a/config/bundles.php b/config/bundles.php index 005b3768..41b98c20 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -5,7 +5,17 @@ Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], Sylius\TwigHooks\TwigHooksBundle::class => ['all' => true], + Sylius\TwigExtra\Symfony\TwigExtraBundle::class => ['all' => true], + Sylius\AdminUi\Symfony\AdminUiBundle::class => ['all' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true], + Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true], + winzou\Bundle\StateMachineBundle\winzouStateMachineBundle::class => ['all' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], + BabDev\PagerfantaBundle\BabDevPagerfantaBundle::class => ['all' => true], + Sylius\Bundle\ResourceBundle\SyliusResourceBundle::class => ['all' => true], + Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], + Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true], + Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], ]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml new file mode 100644 index 00000000..014612d5 --- /dev/null +++ b/config/packages/doctrine.yaml @@ -0,0 +1,50 @@ +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' + + # IMPORTANT: You MUST configure your server version, + # either here or in the DATABASE_URL env var (see .env file) + #server_version: '16' + + profiling_collect_backtrace: '%kernel.debug%' + use_savepoints: true + orm: + auto_generate_proxy_classes: true + enable_lazy_ghost_objects: true + report_fields_where_declared: true + validate_xml_mapping: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + auto_mapping: true + mappings: + App: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/app/Entity' + prefix: 'App\Entity' + alias: App + +when@test: + doctrine: + dbal: + # "TEST_TOKEN" is typically set by ParaTest + dbname_suffix: '_test%env(default::TEST_TOKEN)%' + +when@prod: + doctrine: + orm: + auto_generate_proxy_classes: false + proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' + query_cache_driver: + type: pool + pool: doctrine.system_cache_pool + result_cache_driver: + type: pool + pool: doctrine.result_cache_pool + + framework: + cache: + pools: + doctrine.result_cache_pool: + adapter: cache.app + doctrine.system_cache_pool: + adapter: cache.system diff --git a/config/packages/doctrine_migrations.yaml b/config/packages/doctrine_migrations.yaml new file mode 100644 index 00000000..29231d94 --- /dev/null +++ b/config/packages/doctrine_migrations.yaml @@ -0,0 +1,6 @@ +doctrine_migrations: + migrations_paths: + # namespace is arbitrary but should be different from App\Migrations + # as migrations classes should NOT be autoloaded + 'DoctrineMigrations': '%kernel.project_dir%/migrations' + enable_profiler: false diff --git a/config/packages/sylius_resource.yaml b/config/packages/sylius_resource.yaml new file mode 100644 index 00000000..0ef4a5d4 --- /dev/null +++ b/config/packages/sylius_resource.yaml @@ -0,0 +1,15 @@ +# @see https://github.com/Sylius/SyliusResourceBundle/blob/master/docs/index.md +sylius_resource: + # Override default settings + #settings: + + # Configure the mapping for your resources + mapping: + paths: + - '%kernel.project_dir%/app/Entity' + + # Configure your resources + resources: + app.book: + classes: + model: App\Entity\Book diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml new file mode 100644 index 00000000..abb76aae --- /dev/null +++ b/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/config/packages/validator.yaml b/config/packages/validator.yaml new file mode 100644 index 00000000..0201281d --- /dev/null +++ b/config/packages/validator.yaml @@ -0,0 +1,13 @@ +framework: + validation: + email_validation_mode: html5 + + # Enables validator auto-mapping support. + # For instance, basic validation constraints will be inferred from Doctrine's metadata. + #auto_mapping: + # App\Entity\: [] + +when@test: + framework: + validation: + not_compromised_password: false diff --git a/config/packages/zenstruck_foundry.yaml b/config/packages/zenstruck_foundry.yaml new file mode 100644 index 00000000..0657d2c3 --- /dev/null +++ b/config/packages/zenstruck_foundry.yaml @@ -0,0 +1,7 @@ +when@dev: &dev + # See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration + zenstruck_foundry: + # Whether to auto-refresh proxies by default (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#auto-refresh) + auto_refresh_proxies: true + +when@test: *dev diff --git a/config/routes/sylius_resource.yaml b/config/routes/sylius_resource.yaml new file mode 100644 index 00000000..0c8ade2c --- /dev/null +++ b/config/routes/sylius_resource.yaml @@ -0,0 +1,7 @@ +sylius_crud_routes: + resource: 'sylius.routing.loader.crud_routes_attributes' + type: service + +sylius_routes: + resource: 'sylius.routing.loader.routes_attributes' + type: service diff --git a/migrations/.gitignore b/migrations/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/phpunit.xml.dist b/phpunit.xml.dist index bba98a1c..7e9b83d4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -18,9 +18,12 @@ + + src/TwigExtra/tests + + src/TwigHooks/tests - diff --git a/src/AdminUi/composer.json b/src/AdminUi/composer.json new file mode 100644 index 00000000..7664cabf --- /dev/null +++ b/src/AdminUi/composer.json @@ -0,0 +1,14 @@ +{ + "name": "sylius/admin-ui", + "autoload": { + "psr-4": { + "Sylius\\AdminUi\\": "src/" + } + }, + "require": { + "php": "^8.1", + "knplabs/knp-menu-bundle": "^3.0", + "sylius/twig-hooks": "^0.2", + "symfony/http-kernel": "^6.4" + } +} diff --git a/src/AdminUi/config/services.php b/src/AdminUi/config/services.php new file mode 100644 index 00000000..c4949936 --- /dev/null +++ b/src/AdminUi/config/services.php @@ -0,0 +1,36 @@ +services(); + + $services->set('sylius_admin_ui.menu_builder', MenuBuilder::class) + ->args([service('knp_menu.factory')]) + ->tag(name: 'knp_menu.menu_builder', attributes: ['method' => 'createMenu', 'alias' => 'sylius.ui.menu.admin']) + ; + $services->alias(MenuBuilderInterface::class, 'sylius_admin_ui.menu_builder'); + + $services->set('sylius_admin_ui.factory.hookable_metadata', RoutingHookableMetadataFactory::class) + ->decorate('twig_hooks.factory.hookable_metadata') + ->args([ + service('.inner'), + param('sylius_admin_ui.routing') + ]) + ; +}; diff --git a/src/AdminUi/src/Menu/MenuBuilder.php b/src/AdminUi/src/Menu/MenuBuilder.php new file mode 100644 index 00000000..bb860070 --- /dev/null +++ b/src/AdminUi/src/Menu/MenuBuilder.php @@ -0,0 +1,29 @@ +factory->createItem('root'); + } +} diff --git a/src/AdminUi/src/Menu/MenuBuilderInterface.php b/src/AdminUi/src/Menu/MenuBuilderInterface.php new file mode 100644 index 00000000..73ef9969 --- /dev/null +++ b/src/AdminUi/src/Menu/MenuBuilderInterface.php @@ -0,0 +1,21 @@ +path) { + $reflected = new \ReflectionObject($this); + $this->path = \dirname($reflected->getFileName(), 3); + } + + return $this->path; + } + + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->import('../../config/services.php'); + + $builder->setParameter('sylius_admin_ui.routing', $config['routing']); + } + + public function configure(DefinitionConfigurator $definition): void + { + $definition->rootNode() + ->children() + ->arrayNode('routing') + ->prototype('scalar')->end() + ->end() + ->end() + ; + } + +} diff --git a/src/AdminUi/src/TwigHooks/Hookable/Metadata/RoutingHookableMetadataFactory.php b/src/AdminUi/src/TwigHooks/Hookable/Metadata/RoutingHookableMetadataFactory.php new file mode 100644 index 00000000..3db47bdc --- /dev/null +++ b/src/AdminUi/src/TwigHooks/Hookable/Metadata/RoutingHookableMetadataFactory.php @@ -0,0 +1,27 @@ +routing; + + return $this->hookableMetadataFactory->create($hookMetadata, $context, $configuration, $prefixes); + } +} diff --git a/src/AdminUi/templates/crud/create.html.twig b/src/AdminUi/templates/crud/create.html.twig new file mode 100644 index 00000000..5a9e3524 --- /dev/null +++ b/src/AdminUi/templates/crud/create.html.twig @@ -0,0 +1,26 @@ +{% extends '@SyliusAdminUi/layout/base.html.twig' %} + +{% set event_name = metadata.applicationName ~ '.admin.' ~ 'create.body' %} + +{% set prefixes = [ + 'sylius_admin.%resource_name%'|replace({'%resource_name%': resource_name|default(metadata.name)}), + 'sylius_admin.common' +] %} + +{% set header = configuration.vars.header|default(metadata.applicationName~'.ui.'~metadata.pluralName) %} + +{% block title %}{{ header|trans }} | {{ parent() }}{% endblock %} + +{% block content %} + {% hook 'create' with { _prefixes: prefixes, resource, metadata, configuration, form } %} +{% endblock %} + +{% block stylesheets %} + {{ parent() }} + {% hook 'create#stylesheets' with { _prefixes: prefixes } %} +{% endblock %} + +{% block javascripts %} + {{ parent() }} + {% hook 'create#javascripts' with { _prefixes: prefixes } %} +{% endblock %} diff --git a/src/AdminUi/templates/crud/index.html.twig b/src/AdminUi/templates/crud/index.html.twig new file mode 100644 index 00000000..653fee37 --- /dev/null +++ b/src/AdminUi/templates/crud/index.html.twig @@ -0,0 +1,26 @@ +{% extends '@SyliusAdminUi/layout/base.html.twig' %} + +{% set prefixes = [ + 'sylius_admin.%resource_name%'|replace({'%resource_name%': resource_name|default(metadata.name)}), + 'sylius_admin.common' +] %} + +{% set event_name = metadata.applicationName ~ '.admin.' ~ 'index.body' %} + +{% set header = configuration.vars.header|default(metadata.applicationName~'.ui.'~metadata.pluralName) %} + +{% block title %}{{ header|trans }} {{ parent() }}{% endblock %} + +{% block content %} + {% hook 'index' with { _prefixes: prefixes, metadata, resources } %} +{% endblock %} + +{% block stylesheets %} + {{ parent() }} + {% hook 'index#stylesheets' with { _prefixes: prefixes } %} +{% endblock %} + +{% block javascripts %} + {{ parent() }} + {% hook 'index#javascripts' with { _prefixes: prefixes } %} +{% endblock %} diff --git a/src/AdminUi/templates/crud/update.html.twig b/src/AdminUi/templates/crud/update.html.twig new file mode 100644 index 00000000..30de42df --- /dev/null +++ b/src/AdminUi/templates/crud/update.html.twig @@ -0,0 +1,26 @@ +{% extends '@SyliusAdminUi/layout/base.html.twig' %} + +{% set event_name = metadata.applicationName ~ '.admin.' ~ 'update.body' %} + +{% set prefixes = [ + 'sylius_admin.%resource_name%'|replace({'%resource_name%': resource_name|default(metadata.name)}), + 'sylius_admin.common' +] %} + +{% set header = configuration.vars.header|default(metadata.applicationName~'.ui.'~metadata.pluralName) %} + +{% block title %}{{ header|trans }} | {{ parent() }}{% endblock %} + +{% block content %} + {% hook 'update' with { _prefixes: prefixes, resource, metadata, configuration, form } %} +{% endblock %} + +{% block stylesheets %} + {{ parent() }} + {% hook 'update#stylesheets' with { _prefixes: prefixes } %} +{% endblock %} + +{% block javascripts %} + {{ parent() }} + {% hook 'update#javascripts' with { _prefixes: prefixes } %} +{% endblock %} diff --git a/src/AdminUi/templates/layout/base.html.twig b/src/AdminUi/templates/layout/base.html.twig new file mode 100644 index 00000000..6029e232 --- /dev/null +++ b/src/AdminUi/templates/layout/base.html.twig @@ -0,0 +1,26 @@ +{% set generic_hook = 'sylius_admin.base' %} + + + + + + + + + {% block title %}Sylius{% endblock %} + + {% block metatags %}{% endblock %} + {% block stylesheets %} + {% hook generic_hook ~ '#stylesheets' %} + {% endblock %} + + +
+ {% block content %}{% endblock %} +
+ +{% block javascripts %} + {% hook generic_hook ~ '#javascripts' %} +{% endblock %} + + diff --git a/src/AdminUi/translations/messages.en.yaml b/src/AdminUi/translations/messages.en.yaml new file mode 100644 index 00000000..e66ca794 --- /dev/null +++ b/src/AdminUi/translations/messages.en.yaml @@ -0,0 +1,29 @@ +sylius: + ui: + actions: Actions + are_your_sure_you_want_to_perform_this_action: Are you sure to want to perform this action? + cancel: Cancel + contains: Contains + create: Create + dashboard: Dashboard + delete: Delete + edit: Edit + empty: Empty + ends_with: Ends with + equal: Equal + filter: Filter + filters: Filters + in: In + new: New + not_contains: Not contains + not_empty: Not empty + not_equal: Not equal + not_in: Not in + pagination: + number_of_results: 'Showing %from% to %to% of %total% entries' + reset: Reset + search: Search + show: Show + starts_with: Starts with + update: Update + value: Value diff --git a/src/TwigExtra/composer.json b/src/TwigExtra/composer.json new file mode 100644 index 00000000..221291ce --- /dev/null +++ b/src/TwigExtra/composer.json @@ -0,0 +1,33 @@ +{ + "name": "sylius/twig-extra", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Sylius project", + "homepage": "https://sylius.com" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Sylius/Stack/contributors" + } + ], + "require": { + "php": "^8.1", + "symfony/http-kernel": "^6.4", + "symfony/twig-bundle": "^6.4" + }, + "conflict": { + "sylius/ui-bundle": "<2.0" + }, + "autoload": { + "psr-4": { + "Sylius\\TwigExtra\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\Sylius\\TwigExtra\\": "tests/" + } + } +} diff --git a/src/TwigExtra/config/services.php b/src/TwigExtra/config/services.php new file mode 100644 index 00000000..b3faff13 --- /dev/null +++ b/src/TwigExtra/config/services.php @@ -0,0 +1,33 @@ +services(); + + $services->set('twig_extra.twig.extension.test_form_attribute', TestFormAttributeExtension::class) + ->args([ + param('kernel.environment'), + param('kernel.debug'), + ]) + ->tag(name: 'twig.extension') + ; + + $services->set('twig_extra.twig.extension.test_html_attribute', TestHtmlAttributeExtension::class) + ->args([ + param('kernel.environment'), + param('kernel.debug'), + ]) + ->tag(name: 'twig.extension') + ; + + $services->set('twig_extra.twig.extension.sort_by', SortByExtension::class) + ->tag(name: 'twig.extension') + ; +}; diff --git a/src/TwigExtra/src/Symfony/DependencyInjection/TwigExtraExtension.php b/src/TwigExtra/src/Symfony/DependencyInjection/TwigExtraExtension.php new file mode 100644 index 00000000..33427009 --- /dev/null +++ b/src/TwigExtra/src/Symfony/DependencyInjection/TwigExtraExtension.php @@ -0,0 +1,23 @@ +load('services.php'); + } +} diff --git a/src/TwigExtra/src/Symfony/TwigExtraBundle.php b/src/TwigExtra/src/Symfony/TwigExtraBundle.php new file mode 100644 index 00000000..02ddaa8e --- /dev/null +++ b/src/TwigExtra/src/Symfony/TwigExtraBundle.php @@ -0,0 +1,11 @@ +transformIterableToArray($iterable); + + usort( + $array, + function (array|object $firstElement, array|object $secondElement) use ($field, $order) { + $accessor = PropertyAccess::createPropertyAccessor(); + + $firstProperty = (string) $accessor->getValue($firstElement, $field); + $secondProperty = (string) $accessor->getValue($secondElement, $field); + + $result = strnatcasecmp($firstProperty, $secondProperty); + if ('DESC' === $order) { + $result *= -1; + } + + return $result; + }, + ); + + return $array; + } + + private function transformIterableToArray(iterable $iterable): array + { + if (is_array($iterable)) { + return $iterable; + } + + return iterator_to_array($iterable); + } +} diff --git a/src/TwigExtra/src/Twig/TestFormAttributeExtension.php b/src/TwigExtra/src/Twig/TestFormAttributeExtension.php new file mode 100644 index 00000000..0c2e4c55 --- /dev/null +++ b/src/TwigExtra/src/Twig/TestFormAttributeExtension.php @@ -0,0 +1,35 @@ +environment, 'test')) { + return ['attr' => ['data-test-' . $name => (string) $value]]; + } + + return []; + }, + ['is_safe' => ['html']], + ), + ]; + } +} diff --git a/src/TwigExtra/src/Twig/TestHtmlAttributeExtension.php b/src/TwigExtra/src/Twig/TestHtmlAttributeExtension.php new file mode 100644 index 00000000..2de7c82f --- /dev/null +++ b/src/TwigExtra/src/Twig/TestHtmlAttributeExtension.php @@ -0,0 +1,35 @@ +environment, 'test') || $this->isDebugEnabled) { + return sprintf('data-test-%s="%s"', $name, (string) $value); + } + + return ''; + }, + ['is_safe' => ['html']], + ), + ]; + } +} diff --git a/src/TwigExtra/tests/Functional/Twig/Extension/SortByExtensionTest.php b/src/TwigExtra/tests/Functional/Twig/Extension/SortByExtensionTest.php new file mode 100644 index 00000000..b5b6d84d --- /dev/null +++ b/src/TwigExtra/tests/Functional/Twig/Extension/SortByExtensionTest.php @@ -0,0 +1,21 @@ +bootKernel(); + + $container = $this->getContainer(); + + $this->assertTrue($container->has('twig_extra.twig.extension.sort_by')); + $this->assertInstanceOf(SortByExtension::class, $container->get('twig_extra.twig.extension.sort_by')); + } +} diff --git a/src/TwigExtra/tests/Functional/Twig/Extension/TestFormAttributeExtensionTest.php b/src/TwigExtra/tests/Functional/Twig/Extension/TestFormAttributeExtensionTest.php new file mode 100644 index 00000000..db8c6526 --- /dev/null +++ b/src/TwigExtra/tests/Functional/Twig/Extension/TestFormAttributeExtensionTest.php @@ -0,0 +1,21 @@ +bootKernel(); + + $container = $this->getContainer(); + + $this->assertTrue($container->has('twig_extra.twig.extension.test_form_attribute')); + $this->assertInstanceOf(TestFormAttributeExtension::class, $container->get('twig_extra.twig.extension.test_form_attribute')); + } +} diff --git a/src/TwigExtra/tests/Functional/Twig/Extension/TestHtmlAttributeExtensionTest.php b/src/TwigExtra/tests/Functional/Twig/Extension/TestHtmlAttributeExtensionTest.php new file mode 100644 index 00000000..164078e9 --- /dev/null +++ b/src/TwigExtra/tests/Functional/Twig/Extension/TestHtmlAttributeExtensionTest.php @@ -0,0 +1,21 @@ +bootKernel(); + + $container = $this->getContainer(); + + $this->assertTrue($container->has('twig_extra.twig.extension.test_html_attribute')); + $this->assertInstanceOf(TestHtmlAttributeExtension::class, $container->get('twig_extra.twig.extension.test_html_attribute')); + } +} diff --git a/src/TwigExtra/tests/Unit/Twig/Extension/SortByExtensionTest.php b/src/TwigExtra/tests/Unit/Twig/Extension/SortByExtensionTest.php new file mode 100644 index 00000000..977615cc --- /dev/null +++ b/src/TwigExtra/tests/Unit/Twig/Extension/SortByExtensionTest.php @@ -0,0 +1,155 @@ +assertInstanceOf(ExtensionInterface::class, new SortByExtension()); + } + + public function testItSortsInAscendingOrderByDefault(): void + { + $firstData = (object) ['number' => 3]; + $secondData = (object) ['number' => 5]; + $thirdData = (object) ['number' => 1]; + + $arrayBeforeSorting = [ + $firstData, + $secondData, + $thirdData, + ]; + + $this->assertEquals([ + $thirdData, + $firstData, + $secondData, + ], (new SortByExtension())->sortBy($arrayBeforeSorting, 'number')); + } + + public function testItSortsAnArrayOfObjectsByVariousProperties(): void + { + $firstData = (object) ['number' => 3, 'string' => 'true', 'bizarrelyNamedProperty' => 'banana']; + $secondData = (object) ['number' => 5, 'string' => '123', 'bizarrelyNamedProperty' => 123]; + $thirdData = (object) ['number' => 1, 'string' => 'Alohomora', 'bizarrelyNamedProperty' => null]; + + $arrayBeforeSorting = [ + $firstData, + $secondData, + $thirdData, + ]; + + $this->assertEquals([ + $thirdData, + $firstData, + $secondData, + ], (new SortByExtension())->sortBy($arrayBeforeSorting, 'number')); + + $this->assertEquals([ + $secondData, + $thirdData, + $firstData, + ], (new SortByExtension())->sortBy($arrayBeforeSorting, 'string')); + + $this->assertEquals([ + $thirdData, + $secondData, + $firstData, + ], (new SortByExtension())->sortBy($arrayBeforeSorting, 'bizarrelyNamedProperty')); + } + + public function testItSortsAnArrayOfObjectsInDescendingOrderByAProperty(): void + { + $firstData = (object) ['number' => 3]; + $secondData = (object) ['number' => 5]; + $thirdData = (object) ['number' => 1]; + + $arrayBeforeSorting = [ + $firstData, + $secondData, + $thirdData, + ]; + + $this->assertEquals([ + $secondData, + $firstData, + $thirdData, + ], (new SortByExtension())->sortBy($arrayBeforeSorting, 'number', 'DESC')); + } + + public function testItSortsAnArrayOfObjectsByANestedProperty(): void + { + $firstData = (object) ['data' => (object) ['number' => 3]]; + $secondData = (object) ['data' => (object) ['number' => 5]]; + $thirdData = (object) ['data' => (object) ['number' => 1]]; + + $arrayBeforeSorting = [ + $firstData, + $secondData, + $thirdData, + ]; + + $this->assertEquals([ + $thirdData, + $firstData, + $secondData, + ], (new SortByExtension())->sortBy($arrayBeforeSorting, 'data.number')); + } + + public function testItThrowsAnExceptionIfThePropertyIsNotFoundOnObjects(): void + { + $arrayBeforeSorting = [ + (object) [], + (object) [], + (object) [], + ]; + + $this->expectException(NoSuchPropertyException::class); + + (new SortByExtension())->sortBy($arrayBeforeSorting, 'nonExistingProperty'); + } + + public function testItReturnsInputArrayIfThereIsOnlyOneObjectInside(): void + { + $data = [(object) []]; + + $this->assertEquals($data, (new SortByExtension())->sortBy($data, 'property'), ); + } + + public function testItDoesNothingIfArrayIsEmpty(): void + { + $this->assertEquals([], (new SortByExtension())->sortBy([], 'property')); + } + + public function testItDoesNothingIfCollectionIsEmpty(): void + { + $this->assertEquals([], (new SortByExtension())->sortBy(new ObjectCollection(), 'property')); + } +} + +/** + * @implements \IteratorAggregate + */ +final class ObjectCollection implements \IteratorAggregate +{ + /** @var object[] */ + private array $data; + + public function __construct(object ...$data) + { + $this->data = $data; + } + + public function getIterator(): \Traversable + { + yield from array_values($this->data); + } +} diff --git a/src/TwigExtra/tests/Unit/Twig/Extension/TestFormAttributeExtensionTest.php b/src/TwigExtra/tests/Unit/Twig/Extension/TestFormAttributeExtensionTest.php new file mode 100644 index 00000000..b27784cd --- /dev/null +++ b/src/TwigExtra/tests/Unit/Twig/Extension/TestFormAttributeExtensionTest.php @@ -0,0 +1,59 @@ +assertInstanceOf(ExtensionInterface::class, new TestFormAttributeExtension('dev')); + } + + public function testItContainsATwigFunctionForFormAttributes(): void + { + $twigFunction = (new TestFormAttributeExtension('dev'))->getFunctions()[0]; + + $this->assertEquals('sylius_test_form_attribute', $twigFunction->getName()); + } + + public function testItsTwigFunctionAddsADataTestAttributeForTestEnvironment(): void + { + $twigFunction = (new TestFormAttributeExtension('test'))->getFunctions()[0]; + $callable = $twigFunction->getCallable(); + + $this->assertIsCallable($callable); + $this->assertEquals(['attr' => ['data-test-foo' => '']], ($callable)('foo')); + } + + public function testItsTwigFunctionAddsADataTestAttributeWithValueForTestEnvironment(): void + { + $twigFunction = (new TestFormAttributeExtension('test'))->getFunctions()[0]; + $callable = $twigFunction->getCallable(); + + $this->assertIsCallable($callable); + $this->assertEquals(['attr' => ['data-test-foo' => 'fighters']], ($callable)('foo', 'fighters')); + } + + public function testItsTwigFunctionDoesNothingForProdEnvironment(): void + { + $twigFunction = (new TestFormAttributeExtension('prod'))->getFunctions()[0]; + $callable = $twigFunction->getCallable(); + + $this->assertIsCallable($callable); + $this->assertEquals([], ($callable)('foo')); + } + + public function testItsTwigFunctionIsSafeForHtml(): void + { + $twigFunction = (new TestFormAttributeExtension('dev'))->getFunctions()[0]; + + $this->assertEquals(['html'], $twigFunction->getSafe(new Node())); + } +} diff --git a/src/TwigExtra/tests/Unit/Twig/Extension/TestHtmlAttributeExtensionTest.php b/src/TwigExtra/tests/Unit/Twig/Extension/TestHtmlAttributeExtensionTest.php new file mode 100644 index 00000000..108c79c6 --- /dev/null +++ b/src/TwigExtra/tests/Unit/Twig/Extension/TestHtmlAttributeExtensionTest.php @@ -0,0 +1,68 @@ +assertInstanceOf(ExtensionInterface::class, new TestHtmlAttributeExtension('dev', false)); + } + + public function testItContainsATwigFunctionForHtmlAttributes(): void + { + $twigFunction = (new TestHtmlAttributeExtension('dev', false))->getFunctions()[0]; + + $this->assertEquals('sylius_test_html_attribute', $twigFunction->getName()); + } + + public function testItsTwigFunctionAddsADataTestAttributeForTestEnvironment(): void + { + $twigFunction = (new TestHtmlAttributeExtension('test', false))->getFunctions()[0]; + $callable = $twigFunction->getCallable(); + + $this->assertIsCallable($callable); + $this->assertEquals('data-test-foo=""', ($callable)('foo')); + } + + public function testItsTwigFunctionAddsADataTestAttributeWithValueForTestEnvironment(): void + { + $twigFunction = (new TestHtmlAttributeExtension('test', false))->getFunctions()[0]; + $callable = $twigFunction->getCallable(); + + $this->assertIsCallable($callable); + $this->assertEquals('data-test-foo="fighters"', ($callable)('foo', 'fighters')); + } + + public function testItsTwigFunctionDoesNothingForProdEnvironment(): void + { + $twigFunction = (new TestHtmlAttributeExtension('prod', false))->getFunctions()[0]; + $callable = $twigFunction->getCallable(); + + $this->assertIsCallable($callable); + $this->assertEquals('', ($callable)('foo')); + } + + public function testItsTwigFunctionAddsADataTestAttributeForProdEnvironmentIfDebugIsEnabled(): void + { + $twigFunction = (new TestHtmlAttributeExtension('prod', true))->getFunctions()[0]; + $callable = $twigFunction->getCallable(); + + $this->assertIsCallable($callable); + $this->assertEquals('data-test-foo=""', ($callable)('foo')); + } + + public function testItsTwigFunctionIsSafeForHtml(): void + { + $twigFunction = (new TestHtmlAttributeExtension('dev', false))->getFunctions()[0]; + + $this->assertEquals(['html'], $twigFunction->getSafe(new Node())); + } +} diff --git a/src/TwigHooks/composer.json b/src/TwigHooks/composer.json index fe99e718..a8bc1566 100644 --- a/src/TwigHooks/composer.json +++ b/src/TwigHooks/composer.json @@ -1,7 +1,7 @@ { "name": "sylius/twig-hooks", "description": "Composable Twig layouts", - "type": "project", + "type": "library", "license": "MIT", "authors": [ { diff --git a/symfony.lock b/symfony.lock index bd37c062..d185cf7e 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,4 +1,58 @@ { + "babdev/pagerfanta-bundle": { + "version": "v3.8.0" + }, + "doctrine/annotations": { + "version": "2.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.10", + "ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05" + } + }, + "doctrine/doctrine-bundle": { + "version": "2.12", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.10", + "ref": "c170ded8fc587d6bd670550c43dafcf093762245" + }, + "files": [ + "config/packages/doctrine.yaml", + "src/Entity/.gitignore", + "src/Repository/.gitignore" + ] + }, + "doctrine/doctrine-fixtures-bundle": { + "version": "3.6", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.0", + "ref": "1f5514cfa15b947298df4d771e694e578d4c204d" + }, + "files": [ + "src/DataFixtures/AppFixtures.php" + ] + }, + "doctrine/doctrine-migrations-bundle": { + "version": "3.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.1", + "ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33" + }, + "files": [ + "config/packages/doctrine_migrations.yaml", + "migrations/.gitignore" + ] + }, + "knplabs/knp-menu-bundle": { + "version": "v3.4.2" + }, "phpstan/phpstan": { "version": "1.10", "recipe": { @@ -22,6 +76,19 @@ "tests/bootstrap.php" ] }, + "sylius/resource-bundle": { + "version": "1.10", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.9", + "ref": "ee21c1fc90778f4b01c20e72c320cc34f8839c1e" + }, + "files": [ + "config/packages/sylius_resource.yaml", + "config/routes/sylius_resource.yaml" + ] + }, "symfony/console": { "version": "5.4", "recipe": { @@ -131,6 +198,18 @@ "symfony/ux-twig-component": { "version": "v2.12.0" }, + "symfony/validator": { + "version": "6.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.3", + "ref": "c32cfd98f714894c4f128bb99aa2530c1227603c" + }, + "files": [ + "config/packages/validator.yaml" + ] + }, "symfony/web-profiler-bundle": { "version": "6.3", "recipe": { @@ -143,5 +222,20 @@ "config/packages/web_profiler.yaml", "config/routes/web_profiler.yaml" ] + }, + "winzou/state-machine-bundle": { + "version": "v0.6.2" + }, + "zenstruck/foundry": { + "version": "1.38", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.10", + "ref": "37c2f894cc098ab4c08874b80cccc8e2f8de7976" + }, + "files": [ + "config/packages/zenstruck_foundry.yaml" + ] } } diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml new file mode 100644 index 00000000..1a5cd7db --- /dev/null +++ b/translations/messages.en.yaml @@ -0,0 +1,3 @@ +app: + ui: + books: 'Books'