diff --git a/.github/workflow/ci.yaml b/.github/workflow/ci.yaml new file mode 100644 index 0000000..4ad8dd4 --- /dev/null +++ b/.github/workflow/ci.yaml @@ -0,0 +1,115 @@ +name: 'CI' + +on: + push: + branches: + - master + pull_request: + +jobs: + + lint: + name: 'Lint' + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: 'Checkout' + uses: actions/checkout@v2 + + - name: 'Setup PHP' + uses: shivammathur/setup-php@v2 + with: + coverage: "none" + extensions: "json" + ini-values: "memory_limit=-1" + php-version: "8.0" + + - name: 'Determine composer cache directory' + id: composer-cache + run: echo "::set-output name=directory::$(composer config cache-dir)" + + - name: 'Cache composer dependencies' + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.directory }} + key: 7.4-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: 7.4-composer- + + - name: 'Install dependencies' + id: deps + run: | + echo "::group::composer update" + composer update --no-progress --ansi + echo "::endgroup::" + + echo "::group::install phpunit" + # Required for PhpStan + vendor/bin/simple-phpunit install + echo "::endgroup::" + + - name: 'Composer validate' + if: always() && steps.deps.outcome == 'success' + run: composer validate --strict + + - name: 'PHP CS Fixer' + if: always() && steps.deps.outcome == 'success' + run: vendor/bin/php-cs-fixer fix --dry-run --diff + + - name: 'PhpStan' + if: always() && steps.deps.outcome == 'success' + run: vendor/bin/phpstan analyse + + tests: + name: 'Tests' + runs-on: ubuntu-latest + timeout-minutes: 5 + + strategy: + fail-fast: false # don't cancel other matrix jobs on failure + matrix: + php: [ '7.4', '8.0' ] + + steps: + - name: 'Checkout' + uses: actions/checkout@v2 + + - name: 'Setup PHP' + uses: shivammathur/setup-php@v2 + with: + coverage: "none" + extensions: "json" + ini-values: "memory_limit=-1" + php-version: "${{ matrix.php }}" + + - name: 'Determine composer cache directory' + id: composer-cache + run: echo "::set-output name=directory::$(composer config cache-dir)" + + - name: 'Cache composer dependencies' + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.directory }} + key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ matrix.php }}-composer- + + - name: 'Fixup Composer' + if: matrix.php == 8.0 + run: | + echo "::group::Fixup Composer platform config for third-parties deps not PHP 8 ready yet" + composer config platform.php 7.4.99 + echo "::endgroup::" + + - name: 'Install dependencies' + run: | + echo "::group::composer update" + composer update --no-progress --ansi + echo "::endgroup::" + + echo "::group::install phpunit" + vendor/bin/simple-phpunit install + echo "::endgroup::" + + - name: 'Run tests' + run: vendor/bin/simple-phpunit --testdox + diff --git a/.gitignore b/.gitignore index 3a9875b..feafb2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,13 @@ +###> symfony/phpunit-bridge ### +.phpunit +.phpunit.result.cache +/phpunit.xml +###< symfony/phpunit-bridge ###### + +###> friendsofphp/php-cs-fixer ### +/.php-cs-fixer.php +/.php-cs-fixer.cache +###< friendsofphp/php-cs-fixer ### + /vendor/ composer.lock diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..3a44ec4 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,29 @@ +in([ + __DIR__ +]); + +return (new PhpCsFixer\Config()) + ->setFinder($finder) + ->setCacheFile('.php-cs-fixer.cache') // forward compatibility with 3.x line + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + '@Symfony' => true, + 'strict_param' => true, + 'array_syntax' => ['syntax' => 'short'], + 'concat_space' => ['spacing' => 'one'], + 'declare_strict_types' => true, + 'native_function_invocation' => ['include' => ['@compiler_optimized']], + 'no_superfluous_phpdoc_tags' => true, + 'ordered_imports' => true, + 'phpdoc_annotation_without_dot' => false, + 'phpdoc_order' => true, + 'phpdoc_summary' => false, + 'simplified_null_return' => false, + 'single_line_throw' => false, + 'void_return' => true, + 'yoda_style' => false, + ]) +; diff --git a/DependencyInjection/ElaoAdminThemeExtension.php b/DependencyInjection/ElaoAdminThemeExtension.php index 9b7a914..1564bb0 100644 --- a/DependencyInjection/ElaoAdminThemeExtension.php +++ b/DependencyInjection/ElaoAdminThemeExtension.php @@ -1,17 +1,17 @@ load('services.xml'); diff --git a/ElaoAdminThemeBundle.php b/ElaoAdminThemeBundle.php index 10ff31c..a317f47 100644 --- a/ElaoAdminThemeBundle.php +++ b/ElaoAdminThemeBundle.php @@ -1,5 +1,7 @@ urlGenerator = $urlGenerator; } + /** + * @param array $item + */ public function getMenuPath(array $item): string { if (isset($item['url'])) { + \assert( + \is_string($item['url']), + new \InvalidArgumentException('Option "url" should be a string.') + ); + return $item['url']; } if (isset($item['route'])) { - return $this->urlGenerator->generate($item['route'], isset($item['parameters']) ? $item['parameters'] : []); + \assert( + \is_string($item['route']), + new \InvalidArgumentException('Option "route" should be a string.') + ); + + if (isset($item['parameters'])) { + \assert( + \is_array($item['parameters']), + new \InvalidArgumentException('Option "parameters" should be an array.') + ); + } + + return $this->urlGenerator->generate( + $item['route'], + isset($item['parameters']) ? $item['parameters'] : [] + ); } return '#'; @@ -31,6 +57,8 @@ public function getMenuPath(array $item): string /** * Is the given menu item active? + * + * @param array $item */ public function isActive(array $item): bool { @@ -38,11 +66,18 @@ public function isActive(array $item): bool return (bool) $item['active']; } + if (isset($item['url'])) { + return $item['url'] === $this->getCurrentUrl(); + } + return $this->isCurrentBranch($this->resolve($item, ['branch', 'root', 'route'])) || $this->isCurrentRoot($this->resolve($item, ['root', 'route'])) || $this->isCurrentRoute($this->resolve($item, ['route'])); } + /** + * @param array $item + */ public function isAccessible(array $item): bool { if (isset($item['access'])) { @@ -52,6 +87,12 @@ public function isAccessible(array $item): bool return true; } + /** + * @param array $item + * @param array $keys + * + * @return mixed|null + */ private function resolve(array $item, array $keys) { foreach ($keys as $key) { @@ -65,33 +106,36 @@ private function resolve(array $item, array $keys) public function isCurrentRoot(?string $root): bool { - if (is_null($root)) { + if (\is_null($root)) { return false; } - return $this->getCurrentAttribute('_menu_root') === $root; + return $this->getCurrentAttributeAsString('_menu_root') === $root; } public function isCurrentBranch(?string $branch): bool { - if (is_null($branch)) { + if (\is_null($branch)) { return false; } - return $this->getCurrentAttribute('_menu_branch') === $branch; + return $this->getCurrentAttributeAsString('_menu_branch') === $branch; } public function isCurrentRoute(?string $route): bool { - return $this->getCurrentAttribute('_route') === $route; + return $this->getCurrentAttributeAsString('_route') === $route; } + /** + * @param array $parameters + */ public function addParametersToCurrentUrl(array $parameters, int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string { return $this->urlGenerator->generate( - $this->getCurrentAttribute('_route'), + $this->getCurrentAttributeAsString('_route'), array_merge( - $this->getCurrentAttribute('_route_params'), + $this->getCurrentAttributeAsArray('_route_params', []), $this->getCurrentQuery(), $parameters ), @@ -102,24 +146,63 @@ public function addParametersToCurrentUrl(array $parameters, int $referenceType public function getCurrentUrl(int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string { return $this->urlGenerator->generate( - $this->getCurrentAttribute('_route'), - $this->getCurrentAttribute('_route_params'), + $this->getCurrentAttributeAsString('_route'), + $this->getCurrentAttributeAsArray('_route_params'), $referenceType ); } /** * Get attribute from current request + */ + private function getCurrentAttributeAsString(string $name, string $default = ''): string + { + $request = $this->requestStack->getCurrentRequest(); + + \assert($request instanceof Request, new \RuntimeException('No current request')); + + $value = $request->attributes->get($name, $default); + + \assert( + \is_string($value), + new \InvalidArgumentException('Attribute "$name" should be a string.') + ); + + return $value; + } + + /** + * Get attribute from current request + * + * @param array $default * - * @return string|array + * @return array */ - private function getCurrentAttribute(string $name) + private function getCurrentAttributeAsArray(string $name, array $default = []): array { - return $this->requestStack->getCurrentRequest()->attributes->get($name); + $request = $this->requestStack->getCurrentRequest(); + + \assert($request instanceof Request, new \RuntimeException('No current request')); + + $value = $request->attributes->get($name, $default); + + \assert( + \is_array($value), + new \InvalidArgumentException('Attribute "$name" should be an array.') + ); + + return $value; } + /** + * @return array + */ private function getCurrentQuery(): array { - return $this->requestStack->getCurrentRequest()->query->all(); + $request = $this->requestStack->getCurrentRequest(); + + \assert($request instanceof Request, new \RuntimeException('No current request')); + + return $request->query->all(); } } diff --git a/Tests/BuilderTest.php b/Tests/BuilderTest.php new file mode 100644 index 0000000..b17e37c --- /dev/null +++ b/Tests/BuilderTest.php @@ -0,0 +1,153 @@ +builder = new Builder( + $requestStack = $this->createMock(RequestStack::class), + $urlGenerator = $this->createMock(UrlGeneratorInterface::class) + ); + + $requestStack + ->method('getCurrentRequest') + ->will($this->returnValue($this->getSampleRequest())); + + $urlGenerator + ->method('generate') + ->will($this->returnCallback([$this, 'mockUrlGenerator'])); + } + + public function testGetMenuPath(): void + { + // by Route + $this->assertSame('/user/1', $this->builder->getMenuPath([ + 'route' => 'admin_user_show', + 'parameters' => ['id' => 1], + ])); + + // by Url + $this->assertSame('/user/2', $this->builder->getMenuPath(['url' => '/user/2'])); + + // Default + $this->assertSame('#', $this->builder->getMenuPath([])); + } + + public function testGetCurrentUrl(): void + { + $this->assertSame('/user/1', $this->builder->getCurrentUrl()); + } + + public function testAddParametersToCurrentUrl(): void + { + $this->assertSame('/user/1?page=1&foo=bar', $this->builder->addParametersToCurrentUrl([ + 'page' => 1, + 'foo' => 'bar', + ])); + } + + public function testIsActive(): void + { + // by Active property + $this->assertTrue($this->builder->isActive(['active' => true])); + $this->assertFalse($this->builder->isActive(['active' => false])); + + // by Route + $this->assertTrue($this->builder->isActive(['route' => 'admin_user_show'])); + $this->assertFalse($this->builder->isActive(['route' => 'admin_user_edit'])); + + // by Root + $this->assertTrue($this->builder->isActive(['root' => 'admin'])); + $this->assertFalse($this->builder->isActive(['root' => 'front'])); + + // by Branch + $this->assertTrue($this->builder->isActive(['branch' => 'user'])); + $this->assertFalse($this->builder->isActive(['branch' => 'client'])); + + // by Url + $this->assertTrue($this->builder->isActive(['url' => '/user/1'])); + $this->assertFalse($this->builder->isActive(['url' => '/user/2'])); + } + + public function testIsCurrentRoot(): void + { + $this->assertTrue($this->builder->isCurrentRoot('admin')); + $this->assertFalse($this->builder->isCurrentRoot('front')); + } + + public function testIsCurrentBranch(): void + { + $this->assertTrue($this->builder->isCurrentBranch('user')); + $this->assertFalse($this->builder->isCurrentBranch('client')); + } + + public function testIsCurrentRoute(): void + { + $this->assertTrue($this->builder->isCurrentRoute('admin_user_show')); + $this->assertFalse($this->builder->isCurrentRoute('admin_user_edit')); + } + + public function testIsAccessible(): void + { + // by Access property + $this->assertTrue($this->builder->isAccessible(['access' => true])); + $this->assertFalse($this->builder->isAccessible(['access' => false])); + + // Default + $this->assertTrue($this->builder->isAccessible([])); + } + + /** + * @param array $params + */ + public function mockUrlGenerator(string $route, array $params = []): string + { + $path = ''; + + switch ($route) { + case 'admin_user_show': + $path = "/user/{$params['id']}"; + unset($params['id']); + break; + + default: + throw new \Exception("Route '$route' not found."); + } + + if (\count($params) > 0) { + $path .= '?' . http_build_query($params); + } + + return $path; + } + + private function getSampleRequest(): Request + { + return new Request( + [], // Query + [], // Request + [ + '_route' => 'admin_user_show', + '_route_params' => ['id' => 1], + '_menu_root' => 'admin', + '_menu_branch' => 'user', + ], // Attributes + [], // Cookies + [], // Files + [], // Server + null // Content + ); + } +} diff --git a/Twig/Extensions/MenuExtension.php b/Twig/Extensions/MenuExtension.php index 99f2768..375b2dc 100644 --- a/Twig/Extensions/MenuExtension.php +++ b/Twig/Extensions/MenuExtension.php @@ -1,5 +1,7 @@ add(new LintCommand(new Environment(new FilesystemLoader()))) + ->add(new LintCommand($environment)) ->getApplication() ->setDefaultCommand('lint:twig', true) ->run(); diff --git a/composer.json b/composer.json index 69481ae..ac77932 100644 --- a/composer.json +++ b/composer.json @@ -16,17 +16,25 @@ ], "require": { "php": "^7.4|^8.0", - "twig/twig": "^2.12|^3.0", - "symfony/routing": "~4.0|~5.0", "symfony/config": "~4.0|~5.0", "symfony/dependency-injection": "~4.0|~5.0", - "symfony/http-foundation": "~4.0|~5.0" + "symfony/form": "~4.0|~5.0", + "symfony/framework-bundle": "~4.0|~5.0", + "symfony/http-foundation": "~4.0|~5.0", + "symfony/routing": "~4.0|~5.0", + "twig/twig": "^2.12|^3.0" }, "require-dev": { - "symfony/console": "^5.1", - "symfony/twig-bridge": "^5.1", - "symfony/finder": "^5.1", - "symfony/filesystem": "^5.1" + "symfony/console": "^5.3", + "symfony/twig-bridge": "^5.3", + "symfony/finder": "^5.3", + "symfony/filesystem": "^5.3", + "symfony/phpunit-bridge": "^5.3", + "friendsofphp/php-cs-fixer": "^3.0", + "phpstan/phpstan": "^0.12.94", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-phpunit": "^0.12.21", + "phpstan/phpstan-symfony": "^0.12.41" }, "autoload": { "psr-4": { diff --git a/doc/menus.md b/doc/menus.md new file mode 100644 index 0000000..fbe4997 --- /dev/null +++ b/doc/menus.md @@ -0,0 +1,16 @@ +# Working with menus + +## Main menu + +```php + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + + + + + + + + + + ./Tests/ + + + + + +