diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f2745838..a0e931ab 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,6 +14,7 @@ jobs: fail-fast: false matrix: php: [ "8.1", "8.2", "8.3" ] + postgres: ["14.6"] symfony: [ "^6.4", "^7.0" ] exclude: - php: "8.1" @@ -21,6 +22,7 @@ jobs: name: "PHP ${{ matrix.php }} / Symfony ${{ matrix.symfony }}" env: APP_ENV: test + DATABASE_URL: "pgsql://postgres:postgres@127.0.0.1/sylius_stack?charset=utf8&serverVersion=${{ matrix.postgres }}" steps: - name: "Checkout" uses: actions/checkout@v3 @@ -32,6 +34,15 @@ jobs: tools: symfony coverage: none + - name: Shutdown default MySQL + run: sudo service mysql stop + + - name: Setup PostgreSQL + uses: harmon758/postgresql-action@v1 + with: + postgresql version: "${{ matrix.postgres }}" + postgresql password: "postgres" + - name: "Restrict packages' versions" run: | composer global config --no-plugins allow-plugins.symfony/flex true @@ -70,6 +81,11 @@ jobs: - name: Run PHPStan run: vendor/bin/phpstan analyse + - name: Create Testing database + run: | + bin/console doctrine:database:create + bin/console doctrine:schema:create + - name: Run PHPUnit run: vendor/bin/phpunit diff --git a/app/DataFixtures/AppFixtures.php b/app/DataFixtures/AppFixtures.php index d678395c..bcfb348e 100644 --- a/app/DataFixtures/AppFixtures.php +++ b/app/DataFixtures/AppFixtures.php @@ -1,5 +1,14 @@ [ + 'subheader' => 'app.ui.manage_your_books', + ], + ], +)] class Book implements ResourceInterface { #[ORM\Id] diff --git a/app/Factory/BookFactory.php b/app/Factory/BookFactory.php index bf3550e6..1afe2f9e 100644 --- a/app/Factory/BookFactory.php +++ b/app/Factory/BookFactory.php @@ -1,5 +1,14 @@ orderBy('title') + ->addFilter( + StringFilter::create('search', ['title', 'authorName']) + ->setLabel('sylius.ui.search'), + ) + ->addField( + StringField::create('title') + ->setLabel('Title') + ->setSortable(true), + ) + ->addField( + StringField::create('authorName') + ->setLabel('Author Name') + ->setSortable(true), + ) + ->addActionGroup( + MainActionGroup::create( + CreateAction::create(), + ), + ) + ->addActionGroup( + ItemActionGroup::create( + // ShowAction::create(), + UpdateAction::create(), + DeleteAction::create(), + ), + ) + ->addActionGroup( + BulkActionGroup::create( + DeleteAction::create(), + ), + ) + ; + } + + public function getResourceClass(): string + { + return Book::class; + } +} diff --git a/app/Kernel.php b/app/Kernel.php index 779cd1f2..bb8df17b 100644 --- a/app/Kernel.php +++ b/app/Kernel.php @@ -1,5 +1,16 @@ ['all' => true], Sylius\TwigHooks\TwigHooksBundle::class => ['all' => true], Sylius\TwigExtra\Symfony\TwigExtraBundle::class => ['all' => true], + Sylius\AdminUi\Symfony\SyliusAdminUiBundle::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], @@ -16,4 +17,6 @@ Sylius\Bundle\ResourceBundle\SyliusResourceBundle::class => ['all' => true], Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true], + Sylius\Bundle\GridBundle\SyliusGridBundle::class => ['all' => true], ]; diff --git a/config/packages/sylius_admin_ui.yaml b/config/packages/sylius_admin_ui.yaml new file mode 100644 index 00000000..10faf917 --- /dev/null +++ b/config/packages/sylius_admin_ui.yaml @@ -0,0 +1,3 @@ +sylius_admin_ui: + routing: + #dashboard_path: '/admin' diff --git a/ecs.php b/ecs.php index ff001c32..f05b3cb3 100644 --- a/ecs.php +++ b/ecs.php @@ -7,6 +7,7 @@ return static function (ECSConfig $ecsConfig): void { $ecsConfig->paths([ + __DIR__ . '/app', __DIR__ . '/src', ]); diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7e9b83d4..d2c2ba73 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -18,6 +18,14 @@ + + tests + + + + src/AdminUi/tests + + src/TwigExtra/tests diff --git a/src/AdminUi/composer.json b/src/AdminUi/composer.json new file mode 100644 index 00000000..f8e87bb7 --- /dev/null +++ b/src/AdminUi/composer.json @@ -0,0 +1,20 @@ +{ + "name": "sylius/admin-ui", + "type": "library", + "require": { + "php": "^8.1", + "knplabs/knp-menu-bundle": "^3.0", + "sylius/twig-hooks": "^0.2", + "symfony/http-kernel": "^6.4 || ^7.0" + }, + "autoload": { + "psr-4": { + "Sylius\\AdminUi\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\Sylius\\AdminUi\\": "tests/" + } + } +} diff --git a/src/AdminUi/config/services.php b/src/AdminUi/config/services.php new file mode 100644 index 00000000..5424dfb9 --- /dev/null +++ b/src/AdminUi/config/services.php @@ -0,0 +1,36 @@ +services(); + + $services->set('sylius_admin_ui.knp.menu_builder', MenuBuilder::class) + ->args([service('knp_menu.factory')]) + ->tag(name: 'knp_menu.menu_builder', attributes: ['method' => 'createMenu', 'alias' => 'sylius_admin_ui.menu.sidebar']) + ; + $services->alias(MenuBuilderInterface::class, 'sylius_admin_ui.knp.menu_builder'); + + $services->set('sylius_admin_ui.twig_hooks.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/Knp/Menu/MenuBuilder.php b/src/AdminUi/src/Knp/Menu/MenuBuilder.php new file mode 100644 index 00000000..5e138771 --- /dev/null +++ b/src/AdminUi/src/Knp/Menu/MenuBuilder.php @@ -0,0 +1,29 @@ +factory->createItem('root'); + } +} diff --git a/src/AdminUi/src/Knp/Menu/MenuBuilderInterface.php b/src/AdminUi/src/Knp/Menu/MenuBuilderInterface.php new file mode 100644 index 00000000..21fe5a45 --- /dev/null +++ b/src/AdminUi/src/Knp/Menu/MenuBuilderInterface.php @@ -0,0 +1,21 @@ +getRootNode(); + + $this->addRoutingConfiguration($rootNode); + + return $treeBuilder; + } + + private function addRoutingConfiguration(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('routing') + ->useAttributeAsKey('name') + ->validate() + ->always(static function ($values): array { + foreach ($values as $path) { + if (!is_string($path)) { + throw new \InvalidArgumentException(sprintf('Path must be a string. "%s" given.', get_debug_type($path))); + } + } + + return $values; + }) + ->end() + ->scalarPrototype()->end() + ->end() + ->end() + ; + } +} diff --git a/src/AdminUi/src/Symfony/DependencyInjection/SyliusAdminUiExtension.php b/src/AdminUi/src/Symfony/DependencyInjection/SyliusAdminUiExtension.php new file mode 100644 index 00000000..340f2685 --- /dev/null +++ b/src/AdminUi/src/Symfony/DependencyInjection/SyliusAdminUiExtension.php @@ -0,0 +1,37 @@ +load('services.php'); + + $configuration = $this->getConfiguration([], $container); + $config = $this->processConfiguration($configuration, $configs); + + $container->setParameter('sylius_admin_ui.routing', $config['routing']); + } +} diff --git a/src/AdminUi/src/Symfony/SyliusAdminUiBundle.php b/src/AdminUi/src/Symfony/SyliusAdminUiBundle.php new file mode 100644 index 00000000..41b2dbd7 --- /dev/null +++ b/src/AdminUi/src/Symfony/SyliusAdminUiBundle.php @@ -0,0 +1,29 @@ +path)) { + $reflected = new \ReflectionObject($this); + $this->path = \dirname($reflected->getFileName(), 3); + } + + return $this->path; + } +} 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..51202ed7 --- /dev/null +++ b/src/AdminUi/src/TwigHooks/Hookable/Metadata/RoutingHookableMetadataFactory.php @@ -0,0 +1,36 @@ +routing; + + return $this->hookableMetadataFactory->create($hookMetadata, $context, $configuration, $prefixes); + } +} diff --git a/src/AdminUi/templates/base.html.twig b/src/AdminUi/templates/base.html.twig new file mode 100644 index 00000000..6029e232 --- /dev/null +++ b/src/AdminUi/templates/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/templates/crud/create.html.twig b/src/AdminUi/templates/crud/create.html.twig new file mode 100644 index 00000000..da5b1da9 --- /dev/null +++ b/src/AdminUi/templates/crud/create.html.twig @@ -0,0 +1,24 @@ +{% extends '@SyliusAdminUi/base.html.twig' %} + +{% 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..de1b8582 --- /dev/null +++ b/src/AdminUi/templates/crud/index.html.twig @@ -0,0 +1,24 @@ +{% extends '@SyliusAdminUi/base.html.twig' %} + +{% 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 '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..b85cb157 --- /dev/null +++ b/src/AdminUi/templates/crud/update.html.twig @@ -0,0 +1,24 @@ +{% extends '@SyliusAdminUi/base.html.twig' %} + +{% 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/tests/Integration/DependencyInjection/SyliusAdminUiExtensionTest.php b/src/AdminUi/tests/Integration/DependencyInjection/SyliusAdminUiExtensionTest.php new file mode 100644 index 00000000..4b4aca4b --- /dev/null +++ b/src/AdminUi/tests/Integration/DependencyInjection/SyliusAdminUiExtensionTest.php @@ -0,0 +1,109 @@ +load(); + + $this->assertContainerBuilderHasService('sylius_admin_ui.knp.menu_builder', MenuBuilder::class); + + $this->assertContainerBuilderHasServiceDefinitionWithTag( + 'sylius_admin_ui.knp.menu_builder', + 'knp_menu.menu_builder', + [ + 'method' => 'createMenu', + 'alias' => 'sylius_admin_ui.menu.sidebar', + ], + ) + ; + + $this->assertContainerBuilderHasAlias(MenuBuilderInterface::class, 'sylius_admin_ui.knp.menu_builder'); + } + + public function testItRegistersTwigHooksFactoryHookableMetadata(): void + { + $this->load(); + + $this->assertContainerBuilderHasService('sylius_admin_ui.twig_hooks.factory.hookable_metadata', RoutingHookableMetadataFactory::class); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument( + 'sylius_admin_ui.twig_hooks.factory.hookable_metadata', + 0, + new Reference('.inner'), + ); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument( + 'sylius_admin_ui.twig_hooks.factory.hookable_metadata', + 1, + '%sylius_admin_ui.routing%', + ); + } + + public function testItRegistersRoutingParameter(): void + { + $this->load([ + 'routing' => [ + 'login_path' => '/login', + 'logout_path' => '/logout', + 'dashboard_path' => '/admin', + ], + ]); + + $this->assertContainerBuilderHasParameter('sylius_admin_ui.routing', [ + 'login_path' => '/login', + 'logout_path' => '/logout', + 'dashboard_path' => '/admin', + ]); + } + + public function testItThrowsAnErrorWhenTryingToRegisterNonStringRoutingPath(): void + { + $this->assertConfigurationIsInvalid( + [ + [ + 'routing' => [ + 'login_path' => false, + ], + ], + ], + 'Invalid configuration for path "sylius_admin_ui.routing": Path must be a string. "bool" given.', + ); + } + + protected function getContainerExtensions(): array + { + return [ + new SyliusAdminUiExtension(), + ]; + } + + protected function getConfiguration(): Configuration + { + return new Configuration(); + } +} diff --git a/symfony.lock b/symfony.lock index 2e182283..ef25e4b1 100644 --- a/symfony.lock +++ b/symfony.lock @@ -37,6 +37,9 @@ "src/DataFixtures/AppFixtures.php" ] }, + "knplabs/knp-menu-bundle": { + "version": "v3.4.2" + }, "phpstan/phpstan": { "version": "1.10", "recipe": { @@ -72,6 +75,9 @@ "phpcs.xml.dist" ] }, + "sylius/grid-bundle": { + "version": "dev-symfony-7" + }, "sylius/resource-bundle": { "version": "1.10", "recipe": { diff --git a/tests/Functional/BookTest.php b/tests/Functional/BookTest.php new file mode 100644 index 00000000..52715b32 --- /dev/null +++ b/tests/Functional/BookTest.php @@ -0,0 +1,60 @@ +client = self::createClient(); + } + + public function testBrowsingBooks(): void + { + BookFactory::new() + ->withTitle('Shinning') + ->withAuthorName('Stephen King') + ->create() + ; + + BookFactory::new() + ->withTitle('Carrie') + ->withAuthorName('Stephen King') + ->create() + ; + + $this->client->request('GET', '/admin/books'); + + self::assertResponseIsSuccessful(); + } + + public function testAddingBookContent(): void + { + $this->client->request('GET', '/admin/books/new'); + + self::assertResponseIsSuccessful(); + } + + public function testEditingBookContent(): void + { + $book = BookFactory::new() + ->withTitle('Shinning') + ->withAuthorName('Stephen King') + ->create(); + + $this->client->request('GET', sprintf('/admin/books/%s/edit', $book->getId())); + + self::assertResponseIsSuccessful(); + } +} diff --git a/tests/Functional/LegacyBookTest.php b/tests/Functional/LegacyBookTest.php new file mode 100644 index 00000000..0cb3a1b2 --- /dev/null +++ b/tests/Functional/LegacyBookTest.php @@ -0,0 +1,60 @@ +client = self::createClient(); + } + + public function testBrowsingBooks(): void + { + BookFactory::new() + ->withTitle('Shinning') + ->withAuthorName('Stephen King') + ->create() + ; + + BookFactory::new() + ->withTitle('Carrie') + ->withAuthorName('Stephen King') + ->create() + ; + + $this->client->request('GET', '/admin/legacy/books/'); + + self::assertResponseIsSuccessful(); + } + + public function testAddingBookContent(): void + { + $this->client->request('GET', '/admin/legacy/books/new'); + + self::assertResponseIsSuccessful(); + } + + public function testEditingBookContent(): void + { + $book = BookFactory::new() + ->withTitle('Shinning') + ->withAuthorName('Stephen King') + ->create(); + + $this->client->request('GET', sprintf('/admin/legacy/books/%s/edit', $book->getId())); + + self::assertResponseIsSuccessful(); + } +} diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml new file mode 100644 index 00000000..7b1032a3 --- /dev/null +++ b/translations/messages.en.yaml @@ -0,0 +1,3 @@ +app: + ui: + books: Books