diff --git a/composer.json b/composer.json index 45984ece..8732d617 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "symfony/config": "^5.0|^6.0", "symfony/console": "^5.0|^6.0", "symfony/dependency-injection": "^5.0|^6.0", + "symfony/form": "^5.0|^6.0", "symfony/http-kernel": "^5.0|^6.0", "symfony/framework-bundle": "^5.0|^6.0", "symfony/messenger": "^5.0|^6.0", @@ -39,9 +40,15 @@ "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "^1.4", "phpunit/phpunit": "^9.5", + "sonata-project/admin-bundle": "^4.0", + "symfony/browser-kit": "^5.0|^6.0", + "symfony/css-selector": "^5.0|^6.0", "symfony/filesystem": "^5.0|^6.0", "symfony/finder": "^5.0|^6.0", "symfony/process": "^5.0|^6.0", + "symfony/security-bundle": "^5.0|^6.0", + "symfony/translation": "^5.0|^6.0", + "symfony/twig-bundle": "^5.0|^6.0", "symplify/easy-coding-standard": "^11.3" }, "replace": { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8812407e..40dd838c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -161,44 +161,9 @@ parameters: path: src/batch-openspout/src/Writer/FlatFileWriter.php - - message: "#^Cannot access offset 'connection' on mixed\\.$#" + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" count: 1 - path: src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php - - - - message: "#^Cannot access offset 'dir' on mixed\\.$#" - count: 1 - path: src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php - - - - message: "#^Cannot access offset 'serializer' on mixed\\.$#" - count: 1 - path: src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php - - - - message: "#^Cannot access offset 'table' on mixed\\.$#" - count: 1 - path: src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php - - - - message: "#^Parameter \\#1 \\$id of class Symfony\\\\Component\\\\DependencyInjection\\\\Reference constructor expects string, mixed given\\.$#" - count: 1 - path: src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php - - - - message: "#^Parameter \\#1 \\$id of method Symfony\\\\Component\\\\DependencyInjection\\\\ContainerBuilder\\:\\:getDefinition\\(\\) expects string, mixed given\\.$#" - count: 1 - path: src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php - - - - message: "#^Parameter \\#2 \\$id of method Symfony\\\\Component\\\\DependencyInjection\\\\ContainerBuilder\\:\\:setAlias\\(\\) expects string\\|Symfony\\\\Component\\\\DependencyInjection\\\\Alias, mixed given\\.$#" - count: 1 - path: src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php - - - - message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" - count: 3 - path: src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php + path: src/batch-symfony-framework/src/DependencyInjection/Configuration.php - message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" diff --git a/src/batch-symfony-framework/README.md b/src/batch-symfony-framework/README.md index ce808352..be98c2ee 100644 --- a/src/batch-symfony-framework/README.md +++ b/src/batch-symfony-framework/README.md @@ -25,6 +25,7 @@ composer require yokai/batch-symfony-framework This package provides: - [integration](docs/getting-started.md) with Symfony framework +- a [UI](docs/ui.md) with Symfony framework ## Contribution diff --git a/src/batch-symfony-framework/composer.json b/src/batch-symfony-framework/composer.json index f0605791..cd6ed56c 100644 --- a/src/batch-symfony-framework/composer.json +++ b/src/batch-symfony-framework/composer.json @@ -25,8 +25,21 @@ } }, "require-dev": { + "sonata-project/admin-bundle": "^4.0", + "symfony/filesystem": "^5.0|^6.0", + "symfony/form": "^5.0|^6.0", + "symfony/security-bundle": "^5.0|^6.0", + "symfony/translation": "^5.0|^6.0", + "symfony/twig-bundle": "^5.0|^6.0", "phpunit/phpunit": "^9.5" }, + "suggest": { + "sonata-project/admin-bundle": "If you want a SonataAdmin like rendering in the user interface", + "symfony/form": "If you want the JobExecution form filter in the user interface", + "symfony/security-bundle": "If you want to secure the access to JobExecution in the user interface", + "symfony/translation": "Required if you want to enable the user interface", + "symfony/twig-bundle": "Required if you want to enable the user interface" + }, "autoload-dev": { "psr-4": { "Yokai\\Batch\\Tests\\Bridge\\Symfony\\Framework\\": "tests/" diff --git a/src/batch-symfony-framework/docs/images/bootstrap4-children.png b/src/batch-symfony-framework/docs/images/bootstrap4-children.png new file mode 100644 index 00000000..9dada1e6 Binary files /dev/null and b/src/batch-symfony-framework/docs/images/bootstrap4-children.png differ diff --git a/src/batch-symfony-framework/docs/images/bootstrap4-details.png b/src/batch-symfony-framework/docs/images/bootstrap4-details.png new file mode 100644 index 00000000..7515d252 Binary files /dev/null and b/src/batch-symfony-framework/docs/images/bootstrap4-details.png differ diff --git a/src/batch-symfony-framework/docs/images/bootstrap4-list.png b/src/batch-symfony-framework/docs/images/bootstrap4-list.png new file mode 100644 index 00000000..5a34f601 Binary files /dev/null and b/src/batch-symfony-framework/docs/images/bootstrap4-list.png differ diff --git a/src/batch-symfony-framework/docs/images/bootstrap4-warnings.png b/src/batch-symfony-framework/docs/images/bootstrap4-warnings.png new file mode 100644 index 00000000..7eaee689 Binary files /dev/null and b/src/batch-symfony-framework/docs/images/bootstrap4-warnings.png differ diff --git a/src/batch-symfony-framework/docs/images/sonata-children.png b/src/batch-symfony-framework/docs/images/sonata-children.png new file mode 100644 index 00000000..474ce303 Binary files /dev/null and b/src/batch-symfony-framework/docs/images/sonata-children.png differ diff --git a/src/batch-symfony-framework/docs/images/sonata-details.png b/src/batch-symfony-framework/docs/images/sonata-details.png new file mode 100644 index 00000000..3179e4e7 Binary files /dev/null and b/src/batch-symfony-framework/docs/images/sonata-details.png differ diff --git a/src/batch-symfony-framework/docs/images/sonata-list.png b/src/batch-symfony-framework/docs/images/sonata-list.png new file mode 100644 index 00000000..90f8fca8 Binary files /dev/null and b/src/batch-symfony-framework/docs/images/sonata-list.png differ diff --git a/src/batch-symfony-framework/docs/images/sonata-warnings.png b/src/batch-symfony-framework/docs/images/sonata-warnings.png new file mode 100644 index 00000000..4a585e4b Binary files /dev/null and b/src/batch-symfony-framework/docs/images/sonata-warnings.png differ diff --git a/src/batch-symfony-framework/docs/ui.md b/src/batch-symfony-framework/docs/ui.md new file mode 100644 index 00000000..47cd3d35 --- /dev/null +++ b/src/batch-symfony-framework/docs/ui.md @@ -0,0 +1,196 @@ +# User Interface + +The package is shipped with few routes that will allow you and your users, to watch for `JobExecution`. + +Bootstrap 4 - List action Bootstrap 4 - Detail : Information Bootstrap 4 - Detail : Children Bootstrap 4 - Detail : Warnings + + +## Installation + +For the UI to be enabled, it is required that you install some dependencies: +```shell +composer require symfony/translation symfony/twig-bundle +``` + + +## Configuration + +The UI is disabled by default, you must enable it explicitely: +```yaml +# config/packages/yokai_batch.yaml +yokai_batch: + ui: + enabled: true +``` + +You will also need to import bundle routes: +```yaml +# config/routes/yokai_batch.yaml +_yokai_batch: + resource: "@YokaiBatchBundle/Resources/routing/ui.xml" +``` + +### Templating + +The templating service is used by the [JobController](../src/UserInterface/Controller/JobController.php) to render its templates. +It's a wrapper around [Twig](https://twig.symfony.com/), for you to control templates used, and variables passed. + +> By default +> - the templating will find templates like `@YokaiBatch/bootstrap4/*.html.twig` +> - the template base view will be `base.html.twig` + +You can configure a prefix for all templates: +```yaml +# config/packages/yokai_batch.yaml +yokai_batch: + ui: + templating: + prefix: 'batch/job/' +``` +> With this configuration, we will look for templates like `batch/job/*.html.twig`. + +You can also configure the name of the base template for the root views of that bundle: +```yaml +# config/packages/yokai_batch.yaml +yokai_batch: + ui: + templating: + base_template: 'layout.html.twig' +``` +> With this configuration, the template base view will be `layout.html.twig`. + +If these are not enough, or if you need to add more variables to context, you can configure a service: +```yaml +# config/packages/yokai_batch.yaml +yokai_batch: + ui: + templating: + service: 'App\Batch\AppTemplating' +``` + +And create the class that will cover the templating: +```php + 'bar']); // add variables to $context if you want + } +} +``` + +> **Note** You can also use the `Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\ConfigurableTemplating` that will cover both prefix and static variables at construction. + + +### Filtering + +The `JobExecution` list includes a filter form, but you will need another optional dependency: +```shell +composer require symfony/form +``` + +### Security + +There is no access control over `JobExecution` by default, you will need another optional dependency: +```shell +composer require symfony/security-bundle +``` + +Every security attribute the bundle is using is configurable: +```yaml +# config/packages/yokai_batch.yaml +yokai_batch: + ui: + security: + attributes: + list: ROLE_JOB_LIST # defaults to IS_AUTHENTICATED + view: ROLE_JOB_VIEW # defaults to IS_AUTHENTICATED + traces: ROLE_JOB_TRACES # defaults to IS_AUTHENTICATED + logs: ROLE_JOB_LOGS # defaults to IS_AUTHENTICATED +``` + +Optionally, you can register a voter for these attributes. +This is especially useful if you need different access control rules per `JobExecution`. +```php + Sonata - Detail : Information Sonata - Detail : Children Sonata - Detail : Warnings + +```shell +composer require sonata-project/admin-bundle +``` + +```yaml +# config/packages/yokai_batch.yaml +yokai_batch: + ui: + templating: sonata +``` +> With this configuration, we will look for templates like `@YokaiBatch/sonata/*.html.twig`. + + +## Customizing templates + +You can override templates like [described it Symfony's documentation](https://symfony.com/doc/current/bundles/override.html). +Examples: +- `templates/bundles/YokaiBatchBundle/bootstrap4/list.html.twig` +- `templates/bundles/YokaiBatchBundle/bootstrap4/show/_parameters.html.twig` + +But you can also register job name dedicated templates if you need some specific view for one of your jobs: +- `templates/bundles/YokaiBatchBundle/bootstrap4/show/{job name}/_children-executions.html.twig` +- `templates/bundles/YokaiBatchBundle/bootstrap4/show/{job name}/_failures.html.twig` +- `templates/bundles/YokaiBatchBundle/bootstrap4/show/{job name}/_general.html.twig` +- `templates/bundles/YokaiBatchBundle/bootstrap4/show/{job name}/_information.html.twig` +- `templates/bundles/YokaiBatchBundle/bootstrap4/show/{job name}/_parameters.html.twig` +- `templates/bundles/YokaiBatchBundle/bootstrap4/show/{job name}/_summary.html.twig` +- `templates/bundles/YokaiBatchBundle/bootstrap4/show/{job name}/_warnings.html.twig` + +## On the same subject + +- [What is a job execution storage ?](https://github.com/yokai-php/batch/blob/0.x/docs/domain/job-execution-storage.md) +- [What is a job ?](https://github.com/yokai-php/batch/blob/0.x/docs/domain/job.md) +- [What is a job launcher ?](https://github.com/yokai-php/batch/blob/0.x/docs/domain/job-launcher.md) diff --git a/src/batch-symfony-framework/src/DependencyInjection/CompilerPass/RegisterJobsCompilerPass.php b/src/batch-symfony-framework/src/DependencyInjection/CompilerPass/RegisterJobsCompilerPass.php index 24a35ab0..df965da0 100644 --- a/src/batch-symfony-framework/src/DependencyInjection/CompilerPass/RegisterJobsCompilerPass.php +++ b/src/batch-symfony-framework/src/DependencyInjection/CompilerPass/RegisterJobsCompilerPass.php @@ -30,6 +30,11 @@ public function process(ContainerBuilder $container): void $container->getDefinition('yokai_batch.job_registry') ->setArgument('$jobs', ServiceLocatorTagPass::register($container, $jobs)); + + if ($container->hasDefinition('yokai_batch.ui.filter_form')) { + $container->getDefinition('yokai_batch.ui.filter_form') + ->setArgument('$jobs', \array_keys($jobs)); + } } /** diff --git a/src/batch-symfony-framework/src/DependencyInjection/Configuration.php b/src/batch-symfony-framework/src/DependencyInjection/Configuration.php index aa67158a..7b9a5191 100644 --- a/src/batch-symfony-framework/src/DependencyInjection/Configuration.php +++ b/src/batch-symfony-framework/src/DependencyInjection/Configuration.php @@ -10,6 +10,38 @@ /** * Configuration for yokai/batch Symfony Bundle. + * + * @phpstan-type Config array{ + * storage: StorageConfig, + * ui: UserInterfaceConfig, + * } + * @phpstan-type StorageConfig array{ + * service?: string, + * dbal?: array{ + * connection: string, + * table: string, + * }, + * filesystem: array{ + * serializer: string, + * dir: string, + * }, + * } + * @phpstan-type UserInterfaceConfig array{ + * enabled: bool, + * security: array{ + * attributes: array{ + * list: string, + * view: string, + * traces: string, + * logs: string, + * }, + * }, + * templating: array{ + * prefix: string|null, + * service: string|null, + * base_template: string|null, + * }, + * } */ final class Configuration implements ConfigurationInterface { @@ -21,6 +53,7 @@ public function getConfigTreeBuilder(): TreeBuilder $root ->children() ->append($this->storage()) + ->append($this->ui()) ->end() ; @@ -63,4 +96,74 @@ private function storage(): ArrayNodeDefinition return $node; } + + private function ui(): ArrayNodeDefinition + { + /** @var ArrayNodeDefinition $node */ + $node = (new TreeBuilder('ui'))->getRootNode(); + + $node + ->addDefaultsIfNotSet() + ->canBeEnabled() + ->children() + ->arrayNode('templating') + ->addDefaultsIfNotSet() + ->beforeNormalization() + ->always(function (string|array $value) { + if (is_string($value)) { + $value = match ($value) { + 'bootstrap4' => ['prefix' => '@YokaiBatch/bootstrap4', 'service' => null], + 'sonata' => ['service' => 'yokai_batch.ui.sonata_templating', 'prefix' => null], + default => throw new \InvalidArgumentException( + \sprintf('Unknown templating shortcut "%s".', $value), + ), + }; + } + + if (!isset($value['service']) && !isset($value['prefix'])) { + throw new \InvalidArgumentException( + 'You must either configure "service" or "prefix".', + ); + } elseif (isset($value['service']) && isset($value['prefix'])) { + throw new \InvalidArgumentException( + 'You cannot configure "service" and "prefix" at the same time.', + ); + } + + return $value; + }) + ->end() + ->children() + ->scalarNode('service')->defaultNull()->end() + ->scalarNode('prefix')->defaultValue('@YokaiBatch/bootstrap4')->end() + ->scalarNode('base_template')->defaultValue('base.html.twig')->end() + ->end() + ->end() + ->arrayNode('security') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('attributes') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('list') + ->defaultValue('IS_AUTHENTICATED') + ->end() + ->scalarNode('view') + ->defaultValue('IS_AUTHENTICATED') + ->end() + ->scalarNode('traces') + ->defaultValue('IS_AUTHENTICATED') + ->end() + ->scalarNode('logs') + ->defaultValue('IS_AUTHENTICATED') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + + return $node; + } } diff --git a/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php b/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php index 27ad2787..d5bd9af5 100644 --- a/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php +++ b/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php @@ -5,15 +5,22 @@ namespace Yokai\Batch\Bridge\Symfony\Framework\DependencyInjection; use Composer\InstalledVersions; +use Sonata\AdminBundle\Templating\TemplateRegistryInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader as ConfigLoader; +use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\DependencyInjection\Loader as DependencyInjectionLoader; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Form\AbstractType; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Yokai\Batch\Bridge\Doctrine\DBAL\DoctrineDBALJobExecutionStorage; +use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Form\JobFilterType; +use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\ConfigurableTemplating; +use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\SonataAdminTemplating; +use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\TemplatingInterface; use Yokai\Batch\Launcher\JobLauncherInterface; use Yokai\Batch\Storage\FilesystemJobExecutionStorage; use Yokai\Batch\Storage\JobExecutionStorageInterface; @@ -22,6 +29,10 @@ /** * Dependency injection extension for yokai/batch Symfony Bundle. + * + * @phpstan-import-type Config from Configuration + * @phpstan-import-type StorageConfig from Configuration + * @phpstan-import-type UserInterfaceConfig from Configuration */ final class YokaiBatchExtension extends Extension { @@ -31,6 +42,7 @@ final class YokaiBatchExtension extends Extension public function load(array $configs, ContainerBuilder $container): void { $configuration = new Configuration(); + /** @var Config $config */ $config = $this->processConfiguration($configuration, $configs); $loader = $this->getLoader($container); @@ -49,6 +61,7 @@ public function load(array $configs, ContainerBuilder $container): void } $this->configureStorage($container, $config['storage']); + $this->configureUserInterface($container, $loader, $config['ui']); $launchers = [ 'yokai_batch.job_launcher.dispatch_message' => $this->installed('symfony-messenger'), @@ -66,7 +79,7 @@ private function installed(string $package): bool || InstalledVersions::isInstalled('yokai/batch-' . $package); } - private function getLoader(ContainerBuilder $container): ConfigLoader\LoaderInterface + private function getLoader(ContainerBuilder $container): LoaderInterface { $locator = new FileLocator(__DIR__ . '/../Resources/services'); $resolver = new ConfigLoader\LoaderResolver( @@ -80,7 +93,7 @@ private function getLoader(ContainerBuilder $container): ConfigLoader\LoaderInte } /** - * @param array $config + * @param StorageConfig $config */ private function configureStorage(ContainerBuilder $container, array $config): void { @@ -152,4 +165,60 @@ private function configureStorage(ContainerBuilder $container, array $config): v ; } } + + /** + * @param UserInterfaceConfig $config + */ + private function configureUserInterface(ContainerBuilder $container, LoaderInterface $loader, array $config): void + { + if (!$config['enabled']) { + return; + } + + $loader->load('ui.xml'); + + if (\class_exists(AbstractType::class)) { + $container->register('yokai_batch.ui.filter_form', JobFilterType::class) + ->addTag('form.type'); + } + if (\interface_exists(TemplateRegistryInterface::class)) { + $container->register('yokai_batch.ui.sonata_templating', SonataAdminTemplating::class) + ->addArgument(new Reference('sonata.admin.global_template_registry')); + } + + $attributes = $config['security']['attributes']; + $container->setParameter('yokai_batch.ui.security_list_attribute', $attributes['list']); + $container->setParameter('yokai_batch.ui.security_view_attribute', $attributes['view']); + $container->setParameter('yokai_batch.ui.security_traces_attribute', $attributes['traces']); + $container->setParameter('yokai_batch.ui.security_logs_attribute', $attributes['logs']); + + $templating = $config['templating']; + if ($templating['service'] !== null) { + try { + $templatingClass = $container->getDefinition($templating['service'])->getClass(); + if ($templatingClass === null || !\is_a($templatingClass, TemplatingInterface::class, true)) { + throw new LogicException( + \sprintf( + 'Configured UI templating service "%s" must implements interface "%s".', + $templating['service'], + TemplatingInterface::class, + ), + ); + } + } catch (ServiceNotFoundException $exception) { + throw new LogicException( + sprintf('Configured UI templating service "%s" does not exists.', $templating['service']), + 0, + $exception + ); + } + + $container->setAlias(TemplatingInterface::class, $templating['service']); + } elseif ($templating['prefix'] !== null) { + $container->register('yokai_batch.ui.templating', ConfigurableTemplating::class) + ->addArgument($templating['prefix']) + ->addArgument(['base_template' => $templating['base_template']]); + $container->setAlias(TemplatingInterface::class, 'yokai_batch.ui.templating'); + } + } } diff --git a/src/batch-symfony-framework/src/Resources/routing/ui.xml b/src/batch-symfony-framework/src/Resources/routing/ui.xml new file mode 100644 index 00000000..2d2f14d9 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/routing/ui.xml @@ -0,0 +1,23 @@ + + + + + + yokai_batch.ui.controller::list + + + + yokai_batch.ui.controller::view + + + + yokai_batch.ui.controller::view + + + + yokai_batch.ui.controller::logs + + + diff --git a/src/batch-symfony-framework/src/Resources/services/ui.xml b/src/batch-symfony-framework/src/Resources/services/ui.xml new file mode 100644 index 00000000..472d86da --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/services/ui.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + %yokai_batch.ui.security_list_attribute% + %yokai_batch.ui.security_view_attribute% + %yokai_batch.ui.security_traces_attribute% + %yokai_batch.ui.security_logs_attribute% + + + + + + + + diff --git a/src/batch-symfony-framework/src/Resources/translations/YokaiBatchBundle.en.xlf b/src/batch-symfony-framework/src/Resources/translations/YokaiBatchBundle.en.xlf new file mode 100644 index 00000000..9e878b25 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/translations/YokaiBatchBundle.en.xlf @@ -0,0 +1,151 @@ + + + + + + job.name + Jobs + + + job.title.list + Jobs list + + + job.title.view + Job detail + + + job.tab.general + General + + + job.tab.failures + Errors + + + job.tab.warnings + Warnings + + + job.tab.children + Children + + + job.fieldset.information + Information + + + job.fieldset.parameters + Parameters + + + job.fieldset.summary + Summary + + + job.field.execution_id + Execution ID + + + job.field.job_name + Job name + + + job.field.status + Status + + + job.field.start_time + Start time + + + job.field.end_time + End time + + + job.field.summary + Information + + + job.field.failures + Errors + + + job.field.warnings + Warnings + + + job.field.failure.message + Message + + + job.field.failure.class + Type + + + job.field.failure.trace + Trace + + + job.field.warning.message + Message + + + job.field.warning.context + Context + + + job.status.pending + Pending + + + job.status.running + Running + + + job.status.stopped + Stopped + + + job.status.completed + Completed + + + job.status.abandoned + Abandoned + + + job.status.failed + Failed + + + job.action.list + List + + + job.action.filter + Filter + + + job.action.view + View + + + job.action.download_logs + Download logs + + + job.action.pagination_first + First + + + job.action.pagination_prev + Previous + + + job.action.pagination_next + Next + + + + diff --git a/src/batch-symfony-framework/src/Resources/translations/YokaiBatchBundle.fr.xlf b/src/batch-symfony-framework/src/Resources/translations/YokaiBatchBundle.fr.xlf new file mode 100644 index 00000000..62485993 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/translations/YokaiBatchBundle.fr.xlf @@ -0,0 +1,151 @@ + + + + + + job.name + Jobs + + + job.title.list + Liste des jobs + + + job.title.view + Détail d'un job + + + job.tab.general + Général + + + job.tab.failures + Erreurs + + + job.tab.warnings + Avertissements + + + job.tab.children + Enfants + + + job.fieldset.information + Informations + + + job.fieldset.parameters + Paramètres + + + job.fieldset.summary + Informations + + + job.field.execution_id + Execution ID + + + job.field.job_name + Nom du job + + + job.field.status + Statut + + + job.field.start_time + Date de début + + + job.field.end_time + Date de fin + + + job.field.summary + Informations + + + job.field.failures + Erreurs + + + job.field.warnings + Avertissements + + + job.field.failure.message + Message + + + job.field.failure.class + Type + + + job.field.failure.trace + Trace + + + job.field.warning.message + Message + + + job.field.warning.context + Contexte + + + job.status.pending + En attente + + + job.status.running + En cours + + + job.status.stopped + Arrêté + + + job.status.completed + Terminé + + + job.status.abandoned + Abandonné + + + job.status.failed + Échoué + + + job.action.list + Liste + + + job.action.filter + Filtrer + + + job.action.view + Voir + + + job.action.download_logs + Télécharger les logs + + + job.action.pagination_first + Première + + + job.action.pagination_prev + Précédente + + + job.action.pagination_next + Suivante + + + + diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/_datetime.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/_datetime.html.twig new file mode 100644 index 00000000..e286177a --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/_datetime.html.twig @@ -0,0 +1 @@ +{{ value ? value|date : '' }} diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/_job-name.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/_job-name.html.twig new file mode 100644 index 00000000..89b76cc4 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/_job-name.html.twig @@ -0,0 +1,4 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{{ ('job.job_name.'~execution.jobName)|trans({}, 'YokaiBatchBundle') }} diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/_json.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/_json.html.twig new file mode 100644 index 00000000..6898d210 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/_json.html.twig @@ -0,0 +1 @@ +
{{ value|json_encode(constant('JSON_PRETTY_PRINT')) }}
diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/_status.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/_status.html.twig new file mode 100644 index 00000000..f9b17122 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/_status.html.twig @@ -0,0 +1,14 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{% set classMap = { + (constant('PENDING', execution.status)): 'light', + (constant('RUNNING', execution.status)): 'warning', + (constant('STOPPED', execution.status)): 'info', + (constant('COMPLETED', execution.status)): 'success', + (constant('ABANDONED', execution.status)): 'info', + (constant('FAILED', execution.status)): 'danger', +} %} + + {{ ('job.status.'~execution.status|lower)|trans }} + diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/_traces.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/_traces.html.twig new file mode 100644 index 00000000..961cb1d6 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/_traces.html.twig @@ -0,0 +1 @@ +
{{ value }}
diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/list.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/list.html.twig new file mode 100644 index 00000000..4bff1752 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/list.html.twig @@ -0,0 +1,154 @@ +{% extends base_template %}{# configured at yokai_batch.ui.templating.base_template #} + +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} + +{% set parameters = app.request.query.all('filter') %} + +{% block title %} + {{ 'job.title.list'|trans }} +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block filter %} + {% if form is not null %} + {{ form_start(form) }} + {{ form_widget(form) }} + + {{ form_end(form) }} + {% endif %} +{% endblock %} + +{% block table %} + + + + + + + + + + + + + {% for execution in executions %} + + + + + + + + + {% endfor %} + +
+ {{ 'job.field.execution_id'|trans }} + + {{ 'job.field.job_name'|trans }} + + {{ 'job.field.status'|trans }} + + + {{ 'job.field.start_time'|trans }} + + + + {{ 'job.field.end_time'|trans }} + +
+ {% if yokai_batch_grant_view(execution) %} + + {{ execution.id }} + + {% else %} + {{ execution.id }} + {% endif %} + + {% include '@YokaiBatch/bootstrap4/_job-name.html.twig' with { execution: execution } only %} + + {% include '@YokaiBatch/bootstrap4/_status.html.twig' with { execution: execution } only %} + + {% include '@YokaiBatch/bootstrap4/_datetime.html.twig' with {value: execution.startTime} only %} + + {% include '@YokaiBatch/bootstrap4/_datetime.html.twig' with {value: execution.endTime} only %} + +
+ {% if yokai_batch_grant_view(execution) %} + + + {{ 'job.action.view'|trans }} + + {% endif %} + {% if yokai_batch_grant_logs(execution) %} + + + {{ 'job.action.download_logs'|trans }} + + {% endif %} +
+
+{% endblock %} + +{% block pagination %} + {% if executions|length > 0 and (pagination.prev.enabled or pagination.next.enabled) %} + + {% endif %} +{% endblock %} + +{% block body %} + {{ block('breadcrumbs') }} + {{ block('filter') }} + {{ block('table') }} + {{ block('pagination') }} +{% endblock %} diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/show.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show.html.twig new file mode 100644 index 00000000..87b62ba8 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show.html.twig @@ -0,0 +1,140 @@ +{% extends base_template %}{# configured at yokai_batch.ui.templating.base_template #} + +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{# @var executionInPath \Yokai\Batch\JobExecution #} + +{% block title %} + {{ 'job.title.view'|trans }} +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block actions %} + {% if yokai_batch_grant_list() %} + + + {{ 'job.action.list'|trans }} + + {% endif %} + {% if yokai_batch_grant_logs(execution) %} + + + {{ 'job.action.download_logs'|trans }} + + {% endif %} +{% endblock %} + +{% block tabs %} + {% set failures = execution.failures %} + {% set warnings = execution.warnings %} + {% set jobName = execution.jobName %} + {% set children = execution.childExecutions %} + +
+ +
+{% endblock %} + +{% block body %} + {{ block('breadcrumbs') }} + {{ block('actions') }} + {{ block('tabs') }} +{% endblock %} diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_children-executions.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_children-executions.html.twig new file mode 100644 index 00000000..9f51cde9 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_children-executions.html.twig @@ -0,0 +1,59 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{# @var childExecution \Yokai\Batch\JobExecution #} + + + + + + + + + + + + + {% for childExecution in execution.childExecutions %} + + + + + + + + + + {% endfor %} +
{{ 'job.field.job_name'|trans }}{{ 'job.field.status'|trans }}{{ 'job.field.failures'|trans }}{{ 'job.field.warnings'|trans }}{{ 'job.field.start_time'|trans }}{{ 'job.field.end_time'|trans }}
+ {% include '@YokaiBatch/bootstrap4/_job-name.html.twig' with {execution: childExecution} only %} + + {% include '@YokaiBatch/bootstrap4/_status.html.twig' with {execution: childExecution} only %} + + {% if childExecution.failures|length > 0 %} + {{ childExecution.failures|length }} + {% else %} + 0 + {% endif %} + + {% if childExecution.warnings|length > 0 %} + {{ childExecution.warnings|length }} + {% else %} + 0 + {% endif %} + + {% include '@YokaiBatch/bootstrap4/_datetime.html.twig' with {value: childExecution.startTime} only %} + + {% include '@YokaiBatch/bootstrap4/_datetime.html.twig' with {value: childExecution.endTime} only %} + + +
diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_failures.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_failures.html.twig new file mode 100644 index 00000000..9eb98d39 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_failures.html.twig @@ -0,0 +1,29 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{# @var failure \Yokai\Batch\Failure #} +{% set canSeeTraces = yokai_batch_grant_traces(execution) %} + + + + + + {% if canSeeTraces %} + + {% endif %} + + + {% for failure in failures %} + + + + {% if canSeeTraces %} + + {% endif %} + + {% endfor %} +
{{ 'job.field.failure.message'|trans }}{{ 'job.field.failure.class'|trans }}{{ 'job.field.failure.trace'|trans }}
{{ failure }}{{ failure.class }} + {% if failure.trace %} + {% include '@YokaiBatch/bootstrap4/_traces.html.twig' with {value: failure.trace} only %} + {% endif %} +
diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_general.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_general.html.twig new file mode 100644 index 00000000..cf22a73f --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_general.html.twig @@ -0,0 +1,27 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +
+
+
+ {% include [ + '@YokaiBatch/bootstrap4/show/'~jobName~'/_information.html.twig', + '@YokaiBatch/bootstrap4/show/_information.html.twig' + ] %} +
+
+
+
+ {% include [ + '@YokaiBatch/bootstrap4/show/'~jobName~'/_parameters.html.twig', + '@YokaiBatch/bootstrap4/show/_parameters.html.twig' + ] %} +
+
+ {% include [ + '@YokaiBatch/bootstrap4/show/'~jobName~'/_summary.html.twig', + '@YokaiBatch/bootstrap4/show/_summary.html.twig' + ] %} +
+
+
diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_information.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_information.html.twig new file mode 100644 index 00000000..a18e0223 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_information.html.twig @@ -0,0 +1,42 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +
+
+ {{ 'job.fieldset.information'|trans }} +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ 'job.field.execution_id'|trans }}{{ execution.id }}
{{ 'job.field.job_name'|trans }} + {% include '@YokaiBatch/bootstrap4/_job-name.html.twig' with {execution: execution} only %} +
{{ 'job.field.status'|trans }} + {% include '@YokaiBatch/bootstrap4/_status.html.twig' with {execution: execution} only %} +
{{ 'job.field.start_time'|trans }} + {%- include '@YokaiBatch/bootstrap4/_datetime.html.twig' with {value: execution.startTime} only -%} +
{{ 'job.field.end_time'|trans }} + {%- include '@YokaiBatch/bootstrap4/_datetime.html.twig' with {value: execution.endTime} only -%} +
+
+
diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_job-name-and-id.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_job-name-and-id.html.twig new file mode 100644 index 00000000..2a8b5d74 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_job-name-and-id.html.twig @@ -0,0 +1,5 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{% include '@YokaiBatch/bootstrap4/_job-name.html.twig' with {execution: execution} only %} +{% if execution.parentExecution is null %}(#{{ execution.id }}){% endif %} diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_parameters.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_parameters.html.twig new file mode 100644 index 00000000..20c0824b --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_parameters.html.twig @@ -0,0 +1,11 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +
+
+ {{ 'job.fieldset.parameters'|trans }} +
+
+ {% include '@YokaiBatch/bootstrap4/_json.html.twig' with {value: execution.parameters.iterator} only %} +
+
diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_summary.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_summary.html.twig new file mode 100644 index 00000000..15d47c88 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_summary.html.twig @@ -0,0 +1,15 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +
+
+ {% block header %} + {{ 'job.fieldset.summary'|trans }} + {% endblock %} +
+
+ {% block body %} + {% include '@YokaiBatch/bootstrap4/_json.html.twig' with {value: execution.summary.iterator} only %} + {% endblock %} +
+
diff --git a/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_warnings.html.twig b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_warnings.html.twig new file mode 100644 index 00000000..3f48a291 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/bootstrap4/show/_warnings.html.twig @@ -0,0 +1,22 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{# @var warning \Yokai\Batch\Warning #} + + + + + + + + {% for warning in warnings %} + + + + + {% endfor %} +
{{ 'job.field.warning.message'|trans }}{{ 'job.field.warning.context'|trans }}
{{ warning }} + {% if warning.context %} + {% include '@YokaiBatch/bootstrap4/_json.html.twig' with {value: warning.context} only %} + {% endif %} +
diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/_job-name.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/_job-name.html.twig new file mode 100644 index 00000000..89b76cc4 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/_job-name.html.twig @@ -0,0 +1,4 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{{ ('job.job_name.'~execution.jobName)|trans({}, 'YokaiBatchBundle') }} diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/_json.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/_json.html.twig new file mode 100644 index 00000000..6898d210 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/_json.html.twig @@ -0,0 +1 @@ +
{{ value|json_encode(constant('JSON_PRETTY_PRINT')) }}
diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/_status.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/_status.html.twig new file mode 100644 index 00000000..91efa65d --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/_status.html.twig @@ -0,0 +1,14 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{% set classMap = { + (constant('PENDING', execution.status)): 'default', + (constant('RUNNING', execution.status)): 'warning', + (constant('STOPPED', execution.status)): 'info', + (constant('COMPLETED', execution.status)): 'success', + (constant('ABANDONED', execution.status)): 'info', + (constant('FAILED', execution.status)): 'danger', +} %} + + {{ ('job.status.'~execution.status|lower)|trans }} + diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/_traces.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/_traces.html.twig new file mode 100644 index 00000000..961cb1d6 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/_traces.html.twig @@ -0,0 +1 @@ +
{{ value }}
diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/list.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/list.html.twig new file mode 100644 index 00000000..5d4e34a9 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/list.html.twig @@ -0,0 +1,223 @@ +{% extends base_template %} + +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} + +{% set parameters = app.request.query.all('filter') %} + +{% block title %} + - {{ 'job.name'|trans }} +{% endblock %} + +{% block navbar_title %} +{% endblock %} + +{%- block actions -%} +{%- endblock -%} + +{%- block tab_menu -%} +{%- endblock -%} + +{% block breadcrumb %} + +{% endblock %} + +{% block list_table %} +
+
+
+ + + + + + + + + + + + + {% for execution in executions %} + + + + + + + + + {% endfor %} + +
+ {{ 'job.field.execution_id'|trans }} + + {{ 'job.field.job_name'|trans }} + + {{ 'job.field.status'|trans }} + + + {{ 'job.field.start_time'|trans }} + + + + {{ 'job.field.end_time'|trans }} + +
+ {% if yokai_batch_grant_view(execution) %} + + {{ execution.id }} + + {% else %} + {{ execution.id }} + {% endif %} + + {% include '@YokaiBatch/sonata/_job-name.html.twig' with { execution: execution } only %} + + {% include '@YokaiBatch/sonata/_status.html.twig' with { execution: execution } only %} + + {%- include '@SonataAdmin/CRUD/display_datetime.html.twig' with { value: execution.startTime } only -%} + + {%- include '@SonataAdmin/CRUD/display_datetime.html.twig' with { value: execution.endTime } only -%} + +
+ {% if yokai_batch_grant_view(execution) %} + + + {{ 'action_show'|trans({}, 'SonataAdminBundle') }} + + {% endif %} + {% if yokai_batch_grant_logs(execution) %} + + {{ 'job.action.download_logs'|trans }} + + {% endif %} +
+
+
+
+ {% if executions|length > 0 and (pagination.prev.enabled or pagination.next.enabled) %} + + {% endif %} +
+{% endblock %} + +{% block list_filters_actions %} + +{% endblock %} + +{% block list_filters %} + {% form_theme form filter_template %} +
+
+
+
+ {{ form_errors(form) }} +
+
+ {% for filter in form %} + {% set filterVisible = true %} + {% set filterActive = filter.vars.name in filters %} +
+ +
+
+
+ {{ form_widget(filter) }} +
+
+ +
+
+ {% endfor %} +
+
+ {{ form_rest(form) }} + +
+ + + + {{ 'link_reset_filter'|trans({}, 'SonataAdminBundle') }} + +
+
+
+
+
+
+
+{% endblock %} diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/show.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/show.html.twig new file mode 100644 index 00000000..0bdb2bd1 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/show.html.twig @@ -0,0 +1,165 @@ +{% extends '@SonataAdmin/CRUD/base_show.html.twig' %} + +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{# @var executionInPath \Yokai\Batch\JobExecution #} + +{% set executionName %} + {% include '@YokaiBatch/sonata/_job-name.html.twig' with {execution: execution} only %} + {% if execution.parentExecution is null %} + (#{{ execution.id }}) + {% endif %} +{% endset %} + +{% block title %} + - {{ 'title_show'|trans({'%name%': executionName|u.truncate(15) }, 'SonataAdminBundle') }} +{% endblock %} + +{% block navbar_title %} + {{ 'title_show'|trans({'%name%': executionName|u.truncate(100) }, 'SonataAdminBundle') }} +{% endblock %} + +{%- block actions -%} + {% if yokai_batch_grant_logs(execution) %} +
  • + + {{ 'job.action.download_logs'|trans({}) }} + +
  • + {% endif %} + {% if yokai_batch_grant_list() %} +
  • + + {{ 'link_action_list'|trans({}, 'SonataAdminBundle') }} + +
  • + {% endif %} +{%- endblock -%} + +{% block tab_menu %} +{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block show %} +
    + {{ block('show_groups') }} +
    +{% endblock %} + +{% block show_groups %} + {% set failures = execution.failures %} + {% set warnings = execution.warnings %} + {% set jobName = execution.jobName %} + +
    + +
    +{% endblock %} diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/show/_children-executions.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/show/_children-executions.html.twig new file mode 100644 index 00000000..84f9fbf1 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/show/_children-executions.html.twig @@ -0,0 +1,59 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{# @var childExecution \Yokai\Batch\JobExecution #} + + + + + + + + + + + + + {% for childExecution in execution.childExecutions %} + + + + + + + + + + {% endfor %} +
    {{ 'job.field.job_name'|trans }}{{ 'job.field.status'|trans }}{{ 'job.field.failures'|trans }}{{ 'job.field.warnings'|trans }}{{ 'job.field.start_time'|trans }}{{ 'job.field.end_time'|trans }}
    + {% include '@YokaiBatch/sonata/_job-name.html.twig' with {execution: childExecution} only %} + + {% include '@YokaiBatch/sonata/_status.html.twig' with {execution: childExecution} only %} + + {% if childExecution.failures|length > 0 %} + {{ childExecution.failures|length }} + {% else %} + 0 + {% endif %} + + {% if childExecution.warnings|length > 0 %} + {{ childExecution.warnings|length }} + {% else %} + 0 + {% endif %} + + {%- include '@SonataAdmin/CRUD/display_datetime.html.twig' with {value: childExecution.startTime} only -%} + + {%- include '@SonataAdmin/CRUD/display_datetime.html.twig' with {value: childExecution.endTime} only -%} + + +
    diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/show/_failures.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/show/_failures.html.twig new file mode 100644 index 00000000..916940d1 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/show/_failures.html.twig @@ -0,0 +1,29 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{# @var failure \Yokai\Batch\Failure #} +{% set canSeeTraces = yokai_batch_grant_traces(execution) %} + + + + + + {% if canSeeTraces %} + + {% endif %} + + + {% for failure in failures %} + + + + {% if canSeeTraces %} + + {% endif %} + + {% endfor %} +
    {{ 'job.field.failure.message'|trans }}{{ 'job.field.failure.class'|trans }}{{ 'job.field.failure.trace'|trans }}
    {{ failure }}{{ failure.class }} + {% if failure.trace %} + {% include '@YokaiBatch/sonata/_traces.html.twig' with {value: failure.trace} only %} + {% endif %} +
    diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/show/_general.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/show/_general.html.twig new file mode 100644 index 00000000..2e6a5ccb --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/show/_general.html.twig @@ -0,0 +1,27 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +
    +
    +
    + {% include [ + '@YokaiBatch/sonata/show/'~jobName~'/_information.html.twig', + '@YokaiBatch/sonata/show/_information.html.twig' + ] %} +
    +
    +
    +
    + {% include [ + '@YokaiBatch/sonata/show/'~jobName~'/_parameters.html.twig', + '@YokaiBatch/sonata/show/_parameters.html.twig' + ] %} +
    +
    + {% include [ + '@YokaiBatch/sonata/show/'~jobName~'/_summary.html.twig', + '@YokaiBatch/sonata/show/_summary.html.twig' + ] %} +
    +
    +
    diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/show/_information.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/show/_information.html.twig new file mode 100644 index 00000000..1a667966 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/show/_information.html.twig @@ -0,0 +1,44 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +
    +
    +

    + {{ 'job.fieldset.information'|trans }} +

    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    {{ 'job.field.execution_id'|trans }}{{ execution.id }}
    {{ 'job.field.job_name'|trans }} + {% include '@YokaiBatch/sonata/_job-name.html.twig' with {execution: execution} only %} +
    {{ 'job.field.status'|trans }} + {% include '@YokaiBatch/sonata/_status.html.twig' with {execution: execution} only %} +
    {{ 'job.field.start_time'|trans }} + {%- include '@SonataAdmin/CRUD/display_datetime.html.twig' with {value: execution.startTime} only -%} +
    {{ 'job.field.end_time'|trans }} + {%- include '@SonataAdmin/CRUD/display_datetime.html.twig' with {value: execution.endTime} only -%} +
    +
    +
    diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/show/_job-name-and-id.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/show/_job-name-and-id.html.twig new file mode 100644 index 00000000..2a8b5d74 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/show/_job-name-and-id.html.twig @@ -0,0 +1,5 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{% include '@YokaiBatch/bootstrap4/_job-name.html.twig' with {execution: execution} only %} +{% if execution.parentExecution is null %}(#{{ execution.id }}){% endif %} diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/show/_parameters.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/show/_parameters.html.twig new file mode 100644 index 00000000..90f083cd --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/show/_parameters.html.twig @@ -0,0 +1,13 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +
    +
    +

    + {{ 'job.fieldset.parameters'|trans }} +

    +
    +
    + {% include '@YokaiBatch/sonata/_json.html.twig' with {value: execution.parameters.iterator} only %} +
    +
    diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/show/_summary.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/show/_summary.html.twig new file mode 100644 index 00000000..a305345e --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/show/_summary.html.twig @@ -0,0 +1,13 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +
    +
    +

    + {{ 'job.fieldset.summary'|trans }} +

    +
    +
    + {% include '@YokaiBatch/sonata/_json.html.twig' with {value: execution.summary.iterator} only %} +
    +
    diff --git a/src/batch-symfony-framework/src/Resources/views/sonata/show/_warnings.html.twig b/src/batch-symfony-framework/src/Resources/views/sonata/show/_warnings.html.twig new file mode 100644 index 00000000..93a39f05 --- /dev/null +++ b/src/batch-symfony-framework/src/Resources/views/sonata/show/_warnings.html.twig @@ -0,0 +1,22 @@ +{% trans_default_domain 'YokaiBatchBundle' %} + +{# @var execution \Yokai\Batch\JobExecution #} +{# @var warning \Yokai\Batch\Warning #} + + + + + + + + {% for warning in warnings %} + + + + + {% endfor %} +
    {{ 'job.field.warning.message'|trans }}{{ 'job.field.warning.context'|trans }}
    {{ warning }} + {% if warning.context %} + {% include '@YokaiBatch/sonata/_json.html.twig' with {value: warning.context} only %} + {% endif %} +
    diff --git a/src/batch-symfony-framework/src/UserInterface/Controller/JobController.php b/src/batch-symfony-framework/src/UserInterface/Controller/JobController.php new file mode 100644 index 00000000..7632ab15 --- /dev/null +++ b/src/batch-symfony-framework/src/UserInterface/Controller/JobController.php @@ -0,0 +1,201 @@ +security->denyAccessUnlessGrantedList(); + + $page = $request->query->getInt('page', 1); + $sort = (string)$request->query->get('sort', Query::SORT_BY_START_DESC); + + $query = new QueryBuilder(); + + $filter = null; + $filters = null; + if ($this->formFactory !== null) { + $filter = $this->formFactory->createNamed( + 'filter', + JobFilterType::class, + $data = new JobFilter(), + [ + 'method' => Request::METHOD_GET, + 'csrf_protection' => false, + ] + ); + $filter->handleRequest($request); + + $query->jobs($data->jobs); + $query->statuses($data->statuses); + + // find out which filters were used + $filters = []; + foreach (\get_object_vars($data) as $field => $value) { + if ($value !== []) { + $filters[] = $field; + } + } + } + + try { + $query->limit(self::LIMIT, self::LIMIT * ($page - 1)); + $query->sort($sort); + } catch (Throwable $exception) { + throw new BadRequestHttpException(previous: $exception); + } + + // transform iterable executions to array + $executions = []; + foreach ($this->jobExecutionStorage->query($query->getQuery()) as $execution) { + $executions[] = $execution; + } + + // prepare sort variable for view + $sort = [ + 'parameter' => 'sort', + 'current' => $sort, + 'desc' => \in_array($sort, [Query::SORT_BY_START_DESC, Query::SORT_BY_END_DESC], true), + // sort by execution start info + 'start' => [ + 'switch' => $sort === Query::SORT_BY_START_DESC ? Query::SORT_BY_START_ASC : Query::SORT_BY_START_DESC, + 'sorted' => \in_array($sort, [Query::SORT_BY_START_ASC, Query::SORT_BY_START_DESC], true), + ], + // sort by execution end info + 'end' => [ + 'switch' => $sort === Query::SORT_BY_END_DESC ? Query::SORT_BY_END_ASC : Query::SORT_BY_END_DESC, + 'sorted' => \in_array($sort, [Query::SORT_BY_END_ASC, Query::SORT_BY_END_DESC], true), + ], + ]; + // prepare pagination variable for view + $pagination = [ + 'parameter' => 'page', + 'per_page' => self::LIMIT, + 'results' => \count($executions), + 'current' => $page, + 'is' => [ + 'first' => $page === 1, + 'last' => \count($executions) !== self::LIMIT, + ], + 'prev' => ['enabled' => $page !== 1, 'value' => $page - 1], + 'next' => ['enabled' => \count($executions) === self::LIMIT, 'value' => $page + 1], + ]; + + return new Response( + $this->twig->render( + $this->templating->name('list.html.twig'), + $this->templating->context([ + 'executions' => $executions, + 'form' => $filter?->createView(), + 'sort' => $sort, + 'filters' => $filters, + 'pagination' => $pagination, + ]), + ), + ); + } + + /** + * View {@see JobExecution} details in a Twig template. + */ + public function view(string $job, string $id, ?string $path = null): Response + { + try { + $execution = $this->jobExecutionStorage->retrieve($job, $id); + } catch (JobExecutionNotFoundException $exception) { + throw new NotFoundHttpException(previous: $exception); + } + + $this->security->denyAccessUnlessGrantedView($execution); + + $executionsPath = [ + '' => $execution, + ]; + if ($path !== null) { + $parentPath = []; + foreach (\explode('|', $path) as $childName) { + $execution = $execution->getChildExecution($childName) ?? throw new NotFoundHttpException(); + $parentPath[] = $childName; + $executionsPath[\implode('|', $parentPath)] = $execution; + } + } + + $pathPrefix = ''; + if ($path !== null) { + $pathPrefix = $path . '|'; + } + + return new Response( + $this->twig->render( + $this->templating->name('show.html.twig'), + $this->templating->context([ + 'execution' => $execution, + 'pathPrefix' => $pathPrefix, + 'executionsPath' => $executionsPath, + ]), + ), + ); + } + + /** + * Download {@see JobExecution} logs. + */ + public function logs(string $job, string $id): Response + { + try { + $execution = $this->jobExecutionStorage->retrieve($job, $id); + } catch (JobExecutionNotFoundException $exception) { + throw new NotFoundHttpException(previous: $exception); + } + + $this->security->denyAccessUnlessGrantedLogs($execution); + + $filename = \sprintf('%s-%s.log', $execution->getJobName(), $execution->getId()); + $response = new Response((string)$execution->getLogs()); + $response->headers->set( + 'Content-Disposition', + $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename) + ); + $response->headers->set('Content-Type', 'application/log'); + + return $response; + } +} diff --git a/src/batch-symfony-framework/src/UserInterface/Form/JobFilter.php b/src/batch-symfony-framework/src/UserInterface/Form/JobFilter.php new file mode 100644 index 00000000..70f5f03f --- /dev/null +++ b/src/batch-symfony-framework/src/UserInterface/Form/JobFilter.php @@ -0,0 +1,34 @@ + + */ + public array $jobs = []; + + /** + * @var array + */ + public array $statuses = []; + + /** + * @param array $jobs + * @param array $statuses + */ + public function __construct(array $jobs = [], array $statuses = []) + { + $this->jobs = $jobs; + $this->statuses = $statuses; + } +} diff --git a/src/batch-symfony-framework/src/UserInterface/Form/JobFilterType.php b/src/batch-symfony-framework/src/UserInterface/Form/JobFilterType.php new file mode 100644 index 00000000..fb62fc77 --- /dev/null +++ b/src/batch-symfony-framework/src/UserInterface/Form/JobFilterType.php @@ -0,0 +1,66 @@ + BatchStatus::PENDING, + 'running' => BatchStatus::RUNNING, + 'stopped' => BatchStatus::STOPPED, + 'completed' => BatchStatus::COMPLETED, + 'abandoned' => BatchStatus::ABANDONED, + 'failed' => BatchStatus::FAILED, + ]; + + public function __construct( + /** + * @var array + */ + private array $jobs, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add( + 'jobs', + ChoiceType::class, + [ + 'label' => 'job.field.job_name', + 'choice_label' => fn($choice, string $key, $value) => sprintf('job.job_name.%s', $key), + 'choices' => \array_combine($this->jobs, $this->jobs), + 'required' => false, + 'multiple' => true, + ], + ); + $builder->add( + 'statuses', + ChoiceType::class, + [ + 'label' => 'job.field.status', + 'choice_label' => fn($choice, string $key, $value) => sprintf('job.status.%s', $key), + 'choices' => self::STATUSES, + 'required' => false, + 'multiple' => true, + ], + ); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('translation_domain', 'YokaiBatchBundle'); + $resolver->setDefault('data_class', JobFilter::class); + } +} diff --git a/src/batch-symfony-framework/src/UserInterface/JobSecurity.php b/src/batch-symfony-framework/src/UserInterface/JobSecurity.php new file mode 100644 index 00000000..165c0ae0 --- /dev/null +++ b/src/batch-symfony-framework/src/UserInterface/JobSecurity.php @@ -0,0 +1,82 @@ +denyAccessUnlessGranted($this->listAttribute); + } + + /** + * Deny access unless granted to access {@see JobExecution} detail. + * + * @throws AccessDeniedException + */ + public function denyAccessUnlessGrantedView(JobExecution $execution): void + { + $this->denyAccessUnlessGranted($this->viewAttribute, $execution); + } + + /** + * Deny access unless granted to access {@see JobExecution} traces. + * + * @throws AccessDeniedException + */ + public function denyAccessUnlessGrantedTraces(JobExecution $execution): void + { + $this->denyAccessUnlessGranted($this->tracesAttribute, $execution); + } + + /** + * Deny access unless granted to access {@see JobExecution} logs. + * + * @throws AccessDeniedException + */ + public function denyAccessUnlessGrantedLogs(JobExecution $execution): void + { + $this->denyAccessUnlessGranted($this->logsAttribute, $execution); + } + + /** + * @throws AccessDeniedException + */ + private function denyAccessUnlessGranted(mixed $attribute, mixed $subject = null): void + { + if ($this->authorizationChecker === null) { + return; + } + if (!$this->authorizationChecker->isGranted($attribute, $subject)) { + $exception = new AccessDeniedException(); + $exception->setAttributes([$attribute]); + $exception->setSubject($subject); + + throw $exception; + } + } +} diff --git a/src/batch-symfony-framework/src/UserInterface/Templating/ConfigurableTemplating.php b/src/batch-symfony-framework/src/UserInterface/Templating/ConfigurableTemplating.php new file mode 100644 index 00000000..aa165420 --- /dev/null +++ b/src/batch-symfony-framework/src/UserInterface/Templating/ConfigurableTemplating.php @@ -0,0 +1,30 @@ + $context + */ + public function __construct( + private string $prefix, + private array $context, + ) { + } + + public function name(string $name): string + { + return \rtrim($this->prefix, '/') . '/' . \ltrim($name, '/'); + } + + public function context(array $context): array + { + return \array_merge($this->context, $context); + } +} diff --git a/src/batch-symfony-framework/src/UserInterface/Templating/SonataAdminTemplating.php b/src/batch-symfony-framework/src/UserInterface/Templating/SonataAdminTemplating.php new file mode 100644 index 00000000..8ccb78e5 --- /dev/null +++ b/src/batch-symfony-framework/src/UserInterface/Templating/SonataAdminTemplating.php @@ -0,0 +1,31 @@ + $this->templates->getTemplate('layout'), + 'filter_template' => $this->templates->getTemplate('filter'), + ], $context); + } +} diff --git a/src/batch-symfony-framework/src/UserInterface/Templating/TemplatingInterface.php b/src/batch-symfony-framework/src/UserInterface/Templating/TemplatingInterface.php new file mode 100644 index 00000000..f0bbbdf4 --- /dev/null +++ b/src/batch-symfony-framework/src/UserInterface/Templating/TemplatingInterface.php @@ -0,0 +1,28 @@ + $context + * + * @return array + */ + public function context(array $context): array; +} diff --git a/src/batch-symfony-framework/src/UserInterface/TwigExtension.php b/src/batch-symfony-framework/src/UserInterface/TwigExtension.php new file mode 100644 index 00000000..6702cf1d --- /dev/null +++ b/src/batch-symfony-framework/src/UserInterface/TwigExtension.php @@ -0,0 +1,61 @@ + $isGranted( + fn() => $this->security->denyAccessUnlessGrantedList(), + ), + ), + new TwigFunction( + 'yokai_batch_grant_view', + fn(JobExecution $execution) => $isGranted( + fn() => $this->security->denyAccessUnlessGrantedView($execution), + ), + ), + new TwigFunction( + 'yokai_batch_grant_traces', + fn(JobExecution $execution) => $isGranted( + fn() => $this->security->denyAccessUnlessGrantedTraces($execution), + ), + ), + new TwigFunction( + 'yokai_batch_grant_logs', + fn(JobExecution $execution) => $isGranted( + fn() => $this->security->denyAccessUnlessGrantedLogs($execution), + ), + ), + ]; + } +} diff --git a/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php b/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php index f56ed52a..602ea076 100644 --- a/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php +++ b/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php @@ -7,8 +7,12 @@ use Exception; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\LogicException; use Yokai\Batch\Bridge\Symfony\Framework\DependencyInjection\YokaiBatchExtension; +use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\ConfigurableTemplating; +use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\SonataAdminTemplating; +use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\TemplatingInterface; use Yokai\Batch\Launcher\JobLauncherInterface; use Yokai\Batch\Storage\JobExecutionStorageInterface; use Yokai\Batch\Storage\NullJobExecutionStorage; @@ -18,10 +22,12 @@ class YokaiBatchExtensionTest extends TestCase /** * @dataProvider configs */ - public function test(array $config, string $storage): void + public function test(array $config, ?callable $configure, string $storage, ?callable $templating, ?array $security): void { $container = new ContainerBuilder(); - $container->register('app.yokai_batch.storage', NullJobExecutionStorage::class); + if ($configure !== null) { + $configure($container); + } (new YokaiBatchExtension())->load([$config], $container); @@ -33,14 +39,165 @@ public function test(array $config, string $storage): void $storage, (string)$container->getAlias(JobExecutionStorageInterface::class) ); + if ($templating === null && $security === null) { + self::assertFalse($container->hasAlias(TemplatingInterface::class)); + self::assertFalse($container->hasParameter('yokai_batch.ui.security_list_attribute')); + self::assertFalse($container->hasParameter('yokai_batch.ui.security_view_attribute')); + self::assertFalse($container->hasParameter('yokai_batch.ui.security_traces_attribute')); + self::assertFalse($container->hasParameter('yokai_batch.ui.security_logs_attribute')); + } else { + $templatingId = (string)$container->getAlias(TemplatingInterface::class); + $templating($container->getDefinition($templatingId), $templatingId); + + self::assertSame( + $security['list'], + (string)$container->getParameter('yokai_batch.ui.security_list_attribute') + ); + self::assertSame( + $security['view'], + (string)$container->getParameter('yokai_batch.ui.security_view_attribute') + ); + self::assertSame( + $security['traces'], + (string)$container->getParameter('yokai_batch.ui.security_traces_attribute') + ); + self::assertSame( + $security['logs'], + (string)$container->getParameter('yokai_batch.ui.security_logs_attribute') + ); + } } public function configs(): \Generator { - yield [[], 'yokai_batch.storage.filesystem']; - yield [['storage' => ['filesystem' => null]], 'yokai_batch.storage.filesystem']; - yield [['storage' => ['dbal' => null]], 'yokai_batch.storage.dbal']; - yield [['storage' => ['service' => 'app.yokai_batch.storage']], 'app.yokai_batch.storage']; + yield [ + [], + null, + 'yokai_batch.storage.filesystem', + null, + null, + ]; + yield [ + ['storage' => ['filesystem' => null]], + null, + 'yokai_batch.storage.filesystem', + null, + null, + ]; + yield [ + ['storage' => ['dbal' => null]], + null, + 'yokai_batch.storage.dbal', + null, + null, + ]; + yield [ + ['storage' => ['service' => 'app.yokai_batch.storage']], + fn(ContainerBuilder $container) => $container->register( + 'app.yokai_batch.storage', + NullJobExecutionStorage::class, + ), + 'app.yokai_batch.storage', + null, + null, + ]; + yield [ + ['ui' => ['enabled' => true]], + null, + 'yokai_batch.storage.filesystem', + function (Definition $templating) { + self::assertSame(ConfigurableTemplating::class, $templating->getClass()); + self::assertSame('@YokaiBatch/bootstrap4', $templating->getArgument(0)); + self::assertSame(['base_template' => 'base.html.twig'], $templating->getArgument(1)); + }, + [ + 'list' => 'IS_AUTHENTICATED', + 'view' => 'IS_AUTHENTICATED', + 'traces' => 'IS_AUTHENTICATED', + 'logs' => 'IS_AUTHENTICATED', + ], + ]; + yield [ + ['ui' => ['enabled' => true, 'templating' => 'bootstrap4']], + null, + 'yokai_batch.storage.filesystem', + function (Definition $templating) { + self::assertSame(ConfigurableTemplating::class, $templating->getClass()); + self::assertSame('@YokaiBatch/bootstrap4', $templating->getArgument(0)); + self::assertSame(['base_template' => 'base.html.twig'], $templating->getArgument(1)); + }, + [ + 'list' => 'IS_AUTHENTICATED', + 'view' => 'IS_AUTHENTICATED', + 'traces' => 'IS_AUTHENTICATED', + 'logs' => 'IS_AUTHENTICATED', + ], + ]; + yield [ + [ + 'ui' => [ + 'enabled' => true, + 'templating' => ['prefix' => 'yokai-batch/tailwind', 'base_template' => 'layout.html.twig'], + ], + ], + null, + 'yokai_batch.storage.filesystem', + function (Definition $templating) { + self::assertSame(ConfigurableTemplating::class, $templating->getClass()); + self::assertSame('yokai-batch/tailwind', $templating->getArgument(0)); + self::assertSame(['base_template' => 'layout.html.twig'], $templating->getArgument(1)); + }, + [ + 'list' => 'IS_AUTHENTICATED', + 'view' => 'IS_AUTHENTICATED', + 'traces' => 'IS_AUTHENTICATED', + 'logs' => 'IS_AUTHENTICATED', + ], + ]; + yield [ + ['ui' => ['enabled' => true, 'templating' => ['service' => 'app.yokai_batch_templating']]], + fn(ContainerBuilder $container) => $container->register( + 'app.yokai_batch_templating', + ConfigurableTemplating::class, + ), + 'yokai_batch.storage.filesystem', + function (Definition $templating, string $id) { + self::assertSame($id, 'app.yokai_batch_templating'); + }, + [ + 'list' => 'IS_AUTHENTICATED', + 'view' => 'IS_AUTHENTICATED', + 'traces' => 'IS_AUTHENTICATED', + 'logs' => 'IS_AUTHENTICATED', + ], + ]; + yield [ + [ + 'ui' => [ + 'enabled' => true, + 'templating' => 'sonata', + 'security' => [ + 'attributes' => [ + 'list' => 'ROLE_ADMIN', + 'view' => 'ROLE_ADMIN', + 'traces' => 'ROLE_SUPERADMIN', + 'logs' => 'ROLE_SUPERADMIN', + ], + ], + ], + ], + null, + 'yokai_batch.storage.filesystem', + function (Definition $templating) { + self::assertSame(SonataAdminTemplating::class, $templating->getClass()); + }, + [ + 'list' => 'ROLE_ADMIN', + 'view' => 'ROLE_ADMIN', + 'traces' => 'ROLE_SUPERADMIN', + 'logs' => 'ROLE_SUPERADMIN', + ], + ]; } /** @@ -80,5 +237,38 @@ public function errors(): \Generator ' and must implements interface "Yokai\Batch\Storage\JobExecutionStorageInterface".' ), ]; + yield 'Templating : Not configured' => [ + ['ui' => ['enabled' => true, 'templating' => []]], + null, + new \InvalidArgumentException('You must either configure "service" or "prefix".'), + ]; + yield 'Templating : Both configured' => [ + ['ui' => ['enabled' => true, 'templating' => ['service' => 'service.id', 'prefix' => 'prefix/']]], + null, + new \InvalidArgumentException('You cannot configure "service" and "prefix" at the same time.'), + ]; + yield 'Templating : Unknown service' => [ + ['ui' => ['enabled' => true, 'templating' => ['service' => 'unknown.service']]], + null, + new LogicException('Configured UI templating service "unknown.service" does not exists.'), + ]; + yield 'Templating : Service with no class' => [ + ['ui' => ['enabled' => true, 'templating' => ['service' => 'service.with.no.class']]], + fn(ContainerBuilder $container) => $container->register('service.with.no.class'), + new LogicException( + 'Configured UI templating service "service.with.no.class" ' . + 'must implements interface' . + ' "Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\TemplatingInterface".', + ), + ]; + yield 'Templating : Service without required interface' => [ + ['ui' => ['enabled' => true, 'templating' => ['service' => 'service.without.required.interface']]], + fn(ContainerBuilder $container) => $container->register('service.without.required.interface', __CLASS__), + new LogicException( + 'Configured UI templating service "service.without.required.interface" ' . + 'must implements interface' . + ' "Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\TemplatingInterface".', + ), + ]; } } diff --git a/src/batch-symfony-framework/tests/UserInterface/Controller/JobControllerTest.php b/src/batch-symfony-framework/tests/UserInterface/Controller/JobControllerTest.php new file mode 100644 index 00000000..ca023e34 --- /dev/null +++ b/src/batch-symfony-framework/tests/UserInterface/Controller/JobControllerTest.php @@ -0,0 +1,552 @@ +remove(self::STORAGE_DIR); + } + + /** + * @dataProvider list + */ + public function testList( + \Closure $fixtures, + Request $request, + ?FormFactoryInterface $formFactory, + JobSecurity $security, + TemplatingInterface $templating, + int $expectedStatus, + int $expectedCountExecutions = 0, + int $expectedCountPages = 0, + ): void { + $fixtures(); + + $response = $this->response( + fn(JobController $controller) => $controller->list($request), + $formFactory, + $security, + $templating, + ); + + self::assertResponseStatusCodeSame($response, $expectedStatus); + if ($expectedStatus === Response::HTTP_OK) { + $page = new Crawler((string)$response->getContent()); + self::assertCount($expectedCountExecutions, $page->filter('.job-list > tbody > tr')); + self::assertCount($expectedCountPages, $page->filter('.pagination a[href]')); + } + } + + public static function list(): \Generator + { + foreach (self::formFactories() as $formFactory) { + foreach (self::securities() as [$security, $granted]) { + $status = $granted ? Response::HTTP_OK : Response::HTTP_FORBIDDEN; + foreach (self::templatings() as $templating) { + yield [ + fn() => self::fixtures(30), + Request::create('/jobs'), + $formFactory, + $security, + $templating, + $status, + 20, + 1, + ]; + yield [ + fn() => self::fixtures(30), + Request::create('/jobs?sort=end_asc'), + $formFactory, + $security, + $templating, + $status, + 20, + 1, + ]; + yield [ + fn() => null, + Request::create('/jobs?sort=unknown'), + $formFactory, + $security, + $templating, + $granted ? Response::HTTP_BAD_REQUEST : $status, + ]; + yield [ + fn() => self::fixtures(30), + Request::create('/jobs?page=2'), + $formFactory, + $security, + $templating, + $status, + 10, + 1, + ]; + + // filtering is only possible when symfony/form is installed + if ($formFactory !== null) { + yield [ + function () { + self::fixtures(10, ['jobName' => 'export']); + self::fixtures(10, ['jobName' => 'import']); + }, + Request::create('/jobs?filter[jobs][]=export'), + $formFactory, + $security, + $templating, + $status, + 10, + 0, + ]; + yield [ + function () { + self::fixtures(6, ['status' => BatchStatus::PENDING]); + self::fixtures(4, ['status' => BatchStatus::RUNNING]); + self::fixtures(10, ['status' => BatchStatus::COMPLETED]); + }, + Request::create('/jobs?filter[statuses][]=1'), + $formFactory, + $security, + $templating, + $status, + 6, + 0, + ]; + yield [ + function () { + self::fixtures(30, ['jobName' => 'export', 'status' => BatchStatus::PENDING]); + self::fixtures(5, ['jobName' => 'import']); + self::fixtures(5, ['status' => BatchStatus::COMPLETED]); + }, + Request::create('/jobs?filter[jobs][]=export&filter[statuses][]=1&page=2'), + $formFactory, + $security, + $templating, + $status, + 10, + 1, + ]; + } + } + } + } + } + + /** + * @dataProvider view + */ + public function testView( + \Closure $fixtures, + string $job, + string $id, + ?string $path, + JobSecurity $security, + TemplatingInterface $templating, + int $expectedStatus, + array $expected = [], + ): void { + $fixtures(); + + $response = $this->response( + fn(JobController $controller) => $controller->view($job, $id, $path), + null, + $security, + $templating, + ); + + self::assertResponseStatusCodeSame($response, $expectedStatus); + if ($expectedStatus === Response::HTTP_OK) { + $page = new Crawler((string)$response->getContent()); + foreach ($expected as $value) { + self::assertSelectorTextContains($page, '.job-show', $value); + } + } + } + + public static function view(): \Generator + { + foreach (self::securities() as [$security, $granted]) { + $status = $granted ? Response::HTTP_OK : Response::HTTP_FORBIDDEN; + foreach (self::templatings() as $templating) { + $jobWithChildrenFixtures = function () { + $exportExecution = JobExecution::createRoot( + '64edbe399b58e', + 'export', + new BatchStatus(BatchStatus::COMPLETED), + new JobParameters(['type' => 'complete']), + new Summary(['count' => 156]), + ); + $exportExecution->addWarning(new Warning('Skipped suspicious record', [], ['suspicious_record' => 2])); + $exportExecution->addFailure(new Failure('RuntimeException', 'Missing record #2', 0)); + $exportExecution->setStartTime(new \DateTimeImmutable('2021-01-01 10:00')); + $exportExecution->setEndTime(new \DateTimeImmutable('2021-01-01 11:00')); + $exportExecution->addChildExecution( + JobExecution::createChild( + $exportExecution, + 'download', + new BatchStatus(BatchStatus::COMPLETED), + ), + ); + $exportExecution->addChildExecution( + JobExecution::createChild( + $exportExecution, + 'transform', + new BatchStatus(BatchStatus::RUNNING), + ), + ); + $exportExecution->addChildExecution( + JobExecution::createChild( + $exportExecution, + 'upload', + new BatchStatus(BatchStatus::PENDING), + ), + ); + self::$storage->store($exportExecution); + }; + yield [ + $jobWithChildrenFixtures, + 'export', + '64edbe399b58e', + null, + $security, + $templating, + $status, + [ + 'Execution ID 64edbe399b58e', + 'Job name job.job_name.export', + 'Status Completed', + 'Start time January 1, 2021 10:00', + 'End time January 1, 2021 11:00', + '"type": "complete"', + '"count": 156', + 'Skipped suspicious record', + '"suspicious_record": 2', + 'RuntimeException', + 'Missing record #2', + ], + ]; + yield [ + $jobWithChildrenFixtures, + 'export', + '64edbe399b58e', + 'transform', + $security, + $templating, + $status, + [ + 'Execution ID 64edbe399b58e', + 'Job name job.job_name.transform', + 'Status Running', + ], + ]; + yield [ + $jobWithChildrenFixtures, + 'export', + '64edbe399b58e', + 'unknown.children', + $security, + $templating, + $status === Response::HTTP_OK ? Response::HTTP_NOT_FOUND : $status, + ]; + yield [ + fn() => null, + 'job.unknown', + 'unknown_id', + null, + $security, + $templating, + Response::HTTP_NOT_FOUND, + ]; + } + } + } + + /** + * @dataProvider logs + */ + public function testLogs( + \Closure $fixtures, + string $job, + string $id, + JobSecurity $security, + int $expectedStatus, + string $expectedLogs = '', + ): void { + $fixtures(); + + $response = $this->response( + fn(JobController $controller) => $controller->logs($job, $id), + null, + $security, + new ConfigurableTemplating('unused', []), + ); + + self::assertSame($expectedStatus, $response->getStatusCode()); + if ($expectedStatus === Response::HTTP_OK) { + self::assertSame("attachment; filename={$job}-{$id}.log", $response->headers->get('Content-Disposition')); + self::assertSame('application/log', $response->headers->get('Content-Type')); + self::assertSame($expectedLogs, $response->getContent()); + } + } + + public static function logs(): \Generator + { + foreach (self::securities() as [$security, $granted]) { + $status = $granted ? Response::HTTP_OK : Response::HTTP_FORBIDDEN; + yield [ + function () { + $execution = JobExecution::createRoot( + '64f1f6d5e7e18', + 'export', + logs: new JobExecutionLogs( + <<store($execution); + }, + 'export', + '64f1f6d5e7e18', + $security, + $status, + << null, + 'job.unknown', + 'unknown_id', + $security, + Response::HTTP_NOT_FOUND, + ]; + } + } + + /** + * @return \Generator + */ + private static function formFactories(): \Generator + { + yield null; + yield Forms::createFormFactoryBuilder() + ->addExtensions([ + new CsrfExtension(new CsrfTokenManager()), + ]) + ->addTypeExtensions([ + new FormTypeHttpFoundationExtension(), + ]) + ->addTypes([ + new JobFilterType(['export', 'import']), + ]) + ->getFormFactory(); + } + + /** + * @return \Generator + */ + private static function securities(): \Generator + { + foreach ([true, false] as $granted) { + yield [ + new JobSecurity( + new class($granted) implements AuthorizationCheckerInterface { + public function __construct(private bool $granted) + { + } + + public function isGranted(mixed $attribute, mixed $subject = null): bool + { + return $this->granted; + } + }, + 'ROLE_UNUSED', + 'ROLE_UNUSED', + 'ROLE_UNUSED', + 'ROLE_UNUSED', + ), + $granted, + ]; + } + } + + /** + * @return \Generator + */ + private static function templatings(): \Generator + { + yield new ConfigurableTemplating('@YokaiBatch/bootstrap4', ['base_template' => 'base.html.twig']); + //todo one day, test with sonata, but it is too much pain to setup actually + } + + /** + * @param \Closure(JobController): Response $closure + */ + private function response( + \Closure $closure, + ?FormFactoryInterface $formFactory, + JobSecurity $security, + TemplatingInterface $templating, + ): Response { + try { + return $closure($this->controller($formFactory, $security, $templating)); + } catch (HttpException $exception) { + return new Response(status: $exception->getStatusCode()); + } catch (AccessDeniedException) { + return new Response(status: Response::HTTP_FORBIDDEN); + } + } + + private function controller( + ?FormFactoryInterface $formFactory, + JobSecurity $security, + TemplatingInterface $templating, + ): JobController { + $twig = new Environment( + $loader = new FilesystemLoader( + \array_filter([ + __DIR__ . '/templates', + __DIR__ . '/../../../../../vendor/symfony/twig-bridge/Resources/views/Form', + __DIR__ . '/../../../vendor/symfony/twig-bridge/Resources/views/Form', + ], 'is_dir'), + ), + ); + $loader->addPath(__DIR__ . '/../../../src/Resources/views', 'YokaiBatch'); + $translator = new Translator('en'); + $translator->addLoader('xlf', new XliffFileLoader()); + $translator->addResource( + 'xlf', + __DIR__ . '/../../../src/Resources/translations/YokaiBatchBundle.en.xlf', + 'en', + 'YokaiBatchBundle', + ); + $twig->addExtension(new TranslationExtension($translator)); + $twig->addExtension(new FormExtension()); + $twig->addExtension( + new RoutingExtension( + new UrlGenerator( + (new XmlFileLoader(new FileLocator()))->load(__DIR__ . '/../../../src/Resources/routing/ui.xml'), + new RequestContext(), + ), + ), + ); + $twig->addExtension(new TwigExtension($security)); + $twig->addRuntimeLoader(new FactoryRuntimeLoader([ + FormRenderer::class => fn() => new FormRenderer( + new TwigRendererEngine(['bootstrap_4_layout.html.twig'], $twig), + new CsrfTokenManager(), + ), + ])); + + return new JobController(self::$storage, $formFactory, $security, $twig, $templating); + } + + private static function fixtures(int $count, array $attributes = []): void + { + for ($i = 0; $i < $count; $i++) { + $values = \array_merge( + [ + 'id' => \uniqid(), + 'jobName' => \array_rand(\array_flip(['export', 'import'])), + 'status' => \array_rand(\array_flip([ + BatchStatus::PENDING, + BatchStatus::RUNNING, + BatchStatus::COMPLETED, + BatchStatus::FAILED, + ])), + 'startTime' => $start = (new \DateTimeImmutable())->setTimestamp(\random_int(0, \time() - 10)), + 'endTime' => (new \DateTimeImmutable())->setTimestamp(\random_int($start->getTimestamp(), \time() - 10)), + ], + $attributes, + ); + + $execution = JobExecution::createRoot( + $values['id'], + $values['jobName'], + new BatchStatus($values['status']), + ); + if (!$execution->getStatus()->is(BatchStatus::PENDING)) { + $execution->setStartTime($values['startTime']); + if (!$execution->getStatus()->is(BatchStatus::RUNNING)) { + $execution->setEndTime($values['endTime']); + } + } + + self::$storage->store($execution); + } + } + + private static function assertSelectorTextContains(Crawler $crawler, string $selector, string $text): void + { + self::assertThat($crawler, LogicalAnd::fromConstraints( + new DomCrawlerConstraint\CrawlerSelectorExists($selector), + new DomCrawlerConstraint\CrawlerSelectorTextContains($selector, $text) + )); + } + + private static function assertResponseStatusCodeSame(Response $response, int $expectedCode): void + { + self::assertThat($response, new ResponseConstraint\ResponseStatusCodeSame($expectedCode)); + } +} diff --git a/src/batch-symfony-framework/tests/UserInterface/Controller/templates/base.html.twig b/src/batch-symfony-framework/tests/UserInterface/Controller/templates/base.html.twig new file mode 100644 index 00000000..a2e41ab2 --- /dev/null +++ b/src/batch-symfony-framework/tests/UserInterface/Controller/templates/base.html.twig @@ -0,0 +1,16 @@ + + + + + {% block title %}Welcome!{% endblock %} + + {% block stylesheets %} + {% endblock %} + + {% block javascripts %} + {% endblock %} + + +{% block body %}{% endblock %} + + diff --git a/src/batch-symfony-framework/tests/UserInterface/Form/JobFilterTypeTest.php b/src/batch-symfony-framework/tests/UserInterface/Form/JobFilterTypeTest.php new file mode 100644 index 00000000..6588dbe7 --- /dev/null +++ b/src/batch-symfony-framework/tests/UserInterface/Form/JobFilterTypeTest.php @@ -0,0 +1,97 @@ +factory->create(JobFilterType::class, new JobFilter()); + $view = $form->createView(); + + $choices = function (FormView $view) { + $choices = []; + /** @var ChoiceView $choice */ + foreach ($view->vars['choices'] as $choice) { + $choices[(string)$choice->label] = $choice->data; + } + + return $choices; + }; + + self::assertSame( + [ + 'job.job_name.export' => 'export', + 'job.job_name.import' => 'import', + ], + $choices($view->children['jobs']), + ); + self::assertSame( + [ + 'job.status.pending' => BatchStatus::PENDING, + 'job.status.running' => BatchStatus::RUNNING, + 'job.status.stopped' => BatchStatus::STOPPED, + 'job.status.completed' => BatchStatus::COMPLETED, + 'job.status.abandoned' => BatchStatus::ABANDONED, + 'job.status.failed' => BatchStatus::FAILED, + ], + $choices($view->children['statuses']), + ); + } + + /** + * @dataProvider submit + */ + public function testSubmit(array $submit, JobFilter $expected, bool $valid): void + { + $form = $this->factory->create(JobFilterType::class, $actual = new JobFilter()); + $form->submit($submit); + + self::assertTrue($form->isSynchronized(), (string)$form->getTransformationFailure()); + self::assertSame($valid, $form->isValid(), (string)$form->getErrors(true, false)); + self::assertEquals($expected, $actual); + } + + public static function submit(): \Generator + { + yield [ + [], + new JobFilter(), + true, + ]; + + yield [ + ['jobs' => [], 'statuses' => []], + new JobFilter(), + true, + ]; + + yield [ + ['jobs' => ['export'], 'statuses' => [BatchStatus::PENDING]], + new JobFilter(['export'], [BatchStatus::PENDING]), + true, + ]; + + yield [ + ['jobs' => ['unknown'], 'statuses' => [99]], + new JobFilter(), + false, + ]; + } + + protected function getTypes(): array + { + return [ + new JobFilterType(['export', 'import']), + ]; + } +} diff --git a/src/batch-symfony-framework/tests/UserInterface/JobSecurityTest.php b/src/batch-symfony-framework/tests/UserInterface/JobSecurityTest.php new file mode 100644 index 00000000..d8c6aaa5 --- /dev/null +++ b/src/batch-symfony-framework/tests/UserInterface/JobSecurityTest.php @@ -0,0 +1,116 @@ + true, + self::VIEW => true, + self::TRACES => true, + self::LOGS => true, + ]; + self::assertEquals($expected, $this->test($security)); + } + + /** + * @dataProvider withSecurity + */ + public function testWithSecurity(array $attributes, array $expected): void + { + $authorizationChecker = new class($attributes) implements AuthorizationCheckerInterface { + public function __construct(private array $attributes) + { + } + + public function isGranted(mixed $attribute, mixed $subject = null): bool + { + return in_array($attribute, $this->attributes, true) + && ($subject === null || $subject instanceof JobExecution); + } + }; + + $security = new JobSecurity($authorizationChecker, self::LIST, self::VIEW, self::TRACES, self::LOGS); + self::assertEquals($expected, $this->test($security)); + } + + public static function withSecurity(): \Generator + { + $defaults = [self::LIST => false, self::VIEW => false, self::TRACES => false, self::LOGS => false]; + + yield [ + [self::LIST], + [self::LIST => true] + $defaults, + ]; + yield [ + [self::VIEW], + [self::VIEW => true] + $defaults, + ]; + yield [ + [self::TRACES], + [self::TRACES => true] + $defaults, + ]; + yield [ + [self::LOGS], + [self::LOGS => true] + $defaults, + ]; + yield [ + [self::LIST, self::VIEW, self::TRACES, self::LOGS], + [self::LIST => true, self::VIEW => true, self::TRACES => true, self::LOGS => true], + ]; + } + + private function test(JobSecurity $security): array + { + $execution = JobExecution::createRoot('64edbf4d9ec24', 'foo'); + + $votes = [ + self::LIST => true, + self::VIEW => true, + self::TRACES => true, + self::LOGS => true, + ]; + + try { + $security->denyAccessUnlessGrantedList(); + } catch (AccessDeniedException) { + $votes[self::LIST] = false; + } + + try { + $security->denyAccessUnlessGrantedView($execution); + } catch (AccessDeniedException) { + $votes[self::VIEW] = false; + } + + try { + $security->denyAccessUnlessGrantedTraces($execution); + } catch (AccessDeniedException) { + $votes[self::TRACES] = false; + } + + try { + $security->denyAccessUnlessGrantedLogs($execution); + } catch (AccessDeniedException) { + $votes[self::LOGS] = false; + } + + return $votes; + } +} diff --git a/src/batch-symfony-framework/tests/UserInterface/Templating/ConfigurableTemplatingTest.php b/src/batch-symfony-framework/tests/UserInterface/Templating/ConfigurableTemplatingTest.php new file mode 100644 index 00000000..bc733a4b --- /dev/null +++ b/src/batch-symfony-framework/tests/UserInterface/Templating/ConfigurableTemplatingTest.php @@ -0,0 +1,28 @@ + '{{ _context|json_encode|raw }}', + ]), + ); + $templating = new ConfigurableTemplating('@YokaiBatch/prefix', ['bar' => 'bar']); + + self::assertSame( + '{"bar":"bar","foo":"foo"}', + $twig->render($templating->name('main.html.twig'), $templating->context(['foo' => 'foo'])), + ); + } +} diff --git a/src/batch-symfony-framework/tests/UserInterface/Templating/SonataAdminTemplatingTest.php b/src/batch-symfony-framework/tests/UserInterface/Templating/SonataAdminTemplatingTest.php new file mode 100644 index 00000000..03a2063a --- /dev/null +++ b/src/batch-symfony-framework/tests/UserInterface/Templating/SonataAdminTemplatingTest.php @@ -0,0 +1,34 @@ + '{{ _context|json_encode|raw }}', + ]), + ); + $templating = new SonataAdminTemplating( + new TemplateRegistry([ + 'layout' => '@SonataAdmin/layout.html.twig', + 'filter' => '@SonataAdmin/filter.html.twig', + ]), + ); + + self::assertSame( + '{"base_template":"@SonataAdmin\/layout.html.twig","filter_template":"@SonataAdmin\/filter.html.twig","foo":"foo"}', + $twig->render($templating->name('main.html.twig'), $templating->context(['foo' => 'foo'])), + ); + } +} diff --git a/src/batch-symfony-framework/tests/UserInterface/TwigExtensionTest.php b/src/batch-symfony-framework/tests/UserInterface/TwigExtensionTest.php new file mode 100644 index 00000000..075f80af --- /dev/null +++ b/src/batch-symfony-framework/tests/UserInterface/TwigExtensionTest.php @@ -0,0 +1,61 @@ + <<addExtension(new TwigExtension($security)); + + self::assertSame( + $granted ? 'list view traces logs' : '', + $twig->render('default', ['execution' => JobExecution::createRoot('export', '64f05ed2c172a')]), + ); + } + + public static function security(): \Generator + { + foreach ([true, false] as $granted) { + yield [ + new JobSecurity( + new class($granted) implements AuthorizationCheckerInterface { + public function __construct(private bool $granted) + { + } + + public function isGranted(mixed $attribute, mixed $subject = null): bool + { + return $this->granted; + } + }, + 'ROLE_UNUSED', + 'ROLE_UNUSED', + 'ROLE_UNUSED', + 'ROLE_UNUSED', + ), + $granted, + ]; + } + } +} diff --git a/tests/convention/Dependency/PackagesTest.php b/tests/convention/Dependency/PackagesTest.php index 40254ec9..0f43d3dc 100644 --- a/tests/convention/Dependency/PackagesTest.php +++ b/tests/convention/Dependency/PackagesTest.php @@ -11,6 +11,17 @@ final class PackagesTest extends TestCase { + /** + * Some package are wrapper around others + * This map helps to remove false negative + */ + private const ALIASES = [ + 'symfony/twig-bundle' => ['twig/twig'], + 'symfony/security-bundle' => ['symfony/security-core'], + 'symfony/form' => ['symfony/options-resolver'], + 'symfony/http-kernel' => ['symfony/http-foundation'], + ]; + /** * Every individual package must declare explicit dependencies, based on use statements. * @@ -28,9 +39,16 @@ public function testPackagesAreUsingRequiredClasses(Package $package): void if (!\str_contains($require, '/')) { continue; // php & extensions } - $requirePackage = Packages::getPackage($require); - foreach (\array_keys($requirePackage->composer->autoload()) as $prefix) { - $requirePrefixes[] = $prefix; + + $add = \array_merge( + [$require], + self::ALIASES[$require] ?? [], + ); + foreach ($add as $name) { + $requirePackage = Packages::getPackage($name); + foreach (\array_keys($requirePackage->composer->autoload()) as $prefix) { + $requirePrefixes[] = $prefix; + } } } diff --git a/tests/convention/Dependency/SourcesTest.php b/tests/convention/Dependency/SourcesTest.php index 95c401cf..0c461be3 100644 --- a/tests/convention/Dependency/SourcesTest.php +++ b/tests/convention/Dependency/SourcesTest.php @@ -51,6 +51,10 @@ public function test(): void $expectedDevDeps = \array_unique($expectedDevDeps); \sort($expectedDevDeps); $devDeps = $rootComposer->packagesDev(); + // regarding packages, this should be a "require-dev" dependency, + // but because Symfony framework expect it in "require" to register related services, + // we are required to add it in "require" + $devDeps[] = 'symfony/form'; \sort($devDeps); self::assertSame( [], diff --git a/tests/symfony/src/Kernel.php b/tests/symfony/src/Kernel.php index 4875d830..2685c9eb 100644 --- a/tests/symfony/src/Kernel.php +++ b/tests/symfony/src/Kernel.php @@ -7,10 +7,15 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Bundle\SecurityBundle\SecurityBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Kernel as BaseKernel; +use Symfony\Component\HttpKernel\Log\Logger; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; use Yokai\Batch\Bridge\Symfony\Framework\YokaiBatchBundle; use Yokai\Batch\Job\JobInterface; @@ -23,6 +28,8 @@ public function registerBundles(): iterable { yield new FrameworkBundle(); yield new DoctrineBundle(); + yield new TwigBundle(); + yield new SecurityBundle(); yield new YokaiBatchBundle(); } @@ -34,7 +41,15 @@ public function getProjectDir(): string protected function configureContainer(ContainerConfigurator $container): void { $container->extension('framework', [ + 'secret' => 'ThisIsNotSecret', 'test' => true, + 'default_locale' => 'en', + 'translator' => null, + 'csrf_protection' => true, + 'session' => [ + 'handler_id' => null, + 'storage_factory_id' => 'session.storage.factory.mock_file', + ], ]); $container->extension('doctrine', [ 'dbal' => [ @@ -55,12 +70,24 @@ protected function configureContainer(ContainerConfigurator $container): void ], ], ]); + $container->extension('twig', [ + 'default_path' => __DIR__ . '/../templates', + 'form_themes' => ['bootstrap_4_layout.html.twig'], + ]); $container->extension('yokai_batch', [ 'storage' => [ 'filesystem' => null, ], + 'ui' => [ + 'enabled' => true, + ], ]); + $container->services() + ->set('logger', Logger::class) + ->args([null, '%kernel.logs_dir%/test.log', null, new Reference(RequestStack::class)]) + ->private(); + $container->services() ->defaults() ->autoconfigure(true) @@ -73,6 +100,7 @@ protected function configureContainer(ContainerConfigurator $container): void protected function configureRoutes(RoutingConfigurator $routes): void { + $routes->import('@YokaiBatchBundle/Resources/routing/ui.xml'); } public function process(ContainerBuilder $container): void diff --git a/tests/symfony/templates/base.html.twig b/tests/symfony/templates/base.html.twig new file mode 100644 index 00000000..3ebdfa05 --- /dev/null +++ b/tests/symfony/templates/base.html.twig @@ -0,0 +1,19 @@ + + + + + {% block title %}Welcome!{% endblock %} + + {% block stylesheets %} + + + {% endblock %} + + {% block javascripts %} + + {% endblock %} + + +{% block body %}{% endblock %} + + diff --git a/tests/symfony/templates/bundles/YokaiBatchBundle/bootstrap4/show/country/_summary.html.twig b/tests/symfony/templates/bundles/YokaiBatchBundle/bootstrap4/show/country/_summary.html.twig new file mode 100644 index 00000000..166da8cf --- /dev/null +++ b/tests/symfony/templates/bundles/YokaiBatchBundle/bootstrap4/show/country/_summary.html.twig @@ -0,0 +1,10 @@ +{% extends "@YokaiBatch/bootstrap4/show/_summary.html.twig" %} + +{# @var execution \Yokai\Batch\JobExecution #} + +{% block body %} +

    Count: {{ execution.summary.get('countries')|length }}

    + {% for country in execution.summary.get('countries') %} + {{ country.name }} + {% endfor %} +{% endblock %} diff --git a/tests/symfony/tests/JobTest.php b/tests/symfony/tests/JobTest.php index 0938ff03..3e27e0cb 100644 --- a/tests/symfony/tests/JobTest.php +++ b/tests/symfony/tests/JobTest.php @@ -73,7 +73,7 @@ public function testUsingLauncher(string $job, callable $assert, callable $setup $assert($execution, $container); } - public function configs(): Generator + public static function configs(): Generator { yield from CountryJobSet::sets(); yield from StarWarsJobSet::sets(); diff --git a/tests/symfony/tests/UserInterfaceTest.php b/tests/symfony/tests/UserInterfaceTest.php new file mode 100644 index 00000000..5fde99a5 --- /dev/null +++ b/tests/symfony/tests/UserInterfaceTest.php @@ -0,0 +1,174 @@ + 'Country import', + 'star-wars.import' => 'Star Wars import', + ]; + + /** + * @var array + */ + private static array $executions; + + public static function setUpBeforeClass(): void + { + (new Filesystem())->remove(__DIR__ . '/../var/batch/'); + + $container = self::getContainer(); + /** @var JobLauncherInterface $launcher */ + $launcher = $container->get('yokai_batch.job_launcher.simple'); + /** @var array $set */ + foreach (JobTest::configs() as $set) { + /** + * @var string $job + * @var \Closure|null $setup + * @var array $config + */ + [0 => $job, 2 => $setup, 3 => $config] = \array_replace([null, null, null, []], $set); + + if (isset(self::$executions[$job])) { + continue; + } + + if ($setup) { + $setup($container); + } + + self::$executions[$job] = $launcher->launch($job, $config); + } + self::ensureKernelShutdown(); + } + + public function testList(): void + { + $http = self::createClient(); + $page = $http->request('get', '/jobs'); + + self::assertResponseIsSuccessful(); + self::assertCount(\count(self::$executions), $page->filter('.job-list > tbody > tr')); + foreach (self::$executions as $execution) { + self::assertSelectorTextContains( + '.job-list > tbody > tr:contains("' . $execution->getId() . '") > td:nth-child(2)', + self::TRANSLATIONS[$execution->getJobName()], + ); + self::assertSelectorTextContains( + '.job-list > tbody > tr:contains("' . $execution->getId() . '") > td:nth-child(3)', + 'Completed', + ); + } + } + + /** + * @dataProvider view + */ + public function testView(string $job, \Closure $expected): void + { + $execution = self::$executions[$job]; + + $http = self::createClient(); + $page = $http->request('get', "/jobs/{$execution->getJobName()}/{$execution->getId()}"); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('.job-show', "Execution ID {$execution->getId()}"); + $expected($page); + } + + public static function view() + { + yield [ + CountryJob::getJobName(), + static function () { + self::assertSelectorTextContains('.job-show', 'Job name Country import'); + self::assertSelectorTextContains('.job-show', 'Status Completed'); + self::assertSelectorTextContains('.job-show', 'Count: 250'); + if (\method_exists(self::class, 'assertSelectorCount')) { + self::assertSelectorCount(250, '.job-show img'); + } + self::assertSelectorExists('.job-show img[src="https://flagcdn.com/48x36/fr.png"]'); + }, + ]; + yield [ + ImportStarWarsJob::getJobName(), + static function (Crawler $page) { + self::assertSelectorTextContains('.job-show', 'Job name Star Wars import'); + self::assertSelectorTextContains('.job-show', 'Status Completed'); + self::assertCount(3, $page->filter('#children > table > tbody > tr')); + self::assertSelectorTextContains('#children > table > tbody', 'Star Wars planets import'); + self::assertSelectorTextContains('#children > table > tbody', 'Star Wars species import'); + self::assertSelectorTextContains('#children > table > tbody', 'Star Wars characters import'); + }, + ]; + } + + /** + * @dataProvider logs + */ + public function testLogs(string $job, \Closure $expected): void + { + $execution = self::$executions[$job]; + + $http = self::createClient(); + $http->request('get', "/jobs/{$execution->getJobName()}/{$execution->getId()}/logs"); + + self::assertResponseIsSuccessful(); + $expected($http->getResponse()->getContent()); + } + + public static function logs() + { + yield [ + CountryJob::getJobName(), + static function (string $content) { + self::assertStringContainsString('Starting job {"job":"country"}', $content); + self::assertStringContainsString('Job produced summary', $content); + self::assertStringContainsString('"name":"France"', $content); + self::assertStringContainsString('Job executed successfully {"job":"country"', $content); + }, + ]; + yield [ + ImportStarWarsJob::getJobName(), + static function (string $content) { + self::assertStringContainsString('Starting job {"job":"star-wars.import"}', $content); + + self::assertStringContainsString('Starting child job {"job":"star-wars.import:planet"}', $content); + self::assertStringContainsString('Job produced summary {"job":"star-wars.import:planet","read":61,"processed":60,"skipped":1,"invalid":1,"write":60}', $content); + self::assertStringContainsString('Job executed successfully {"job":"star-wars.import:planet"', $content); + + self::assertStringContainsString('Starting child job {"job":"star-wars.import:specie"}', $content); + self::assertStringContainsString('Job produced summary {"job":"star-wars.import:specie","read":37,"processed":37,"write":37}', $content); + self::assertStringContainsString('Job executed successfully {"job":"star-wars.import:specie"', $content); + + self::assertStringContainsString('Starting child job {"job":"star-wars.import:character"}', $content); + self::assertStringContainsString('Job produced summary {"job":"star-wars.import:character","read":87,"processed":87,"write":87}', $content); + self::assertStringContainsString('Job executed successfully {"job":"star-wars.import:character"', $content); + + self::assertStringContainsString('Job executed successfully {"job":"star-wars.import"', $content); + }, + ]; + } + + public static function assertSelectorAttributeSame( + Crawler $crawler, + string $selector, + string $attribute, + string $expected, + ): void { + self::assertSelectorExists($selector); + $element = $crawler->filter($selector); + self::assertSame($expected, $element->attr($attribute)); + } +} diff --git a/tests/symfony/translations/YokaiBatchBundle.en.php b/tests/symfony/translations/YokaiBatchBundle.en.php new file mode 100644 index 00000000..5f4176d4 --- /dev/null +++ b/tests/symfony/translations/YokaiBatchBundle.en.php @@ -0,0 +1,9 @@ + 'Country import', + 'job.job_name.star-wars.import' => 'Star Wars import', + 'job.job_name.star-wars.import:character' => 'Star Wars characters import', + 'job.job_name.star-wars.import:planet' => 'Star Wars planets import', + 'job.job_name.star-wars.import:specie' => 'Star Wars species import', +];