diff --git a/AppVariable.php b/AppVariable.php index 23683eb3..e7b976e3 100644 --- a/AppVariable.php +++ b/AppVariable.php @@ -13,10 +13,12 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; +use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Translation\LocaleSwitcher; /** * Exposes some Symfony parameters and services as an "app" global variable. @@ -25,78 +27,77 @@ */ class AppVariable { - private $tokenStorage; - private $requestStack; - private $environment; - private $debug; - - public function setTokenStorage(TokenStorageInterface $tokenStorage) + private TokenStorageInterface $tokenStorage; + private RequestStack $requestStack; + private string $environment; + private bool $debug; + private LocaleSwitcher $localeSwitcher; + private array $enabledLocales; + + public function setTokenStorage(TokenStorageInterface $tokenStorage): void { $this->tokenStorage = $tokenStorage; } - public function setRequestStack(RequestStack $requestStack) + public function setRequestStack(RequestStack $requestStack): void { $this->requestStack = $requestStack; } - public function setEnvironment(string $environment) + public function setEnvironment(string $environment): void { $this->environment = $environment; } - public function setDebug(bool $debug) + public function setDebug(bool $debug): void { $this->debug = $debug; } + public function setLocaleSwitcher(LocaleSwitcher $localeSwitcher): void + { + $this->localeSwitcher = $localeSwitcher; + } + + public function setEnabledLocales(array $enabledLocales): void + { + $this->enabledLocales = $enabledLocales; + } + /** * Returns the current token. * - * @return TokenInterface|null - * * @throws \RuntimeException When the TokenStorage is not available */ - public function getToken() + public function getToken(): ?TokenInterface { - if (null === $tokenStorage = $this->tokenStorage) { + if (!isset($this->tokenStorage)) { throw new \RuntimeException('The "app.token" variable is not available.'); } - return $tokenStorage->getToken(); + return $this->tokenStorage->getToken(); } /** * Returns the current user. * - * @return UserInterface|null - * * @see TokenInterface::getUser() */ - public function getUser() + public function getUser(): ?UserInterface { - if (null === $tokenStorage = $this->tokenStorage) { + if (!isset($this->tokenStorage)) { throw new \RuntimeException('The "app.user" variable is not available.'); } - if (!$token = $tokenStorage->getToken()) { - return null; - } - - $user = $token->getUser(); - - // @deprecated since Symfony 5.4, $user will always be a UserInterface instance - return \is_object($user) ? $user : null; + return $this->tokenStorage->getToken()?->getUser(); } /** * Returns the current request. - * - * @return Request|null */ - public function getRequest() + public function getRequest(): ?Request { - if (null === $this->requestStack) { + if (!isset($this->requestStack)) { throw new \RuntimeException('The "app.request" variable is not available.'); } @@ -105,27 +106,23 @@ public function getRequest() /** * Returns the current session. - * - * @return Session|null */ - public function getSession() + public function getSession(): ?SessionInterface { - if (null === $this->requestStack) { + if (!isset($this->requestStack)) { throw new \RuntimeException('The "app.session" variable is not available.'); } $request = $this->getRequest(); - return $request && $request->hasSession() ? $request->getSession() : null; + return $request?->hasSession() ? $request->getSession() : null; } /** * Returns the current app environment. - * - * @return string */ - public function getEnvironment() + public function getEnvironment(): string { - if (null === $this->environment) { + if (!isset($this->environment)) { throw new \RuntimeException('The "app.environment" variable is not available.'); } @@ -134,33 +131,49 @@ public function getEnvironment() /** * Returns the current app debug mode. - * - * @return bool */ - public function getDebug() + public function getDebug(): bool { - if (null === $this->debug) { + if (!isset($this->debug)) { throw new \RuntimeException('The "app.debug" variable is not available.'); } return $this->debug; } + public function getLocale(): string + { + if (!isset($this->localeSwitcher)) { + throw new \RuntimeException('The "app.locale" variable is not available.'); + } + + return $this->localeSwitcher->getLocale(); + } + + public function getEnabled_locales(): array + { + if (!isset($this->enabledLocales)) { + throw new \RuntimeException('The "app.enabled_locales" variable is not available.'); + } + + return $this->enabledLocales; + } + /** * Returns some or all the existing flash messages: * * getFlashes() returns all the flash messages * * getFlashes('notice') returns a simple array with flash messages of that type * * getFlashes(['notice', 'error']) returns a nested array of type => messages. - * - * @return array */ - public function getFlashes($types = null) + public function getFlashes(string|array|null $types = null): array { try { - if (null === $session = $this->getSession()) { - return []; - } - } catch (\RuntimeException $e) { + $session = $this->getSession(); + } catch (\RuntimeException) { + return []; + } + + if (!$session instanceof FlashBagAwareSessionInterface) { return []; } @@ -179,4 +192,25 @@ public function getFlashes($types = null) return $result; } + + public function getCurrent_route(): ?string + { + if (!isset($this->requestStack)) { + throw new \RuntimeException('The "app.current_route" variable is not available.'); + } + + return $this->getRequest()?->attributes->get('_route'); + } + + /** + * @return array + */ + public function getCurrent_route_parameters(): array + { + if (!isset($this->requestStack)) { + throw new \RuntimeException('The "app.current_route_parameters" variable is not available.'); + } + + return $this->getRequest()?->attributes->get('_route_params') ?? []; + } } diff --git a/Attribute/Template.php b/Attribute/Template.php new file mode 100644 index 00000000..ef2f193b --- /dev/null +++ b/Attribute/Template.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Attribute; + +/** + * Define the template to render in the controller. + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +class Template +{ + /** + * @param string $template The name of the template to render + * @param string[]|null $vars The controller method arguments to pass to the template + * @param bool $stream Enables streaming the template + * @param string|null $block The name of the block to use in the template + */ + public function __construct( + public string $template, + public ?array $vars = null, + public bool $stream = false, + public ?string $block = null, + ) { + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 535df0c0..b18e2745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,47 @@ CHANGELOG ========= +7.2 +--- + + * Deprecate passing a tag to the constructor of `FormThemeNode` + +7.1 +--- + + * Add `emojify` Twig filter + +7.0 +--- + + * Drop support for Twig 2 + +6.4 +--- + + * Allow an array to be passed as the first argument to the `importmap()` Twig function + * Add `TemplatedEmail::locale()` to set the locale for the email rendering + * Add `AppVariable::getEnabledLocales()` to retrieve the enabled locales + * Add `impersonation_path()` and `impersonation_url()` Twig functions + +6.3 +--- + + * Add `AppVariable::getLocale()` to retrieve the current locale when using the `LocaleSwitcher` + +6.2 +--- + + * Add `form_label_content` and `form_help_content` block to form themes + * Add `#[Template()]` to describe how to render arrays returned by controllers + * Add support for toggle buttons in Bootstrap 5 form theme + * Add `app.current_route` and `app.current_route_parameters` variables + +6.1 +--- + + * Wrap help messages on form elements in `div` instead of `p` + 5.4 --- diff --git a/Command/DebugCommand.php b/Command/DebugCommand.php index 0510df58..c145a7ef 100644 --- a/Command/DebugCommand.php +++ b/Command/DebugCommand.php @@ -11,6 +11,7 @@ namespace Symfony\Bridge\Twig\Command; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; @@ -21,8 +22,8 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter; use Symfony\Component\Finder\Finder; -use Symfony\Component\HttpKernel\Debug\FileLinkFormatter; use Twig\Environment; use Twig\Loader\ChainLoader; use Twig\Loader\FilesystemLoader; @@ -32,38 +33,32 @@ * * @author Jordi Boggiano */ +#[AsCommand(name: 'debug:twig', description: 'Show a list of twig functions, filters, globals and tests')] class DebugCommand extends Command { - protected static $defaultName = 'debug:twig'; - protected static $defaultDescription = 'Show a list of twig functions, filters, globals and tests'; - - private $twig; - private $projectDir; - private $bundlesMetadata; - private $twigDefaultPath; - private $filesystemLoaders; - private $fileLinkFormatter; - - public function __construct(Environment $twig, ?string $projectDir = null, array $bundlesMetadata = [], ?string $twigDefaultPath = null, ?FileLinkFormatter $fileLinkFormatter = null) - { + /** + * @var FilesystemLoader[] + */ + private array $filesystemLoaders; + + public function __construct( + private Environment $twig, + private ?string $projectDir = null, + private array $bundlesMetadata = [], + private ?string $twigDefaultPath = null, + private ?FileLinkFormatter $fileLinkFormatter = null, + ) { parent::__construct(); - - $this->twig = $twig; - $this->projectDir = $projectDir; - $this->bundlesMetadata = $bundlesMetadata; - $this->twigDefaultPath = $twigDefaultPath; - $this->fileLinkFormatter = $fileLinkFormatter; } - protected function configure() + protected function configure(): void { $this ->setDefinition([ new InputArgument('name', InputArgument::OPTIONAL, 'The template name'), new InputOption('filter', null, InputOption::VALUE_REQUIRED, 'Show details for all entries matching this filter'), - new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (text or json)', 'text'), + new InputOption('format', null, InputOption::VALUE_REQUIRED, \sprintf('The output format ("%s")', implode('", "', $this->getAvailableFormatOptions())), 'txt'), ]) - ->setDescription(self::$defaultDescription) ->setHelp(<<<'EOF' The %command.name% command outputs a list of twig functions, filters, globals and tests. @@ -88,26 +83,27 @@ protected function configure() ; } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $name = $input->getArgument('name'); $filter = $input->getOption('filter'); if (null !== $name && [] === $this->getFilesystemLoaders()) { - throw new InvalidArgumentException(sprintf('Argument "name" not supported, it requires the Twig loader "%s".', FilesystemLoader::class)); + throw new InvalidArgumentException(\sprintf('Argument "name" not supported, it requires the Twig loader "%s".', FilesystemLoader::class)); } - switch ($input->getOption('format')) { - case 'text': - $name ? $this->displayPathsText($io, $name) : $this->displayGeneralText($io, $filter); - break; - case 'json': - $name ? $this->displayPathsJson($io, $name) : $this->displayGeneralJson($io, $filter); - break; - default: - throw new InvalidArgumentException(sprintf('The format "%s" is not supported.', $input->getOption('format'))); + $format = $input->getOption('format'); + if ('text' === $format) { + trigger_deprecation('symfony/twig-bridge', '7.2', 'The "text" format is deprecated, use "txt" instead.'); + + $format = 'txt'; } + match ($format) { + 'txt' => $name ? $this->displayPathsText($io, $name) : $this->displayGeneralText($io, $filter), + 'json' => $name ? $this->displayPathsJson($io, $name) : $this->displayGeneralJson($io, $filter), + default => throw new InvalidArgumentException(\sprintf('Supported formats are "%s".', implode('", "', $this->getAvailableFormatOptions()))), + }; return 0; } @@ -119,11 +115,11 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti } if ($input->mustSuggestOptionValuesFor('format')) { - $suggestions->suggestValues(['text', 'json']); + $suggestions->suggestValues($this->getAvailableFormatOptions()); } } - private function displayPathsText(SymfonyStyle $io, string $name) + private function displayPathsText(SymfonyStyle $io, string $name): void { $file = new \ArrayIterator($this->findTemplateFiles($name)); $paths = $this->getLoaderPaths($name); @@ -131,7 +127,7 @@ private function displayPathsText(SymfonyStyle $io, string $name) $io->section('Matched File'); if ($file->valid()) { if ($fileLink = $this->getFileLink($file->key())) { - $io->block($file->current(), 'OK', sprintf('fg=black;bg=green;href=%s', $fileLink), ' ', true); + $io->block($file->current(), 'OK', \sprintf('fg=black;bg=green;href=%s', $fileLink), ' ', true); } else { $io->success($file->current()); } @@ -141,9 +137,9 @@ private function displayPathsText(SymfonyStyle $io, string $name) $io->section('Overridden Files'); do { if ($fileLink = $this->getFileLink($file->key())) { - $io->text(sprintf('* %s', $fileLink, $file->current())); + $io->text(\sprintf('* %s', $fileLink, $file->current())); } else { - $io->text(sprintf('* %s', $file->current())); + $io->text(\sprintf('* %s', $file->current())); } $file->next(); } while ($file->valid()); @@ -164,13 +160,11 @@ private function displayPathsText(SymfonyStyle $io, string $name) [$namespace, $shortname] = $this->parseTemplateName($name); $alternatives = $this->findAlternatives($shortname, $shortnames); if (FilesystemLoader::MAIN_NAMESPACE !== $namespace) { - $alternatives = array_map(function ($shortname) use ($namespace) { - return '@'.$namespace.'/'.$shortname; - }, $alternatives); + $alternatives = array_map(fn ($shortname) => '@'.$namespace.'/'.$shortname, $alternatives); } } - $this->error($io, sprintf('Template name "%s" not found', $name), $alternatives); + $this->error($io, \sprintf('Template name "%s" not found', $name), $alternatives); } $io->section('Configured Paths'); @@ -183,7 +177,7 @@ private function displayPathsText(SymfonyStyle $io, string $name) if (FilesystemLoader::MAIN_NAMESPACE === $namespace) { $message = 'No template paths configured for your application'; } else { - $message = sprintf('No template paths configured for "@%s" namespace', $namespace); + $message = \sprintf('No template paths configured for "@%s" namespace', $namespace); foreach ($this->getFilesystemLoaders() as $loader) { $namespaces = $loader->getNamespaces(); foreach ($this->findAlternatives($namespace, $namespaces) as $namespace) { @@ -200,7 +194,7 @@ private function displayPathsText(SymfonyStyle $io, string $name) } } - private function displayPathsJson(SymfonyStyle $io, string $name) + private function displayPathsJson(SymfonyStyle $io, string $name): void { $files = $this->findTemplateFiles($name); $paths = $this->getLoaderPaths($name); @@ -211,14 +205,14 @@ private function displayPathsJson(SymfonyStyle $io, string $name) $data['overridden_files'] = $files; } } else { - $data['matched_file'] = sprintf('Template name "%s" not found', $name); + $data['matched_file'] = \sprintf('Template name "%s" not found', $name); } $data['loader_paths'] = $paths; $io->writeln(json_encode($data)); } - private function displayGeneralText(SymfonyStyle $io, ?string $filter = null) + private function displayGeneralText(SymfonyStyle $io, ?string $filter = null): void { $decorated = $io->isDecorated(); $types = ['functions', 'filters', 'tests', 'globals']; @@ -252,7 +246,7 @@ private function displayGeneralText(SymfonyStyle $io, ?string $filter = null) } } - private function displayGeneralJson(SymfonyStyle $io, ?string $filter) + private function displayGeneralJson(SymfonyStyle $io, ?string $filter): void { $decorated = $io->isDecorated(); $types = ['functions', 'filters', 'tests', 'globals']; @@ -291,7 +285,7 @@ private function getLoaderPaths(?string $name = null): array } foreach ($namespaces as $namespace) { - $paths = array_map([$this, 'getRelativePath'], $loader->getPaths($namespace)); + $paths = array_map($this->getRelativePath(...), $loader->getPaths($namespace)); if (FilesystemLoader::MAIN_NAMESPACE === $namespace) { $namespace = '(None)'; @@ -306,7 +300,7 @@ private function getLoaderPaths(?string $name = null): array return $loaderPaths; } - private function getMetadata(string $type, $entity) + private function getMetadata(string $type, mixed $entity): mixed { if ('globals' === $type) { return $entity; @@ -350,21 +344,19 @@ private function getMetadata(string $type, $entity) } // format args - $args = array_map(function (\ReflectionParameter $param) { + return array_map(function (\ReflectionParameter $param) { if ($param->isDefaultValueAvailable()) { return $param->getName().' = '.json_encode($param->getDefaultValue()); } return $param->getName(); }, $args); - - return $args; } return null; } - private function getPrettyMetadata(string $type, $entity, bool $decorated): ?string + private function getPrettyMetadata(string $type, mixed $entity, bool $decorated): ?string { if ('tests' === $type) { return ''; @@ -376,17 +368,17 @@ private function getPrettyMetadata(string $type, $entity, bool $decorated): ?str return '(unknown?)'; } } catch (\UnexpectedValueException $e) { - return sprintf(' %s', $decorated ? OutputFormatter::escape($e->getMessage()) : $e->getMessage()); + return \sprintf(' %s', $decorated ? OutputFormatter::escape($e->getMessage()) : $e->getMessage()); } if ('globals' === $type) { if (\is_object($meta)) { - return ' = object('.\get_class($meta).')'; + return ' = object('.$meta::class.')'; } $description = substr(@json_encode($meta), 0, 50); - return sprintf(' = %s', $decorated ? OutputFormatter::escape($description) : $description); + return \sprintf(' = %s', $decorated ? OutputFormatter::escape($description) : $description); } if ('functions' === $type) { @@ -420,7 +412,6 @@ private function findWrongBundleOverrides(): array } if ($notFoundBundles = array_diff_key($bundleNames, $this->bundlesMetadata)) { - $alternatives = []; foreach ($notFoundBundles as $notFoundBundle => $path) { $alternatives[$path] = $this->findAlternatives($notFoundBundle, array_keys($this->bundlesMetadata)); } @@ -433,14 +424,14 @@ private function buildWarningMessages(array $wrongBundles): array { $messages = []; foreach ($wrongBundles as $path => $alternatives) { - $message = sprintf('Path "%s" not matching any bundle found', $path); + $message = \sprintf('Path "%s" not matching any bundle found', $path); if ($alternatives) { if (1 === \count($alternatives)) { - $message .= sprintf(", did you mean \"%s\"?\n", $alternatives[0]); + $message .= \sprintf(", did you mean \"%s\"?\n", $alternatives[0]); } else { $message .= ", did you mean one of these:\n"; foreach ($alternatives as $bundle) { - $message .= sprintf(" - %s\n", $bundle); + $message .= \sprintf(" - %s\n", $bundle); } } } @@ -493,7 +484,7 @@ private function parseTemplateName(string $name, string $default = FilesystemLoa { if (isset($name[0]) && '@' === $name[0]) { if (false === ($pos = strpos($name, '/')) || $pos === \strlen($name) - 1) { - throw new InvalidArgumentException(sprintf('Malformed namespaced template name "%s" (expecting "@namespace/template_name").', $name)); + throw new InvalidArgumentException(\sprintf('Malformed namespaced template name "%s" (expecting "@namespace/template_name").', $name)); } $namespace = substr($name, 1, $pos - 1); @@ -545,7 +536,7 @@ private function findAlternatives(string $name, array $collection): array } $threshold = 1e3; - $alternatives = array_filter($alternatives, function ($lev) use ($threshold) { return $lev < 2 * $threshold; }); + $alternatives = array_filter($alternatives, fn ($lev) => $lev < 2 * $threshold); ksort($alternatives, \SORT_NATURAL | \SORT_FLAG_CASE); return array_keys($alternatives); @@ -570,7 +561,7 @@ private function isAbsolutePath(string $file): bool */ private function getFilesystemLoaders(): array { - if (null !== $this->filesystemLoaders) { + if (isset($this->filesystemLoaders)) { return $this->filesystemLoaders; } $this->filesystemLoaders = []; @@ -591,10 +582,12 @@ private function getFilesystemLoaders(): array private function getFileLink(string $absolutePath): string { - if (null === $this->fileLinkFormatter) { - return ''; - } + return (string) $this->fileLinkFormatter?->format($absolutePath, 1); + } - return (string) $this->fileLinkFormatter->format($absolutePath, 1); + /** @return string[] */ + private function getAvailableFormatOptions(): array + { + return ['txt', 'json']; } } diff --git a/Command/LintCommand.php b/Command/LintCommand.php index 17bfa435..54720952 100644 --- a/Command/LintCommand.php +++ b/Command/LintCommand.php @@ -11,6 +11,7 @@ namespace Symfony\Bridge\Twig\Command; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\CI\GithubActionReporter; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Completion\CompletionInput; @@ -35,32 +36,26 @@ * @author Marc Weistroff * @author Jérôme Tamarelle */ +#[AsCommand(name: 'lint:twig', description: 'Lint a Twig template and outputs encountered errors')] class LintCommand extends Command { - protected static $defaultName = 'lint:twig'; - protected static $defaultDescription = 'Lint a Twig template and outputs encountered errors'; + private array $excludes; + private string $format; - private $twig; - - /** - * @var string|null - */ - private $format; - - public function __construct(Environment $twig) - { + public function __construct( + private Environment $twig, + private array $namePatterns = ['*.twig'], + ) { parent::__construct(); - - $this->twig = $twig; } - protected function configure() + protected function configure(): void { $this - ->setDescription(self::$defaultDescription) - ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format') + ->addOption('format', null, InputOption::VALUE_REQUIRED, \sprintf('The output format ("%s")', implode('", "', $this->getAvailableFormatOptions()))) ->addOption('show-deprecations', null, InputOption::VALUE_NONE, 'Show deprecations as errors') ->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN') + ->addOption('excludes', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Excluded directories', []) ->setHelp(<<<'EOF' The %command.name% command lints a template and outputs to STDOUT the first encountered syntax error. @@ -76,26 +71,25 @@ protected function configure() Or of a whole directory: php %command.full_name% dirname - php %command.full_name% dirname --format=json +The --format option specifies the format of the command output: + + php %command.full_name% dirname --format=json EOF ) ; } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $filenames = $input->getArgument('filename'); $showDeprecations = $input->getOption('show-deprecations'); - $this->format = $input->getOption('format'); - - if (null === $this->format) { - $this->format = GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt'; - } + $this->excludes = $input->getOption('excludes'); + $this->format = $input->getOption('format') ?? (GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt'); if (['-'] === $filenames) { - return $this->display($input, $output, $io, [$this->validate(file_get_contents('php://stdin'), uniqid('sf_', true))]); + return $this->display($input, $output, $io, [$this->validate(file_get_contents('php://stdin'), 'Standard Input')]); } if (!$filenames) { @@ -151,15 +145,15 @@ private function getFilesInfo(array $filenames): array return $filesInfo; } - protected function findFiles(string $filename) + protected function findFiles(string $filename): iterable { if (is_file($filename)) { return [$filename]; } elseif (is_dir($filename)) { - return Finder::create()->files()->in($filename)->name('*.twig'); + return Finder::create()->files()->in($filename)->name($this->namePatterns)->exclude($this->excludes); } - throw new RuntimeException(sprintf('File or directory "%s" is not readable.', $filename)); + throw new RuntimeException(\sprintf('File or directory "%s" is not readable.', $filename)); } private function validate(string $template, string $file): array @@ -180,28 +174,24 @@ private function validate(string $template, string $file): array return ['template' => $template, 'file' => $file, 'valid' => true]; } - private function display(InputInterface $input, OutputInterface $output, SymfonyStyle $io, array $files) + private function display(InputInterface $input, OutputInterface $output, SymfonyStyle $io, array $files): int { - switch ($this->format) { - case 'txt': - return $this->displayTxt($output, $io, $files); - case 'json': - return $this->displayJson($output, $files); - case 'github': - return $this->displayTxt($output, $io, $files, true); - default: - throw new InvalidArgumentException(sprintf('The format "%s" is not supported.', $input->getOption('format'))); - } + return match ($this->format) { + 'txt' => $this->displayTxt($output, $io, $files), + 'json' => $this->displayJson($output, $files), + 'github' => $this->displayTxt($output, $io, $files, true), + default => throw new InvalidArgumentException(\sprintf('Supported formats are "%s".', implode('", "', $this->getAvailableFormatOptions()))), + }; } - private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $filesInfo, bool $errorAsGithubAnnotations = false) + private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $filesInfo, bool $errorAsGithubAnnotations = false): int { $errors = 0; $githubReporter = $errorAsGithubAnnotations ? new GithubActionReporter($output) : null; foreach ($filesInfo as $info) { if ($info['valid'] && $output->isVerbose()) { - $io->comment('OK'.($info['file'] ? sprintf(' in %s', $info['file']) : '')); + $io->comment('OK'.($info['file'] ? \sprintf(' in %s', $info['file']) : '')); } elseif (!$info['valid']) { ++$errors; $this->renderException($io, $info['template'], $info['exception'], $info['file'], $githubReporter); @@ -209,15 +199,15 @@ private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $fi } if (0 === $errors) { - $io->success(sprintf('All %d Twig files contain valid syntax.', \count($filesInfo))); + $io->success(\sprintf('All %d Twig files contain valid syntax.', \count($filesInfo))); } else { - $io->warning(sprintf('%d Twig files have valid syntax and %d contain errors.', \count($filesInfo) - $errors, $errors)); + $io->warning(\sprintf('%d Twig files have valid syntax and %d contain errors.', \count($filesInfo) - $errors, $errors)); } return min($errors, 1); } - private function displayJson(OutputInterface $output, array $filesInfo) + private function displayJson(OutputInterface $output, array $filesInfo): int { $errors = 0; @@ -236,42 +226,40 @@ private function displayJson(OutputInterface $output, array $filesInfo) return min($errors, 1); } - private function renderException(SymfonyStyle $output, string $template, Error $exception, ?string $file = null, ?GithubActionReporter $githubReporter = null) + private function renderException(SymfonyStyle $output, string $template, Error $exception, ?string $file = null, ?GithubActionReporter $githubReporter = null): void { $line = $exception->getTemplateLine(); - if ($githubReporter) { - $githubReporter->error($exception->getRawMessage(), $file, $line <= 0 ? null : $line); - } + $githubReporter?->error($exception->getRawMessage(), $file, $line <= 0 ? null : $line); if ($file) { - $output->text(sprintf(' ERROR in %s (line %s)', $file, $line)); + $output->text(\sprintf(' ERROR in %s (line %s)', $file, $line)); } else { - $output->text(sprintf(' ERROR (line %s)', $line)); + $output->text(\sprintf(' ERROR (line %s)', $line)); } // If the line is not known (this might happen for deprecations if we fail at detecting the line for instance), // we render the message without context, to ensure the message is displayed. if ($line <= 0) { - $output->text(sprintf(' >> %s ', $exception->getRawMessage())); + $output->text(\sprintf(' >> %s ', $exception->getRawMessage())); return; } foreach ($this->getContext($template, $line) as $lineNumber => $code) { - $output->text(sprintf( + $output->text(\sprintf( '%s %-6s %s', $lineNumber === $line ? ' >> ' : ' ', $lineNumber, $code )); if ($lineNumber === $line) { - $output->text(sprintf(' >> %s ', $exception->getRawMessage())); + $output->text(\sprintf(' >> %s ', $exception->getRawMessage())); } } } - private function getContext(string $template, int $line, int $context = 3) + private function getContext(string $template, int $line, int $context = 3): array { $lines = explode("\n", $template); @@ -290,7 +278,13 @@ private function getContext(string $template, int $line, int $context = 3) public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { if ($input->mustSuggestOptionValuesFor('format')) { - $suggestions->suggestValues(['txt', 'json', 'github']); + $suggestions->suggestValues($this->getAvailableFormatOptions()); } } + + /** @return string[] */ + private function getAvailableFormatOptions(): array + { + return ['txt', 'json', 'github']; + } } diff --git a/DataCollector/TwigDataCollector.php b/DataCollector/TwigDataCollector.php index 3df1aa4a..f63d85a6 100644 --- a/DataCollector/TwigDataCollector.php +++ b/DataCollector/TwigDataCollector.php @@ -28,37 +28,26 @@ */ class TwigDataCollector extends DataCollector implements LateDataCollectorInterface { - private $profile; - private $twig; - private $computed; + private array $computed; - public function __construct(Profile $profile, ?Environment $twig = null) - { - $this->profile = $profile; - $this->twig = $twig; + public function __construct( + private Profile $profile, + private ?Environment $twig = null, + ) { } - /** - * {@inheritdoc} - */ - public function collect(Request $request, Response $response, ?\Throwable $exception = null) + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void { } - /** - * {@inheritdoc} - */ - public function reset() + public function reset(): void { $this->profile->reset(); - $this->computed = null; + unset($this->computed); $this->data = []; } - /** - * {@inheritdoc} - */ - public function lateCollect() + public function lateCollect(): void { $this->data['profile'] = serialize($this->profile); $this->data['template_paths'] = []; @@ -71,7 +60,7 @@ public function lateCollect() if ($profile->isTemplate()) { try { $template = $this->twig->load($name = $profile->getName()); - } catch (LoaderError $e) { + } catch (LoaderError) { $template = null; } @@ -87,37 +76,37 @@ public function lateCollect() $templateFinder($this->profile); } - public function getTime() + public function getTime(): float { return $this->getProfile()->getDuration() * 1000; } - public function getTemplateCount() + public function getTemplateCount(): int { return $this->getComputedData('template_count'); } - public function getTemplatePaths() + public function getTemplatePaths(): array { return $this->data['template_paths']; } - public function getTemplates() + public function getTemplates(): array { return $this->getComputedData('templates'); } - public function getBlockCount() + public function getBlockCount(): int { return $this->getComputedData('block_count'); } - public function getMacroCount() + public function getMacroCount(): int { return $this->getComputedData('macro_count'); } - public function getHtmlCallGraph() + public function getHtmlCallGraph(): Markup { $dumper = new HtmlDumper(); $dump = $dumper->dump($this->getProfile()); @@ -138,25 +127,19 @@ public function getHtmlCallGraph() return new Markup($dump, 'UTF-8'); } - public function getProfile() + public function getProfile(): Profile { - if (null === $this->profile) { - $this->profile = unserialize($this->data['profile'], ['allowed_classes' => ['Twig_Profiler_Profile', 'Twig\Profiler\Profile']]); - } - - return $this->profile; + return $this->profile ??= unserialize($this->data['profile'], ['allowed_classes' => [Profile::class]]); } - private function getComputedData(string $index) + private function getComputedData(string $index): mixed { - if (null === $this->computed) { - $this->computed = $this->computeData($this->getProfile()); - } + $this->computed ??= $this->computeData($this->getProfile()); return $this->computed[$index]; } - private function computeData(Profile $profile) + private function computeData(Profile $profile): array { $data = [ 'template_count' => 0, @@ -193,9 +176,6 @@ private function computeData(Profile $profile) return $data; } - /** - * {@inheritdoc} - */ public function getName(): string { return 'twig'; diff --git a/ErrorRenderer/TwigErrorRenderer.php b/ErrorRenderer/TwigErrorRenderer.php index 1bc1bb07..f624720b 100644 --- a/ErrorRenderer/TwigErrorRenderer.php +++ b/ErrorRenderer/TwigErrorRenderer.php @@ -25,40 +25,34 @@ */ class TwigErrorRenderer implements ErrorRendererInterface { - private $twig; - private $fallbackErrorRenderer; - private $debug; + private HtmlErrorRenderer $fallbackErrorRenderer; + private \Closure|bool $debug; /** * @param bool|callable $debug The debugging mode as a boolean or a callable that should return it */ - public function __construct(Environment $twig, ?HtmlErrorRenderer $fallbackErrorRenderer = null, $debug = false) - { - if (!\is_bool($debug) && !\is_callable($debug)) { - throw new \TypeError(sprintf('Argument 3 passed to "%s()" must be a boolean or a callable, "%s" given.', __METHOD__, get_debug_type($debug))); - } - - $this->twig = $twig; + public function __construct( + private Environment $twig, + ?HtmlErrorRenderer $fallbackErrorRenderer = null, + bool|callable $debug = false, + ) { $this->fallbackErrorRenderer = $fallbackErrorRenderer ?? new HtmlErrorRenderer(); - $this->debug = $debug; + $this->debug = \is_bool($debug) ? $debug : $debug(...); } - /** - * {@inheritdoc} - */ public function render(\Throwable $exception): FlattenException { - $exception = $this->fallbackErrorRenderer->render($exception); - $debug = \is_bool($this->debug) ? $this->debug : ($this->debug)($exception); + $flattenException = FlattenException::createFromThrowable($exception); + $debug = \is_bool($this->debug) ? $this->debug : ($this->debug)($flattenException); - if ($debug || !$template = $this->findTemplate($exception->getStatusCode())) { - return $exception; + if ($debug || !$template = $this->findTemplate($flattenException->getStatusCode())) { + return $this->fallbackErrorRenderer->render($exception); } - return $exception->setAsString($this->twig->render($template, [ - 'exception' => $exception, - 'status_code' => $exception->getStatusCode(), - 'status_text' => $exception->getStatusText(), + return $flattenException->setAsString($this->twig->render($template, [ + 'exception' => $flattenException, + 'status_code' => $flattenException->getStatusCode(), + 'status_text' => $flattenException->getStatusText(), ])); } @@ -75,7 +69,7 @@ public static function isDebug(RequestStack $requestStack, bool $debug): \Closur private function findTemplate(int $statusCode): ?string { - $template = sprintf('@Twig/Exception/error%s.html.twig', $statusCode); + $template = \sprintf('@Twig/Exception/error%s.html.twig', $statusCode); if ($this->twig->getLoader()->exists($template)) { return $template; } diff --git a/EventListener/TemplateAttributeListener.php b/EventListener/TemplateAttributeListener.php new file mode 100644 index 00000000..45a4e9cc --- /dev/null +++ b/EventListener/TemplateAttributeListener.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\EventListener; + +use Symfony\Bridge\Twig\Attribute\Template; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Twig\Environment; + +class TemplateAttributeListener implements EventSubscriberInterface +{ + public function __construct( + private Environment $twig, + ) { + } + + public function onKernelView(ViewEvent $event): void + { + $parameters = $event->getControllerResult(); + + if (!\is_array($parameters ?? [])) { + return; + } + $attribute = $event->getRequest()->attributes->get('_template'); + + if (!$attribute instanceof Template && !$attribute = $event->controllerArgumentsEvent?->getAttributes()[Template::class][0] ?? null) { + return; + } + + $parameters ??= $this->resolveParameters($event->controllerArgumentsEvent, $attribute->vars); + $status = 200; + + foreach ($parameters as $k => $v) { + if (!$v instanceof FormInterface) { + continue; + } + if ($v->isSubmitted() && !$v->isValid()) { + $status = 422; + } + $parameters[$k] = $v->createView(); + } + + $event->setResponse($attribute->stream + ? new StreamedResponse( + null !== $attribute->block + ? fn () => $this->twig->load($attribute->template)->displayBlock($attribute->block, $parameters) + : fn () => $this->twig->display($attribute->template, $parameters), + $status) + : new Response( + null !== $attribute->block + ? $this->twig->load($attribute->template)->renderBlock($attribute->block, $parameters) + : $this->twig->render($attribute->template, $parameters), + $status) + ); + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::VIEW => ['onKernelView', -128], + ]; + } + + private function resolveParameters(ControllerArgumentsEvent $event, ?array $vars): array + { + if ([] === $vars) { + return []; + } + + $parameters = $event->getNamedArguments(); + + if (null !== $vars) { + $parameters = array_intersect_key($parameters, array_flip($vars)); + } + + return $parameters; + } +} diff --git a/Extension/AssetExtension.php b/Extension/AssetExtension.php index 9ec9778e..ce9fee72 100644 --- a/Extension/AssetExtension.php +++ b/Extension/AssetExtension.php @@ -22,21 +22,16 @@ */ final class AssetExtension extends AbstractExtension { - private $packages; - - public function __construct(Packages $packages) - { - $this->packages = $packages; + public function __construct( + private Packages $packages, + ) { } - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [ - new TwigFunction('asset', [$this, 'getAssetUrl']), - new TwigFunction('asset_version', [$this, 'getAssetVersion']), + new TwigFunction('asset', $this->getAssetUrl(...)), + new TwigFunction('asset_version', $this->getAssetVersion(...)), ]; } diff --git a/Extension/CodeExtension.php b/Extension/CodeExtension.php deleted file mode 100644 index e7a33294..00000000 --- a/Extension/CodeExtension.php +++ /dev/null @@ -1,262 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\Twig\Extension; - -use Symfony\Component\HttpKernel\Debug\FileLinkFormatter; -use Twig\Extension\AbstractExtension; -use Twig\TwigFilter; - -/** - * Twig extension relate to PHP code and used by the profiler and the default exception templates. - * - * @author Fabien Potencier - */ -final class CodeExtension extends AbstractExtension -{ - private $fileLinkFormat; - private $charset; - private $projectDir; - - /** - * @param string|FileLinkFormatter $fileLinkFormat The format for links to source files - */ - public function __construct($fileLinkFormat, string $projectDir, string $charset) - { - $this->fileLinkFormat = $fileLinkFormat ?: \ini_get('xdebug.file_link_format') ?: get_cfg_var('xdebug.file_link_format'); - $this->projectDir = str_replace('\\', '/', $projectDir).'/'; - $this->charset = $charset; - } - - /** - * {@inheritdoc} - */ - public function getFilters(): array - { - return [ - new TwigFilter('abbr_class', [$this, 'abbrClass'], ['is_safe' => ['html'], 'pre_escape' => 'html']), - new TwigFilter('abbr_method', [$this, 'abbrMethod'], ['is_safe' => ['html'], 'pre_escape' => 'html']), - new TwigFilter('format_args', [$this, 'formatArgs'], ['is_safe' => ['html']]), - new TwigFilter('format_args_as_text', [$this, 'formatArgsAsText']), - new TwigFilter('file_excerpt', [$this, 'fileExcerpt'], ['is_safe' => ['html']]), - new TwigFilter('format_file', [$this, 'formatFile'], ['is_safe' => ['html']]), - new TwigFilter('format_file_from_text', [$this, 'formatFileFromText'], ['is_safe' => ['html']]), - new TwigFilter('format_log_message', [$this, 'formatLogMessage'], ['is_safe' => ['html']]), - new TwigFilter('file_link', [$this, 'getFileLink']), - new TwigFilter('file_relative', [$this, 'getFileRelative']), - ]; - } - - public function abbrClass(string $class): string - { - $parts = explode('\\', $class); - $short = array_pop($parts); - - return sprintf('%s', $class, $short); - } - - public function abbrMethod(string $method): string - { - if (str_contains($method, '::')) { - [$class, $method] = explode('::', $method, 2); - $result = sprintf('%s::%s()', $this->abbrClass($class), $method); - } elseif ('Closure' === $method) { - $result = sprintf('%1$s', $method); - } else { - $result = sprintf('%1$s()', $method); - } - - return $result; - } - - /** - * Formats an array as a string. - */ - public function formatArgs(array $args): string - { - $result = []; - foreach ($args as $key => $item) { - if ('object' === $item[0]) { - $item[1] = htmlspecialchars($item[1], \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset); - $parts = explode('\\', $item[1]); - $short = array_pop($parts); - $formattedValue = sprintf('object(%s)', $item[1], $short); - } elseif ('array' === $item[0]) { - $formattedValue = sprintf('array(%s)', \is_array($item[1]) ? $this->formatArgs($item[1]) : htmlspecialchars(var_export($item[1], true), \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset)); - } elseif ('null' === $item[0]) { - $formattedValue = 'null'; - } elseif ('boolean' === $item[0]) { - $formattedValue = ''.strtolower(htmlspecialchars(var_export($item[1], true), \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset)).''; - } elseif ('resource' === $item[0]) { - $formattedValue = 'resource'; - } elseif (preg_match('/[^\x07-\x0D\x1B\x20-\xFF]/', $item[1])) { - $formattedValue = 'binary string'; - } else { - $formattedValue = str_replace("\n", '', htmlspecialchars(var_export($item[1], true), \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset)); - } - - $result[] = \is_int($key) ? $formattedValue : sprintf("'%s' => %s", htmlspecialchars($key, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), $formattedValue); - } - - return implode(', ', $result); - } - - /** - * Formats an array as a string. - */ - public function formatArgsAsText(array $args): string - { - return strip_tags($this->formatArgs($args)); - } - - /** - * Returns an excerpt of a code file around the given line number. - */ - public function fileExcerpt(string $file, int $line, int $srcContext = 3): ?string - { - if (is_file($file) && is_readable($file)) { - // highlight_file could throw warnings - // see https://bugs.php.net/25725 - $code = @highlight_file($file, true); - if (\PHP_VERSION_ID >= 80300) { - // remove main pre/code tags - $code = preg_replace('#^\s*(.*)\s*#s', '\\1', $code); - // split multiline span tags - $code = preg_replace_callback('#]++)>((?:[^<\\n]*+\\n)++[^<]*+)#', function ($m) { - return "".str_replace("\n", "\n", $m[2]).''; - }, $code); - $content = explode("\n", $code); - } else { - // remove main code/span tags - $code = preg_replace('#^\s*(.*)\s*#s', '\\1', $code); - // split multiline spans - $code = preg_replace_callback('#]++)>((?:[^<]*+
)++[^<]*+)
#', function ($m) { - return "".str_replace('
', "

", $m[2]).''; - }, $code); - $content = explode('
', $code); - } - - $lines = []; - if (0 > $srcContext) { - $srcContext = \count($content); - } - - for ($i = max($line - $srcContext, 1), $max = min($line + $srcContext, \count($content)); $i <= $max; ++$i) { - $lines[] = ''.self::fixCodeMarkup($content[$i - 1]).''; - } - - return '
    '.implode("\n", $lines).'
'; - } - - return null; - } - - /** - * Formats a file path. - */ - public function formatFile(string $file, int $line, ?string $text = null): string - { - $file = trim($file); - - if (null === $text) { - if (null !== $rel = $this->getFileRelative($file)) { - $rel = explode('/', htmlspecialchars($rel, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), 2); - $text = sprintf('%s%s', htmlspecialchars($this->projectDir, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), $rel[0], '/'.($rel[1] ?? '')); - } else { - $text = htmlspecialchars($file, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset); - } - } else { - $text = htmlspecialchars($text, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset); - } - - if (0 < $line) { - $text .= ' at line '.$line; - } - - if (false !== $link = $this->getFileLink($file, $line)) { - return sprintf('%s', htmlspecialchars($link, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), $text); - } - - return $text; - } - - /** - * Returns the link for a given file/line pair. - * - * @return string|false - */ - public function getFileLink(string $file, int $line) - { - if ($fmt = $this->fileLinkFormat) { - return \is_string($fmt) ? strtr($fmt, ['%f' => $file, '%l' => $line]) : $fmt->format($file, $line); - } - - return false; - } - - public function getFileRelative(string $file): ?string - { - $file = str_replace('\\', '/', $file); - - if (null !== $this->projectDir && str_starts_with($file, $this->projectDir)) { - return ltrim(substr($file, \strlen($this->projectDir)), '/'); - } - - return null; - } - - public function formatFileFromText(string $text): string - { - return preg_replace_callback('/in ("|")?(.+?)\1(?: +(?:on|at))? +line (\d+)/s', function ($match) { - return 'in '.$this->formatFile($match[2], $match[3]); - }, $text); - } - - /** - * @internal - */ - public function formatLogMessage(string $message, array $context): string - { - if ($context && str_contains($message, '{')) { - $replacements = []; - foreach ($context as $key => $val) { - if (\is_scalar($val)) { - $replacements['{'.$key.'}'] = $val; - } - } - - if ($replacements) { - $message = strtr($message, $replacements); - } - } - - return htmlspecialchars($message, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset); - } - - protected static function fixCodeMarkup(string $line): string - { - // ending tag from previous line - $opening = strpos($line, ''); - if (false !== $closing && (false === $opening || $closing < $opening)) { - $line = substr_replace($line, '', $closing, 7); - } - - // missing tag at the end of line - $opening = strpos($line, ''); - if (false !== $opening && (false === $closing || $closing > $opening)) { - $line .= ''; - } - - return trim($line); - } -} diff --git a/Extension/CsrfExtension.php b/Extension/CsrfExtension.php index 646a4879..951fc31d 100644 --- a/Extension/CsrfExtension.php +++ b/Extension/CsrfExtension.php @@ -20,9 +20,6 @@ */ final class CsrfExtension extends AbstractExtension { - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [ diff --git a/Extension/CsrfRuntime.php b/Extension/CsrfRuntime.php index c3d5da64..29267116 100644 --- a/Extension/CsrfRuntime.php +++ b/Extension/CsrfRuntime.php @@ -19,11 +19,9 @@ */ final class CsrfRuntime { - private $csrfTokenManager; - - public function __construct(CsrfTokenManagerInterface $csrfTokenManager) - { - $this->csrfTokenManager = $csrfTokenManager; + public function __construct( + private CsrfTokenManagerInterface $csrfTokenManager, + ) { } public function getCsrfToken(string $tokenId): string diff --git a/Extension/DumpExtension.php b/Extension/DumpExtension.php index f6620062..a9006165 100644 --- a/Extension/DumpExtension.php +++ b/Extension/DumpExtension.php @@ -26,28 +26,19 @@ */ final class DumpExtension extends AbstractExtension { - private $cloner; - private $dumper; - - public function __construct(ClonerInterface $cloner, ?HtmlDumper $dumper = null) - { - $this->cloner = $cloner; - $this->dumper = $dumper; + public function __construct( + private ClonerInterface $cloner, + private ?HtmlDumper $dumper = null, + ) { } - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [ - new TwigFunction('dump', [$this, 'dump'], ['is_safe' => ['html'], 'needs_context' => true, 'needs_environment' => true]), + new TwigFunction('dump', $this->dump(...), ['is_safe' => ['html'], 'needs_context' => true, 'needs_environment' => true]), ]; } - /** - * {@inheritdoc} - */ public function getTokenParsers(): array { return [new DumpTokenParser()]; @@ -74,7 +65,7 @@ public function dump(Environment $env, array $context): ?string } $dump = fopen('php://memory', 'r+'); - $this->dumper = $this->dumper ?? new HtmlDumper(); + $this->dumper ??= new HtmlDumper(); $this->dumper->setCharset($env->getCharset()); foreach ($vars as $value) { diff --git a/Extension/EmojiExtension.php b/Extension/EmojiExtension.php new file mode 100644 index 00000000..c98a3aac --- /dev/null +++ b/Extension/EmojiExtension.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Symfony\Component\Emoji\EmojiTransliterator; +use Twig\Extension\AbstractExtension; +use Twig\TwigFilter; + +/** + * @author Grégoire Pineau + */ +final class EmojiExtension extends AbstractExtension +{ + private static array $transliterators = []; + + public function __construct( + private readonly string $defaultCatalog = 'text', + ) { + if (!class_exists(EmojiTransliterator::class)) { + throw new \LogicException('You cannot use the "emojify" filter as the "Emoji" component is not installed. Try running "composer require symfony/emoji".'); + } + } + + public function getFilters(): array + { + return [ + new TwigFilter('emojify', $this->emojify(...)), + ]; + } + + /** + * Converts emoji short code (:wave:) to real emoji (👋). + */ + public function emojify(string $string, ?string $catalog = null): string + { + $catalog ??= $this->defaultCatalog; + + try { + $tr = self::$transliterators[$catalog] ??= EmojiTransliterator::create($catalog, EmojiTransliterator::REVERSE); + } catch (\IntlException $e) { + throw new \LogicException(\sprintf('The emoji catalog "%s" is not available.', $catalog), previous: $e); + } + + return (string) $tr->transliterate($string); + } +} diff --git a/Extension/ExpressionExtension.php b/Extension/ExpressionExtension.php index 8d2a35c9..49e4c95c 100644 --- a/Extension/ExpressionExtension.php +++ b/Extension/ExpressionExtension.php @@ -22,13 +22,10 @@ */ final class ExpressionExtension extends AbstractExtension { - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [ - new TwigFunction('expression', [$this, 'createExpression']), + new TwigFunction('expression', $this->createExpression(...)), ]; } diff --git a/Extension/FormExtension.php b/Extension/FormExtension.php index 24c792ae..ec552d7c 100644 --- a/Extension/FormExtension.php +++ b/Extension/FormExtension.php @@ -11,11 +11,15 @@ namespace Symfony\Bridge\Twig\Extension; +use Symfony\Bridge\Twig\Node\RenderBlockNode; +use Symfony\Bridge\Twig\Node\SearchAndRenderBlockNode; use Symfony\Bridge\Twig\TokenParser\FormThemeTokenParser; use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView; use Symfony\Component\Form\ChoiceList\View\ChoiceView; use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormRenderer; use Symfony\Component\Form\FormView; +use Symfony\Contracts\Translation\TranslatableInterface; use Symfony\Contracts\Translation\TranslatorInterface; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; @@ -30,16 +34,11 @@ */ final class FormExtension extends AbstractExtension { - private $translator; - - public function __construct(?TranslatorInterface $translator = null) - { - $this->translator = $translator; + public function __construct( + private ?TranslatorInterface $translator = null, + ) { } - /** - * {@inheritdoc} - */ public function getTokenParsers(): array { return [ @@ -48,46 +47,37 @@ public function getTokenParsers(): array ]; } - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [ - new TwigFunction('form_widget', null, ['node_class' => 'Symfony\Bridge\Twig\Node\SearchAndRenderBlockNode', 'is_safe' => ['html']]), - new TwigFunction('form_errors', null, ['node_class' => 'Symfony\Bridge\Twig\Node\SearchAndRenderBlockNode', 'is_safe' => ['html']]), - new TwigFunction('form_label', null, ['node_class' => 'Symfony\Bridge\Twig\Node\SearchAndRenderBlockNode', 'is_safe' => ['html']]), - new TwigFunction('form_help', null, ['node_class' => 'Symfony\Bridge\Twig\Node\SearchAndRenderBlockNode', 'is_safe' => ['html']]), - new TwigFunction('form_row', null, ['node_class' => 'Symfony\Bridge\Twig\Node\SearchAndRenderBlockNode', 'is_safe' => ['html']]), - new TwigFunction('form_rest', null, ['node_class' => 'Symfony\Bridge\Twig\Node\SearchAndRenderBlockNode', 'is_safe' => ['html']]), - new TwigFunction('form', null, ['node_class' => 'Symfony\Bridge\Twig\Node\RenderBlockNode', 'is_safe' => ['html']]), - new TwigFunction('form_start', null, ['node_class' => 'Symfony\Bridge\Twig\Node\RenderBlockNode', 'is_safe' => ['html']]), - new TwigFunction('form_end', null, ['node_class' => 'Symfony\Bridge\Twig\Node\RenderBlockNode', 'is_safe' => ['html']]), - new TwigFunction('csrf_token', ['Symfony\Component\Form\FormRenderer', 'renderCsrfToken']), + new TwigFunction('form_widget', null, ['node_class' => SearchAndRenderBlockNode::class, 'is_safe' => ['html']]), + new TwigFunction('form_errors', null, ['node_class' => SearchAndRenderBlockNode::class, 'is_safe' => ['html']]), + new TwigFunction('form_label', null, ['node_class' => SearchAndRenderBlockNode::class, 'is_safe' => ['html']]), + new TwigFunction('form_help', null, ['node_class' => SearchAndRenderBlockNode::class, 'is_safe' => ['html']]), + new TwigFunction('form_row', null, ['node_class' => SearchAndRenderBlockNode::class, 'is_safe' => ['html']]), + new TwigFunction('form_rest', null, ['node_class' => SearchAndRenderBlockNode::class, 'is_safe' => ['html']]), + new TwigFunction('form', null, ['node_class' => RenderBlockNode::class, 'is_safe' => ['html']]), + new TwigFunction('form_start', null, ['node_class' => RenderBlockNode::class, 'is_safe' => ['html']]), + new TwigFunction('form_end', null, ['node_class' => RenderBlockNode::class, 'is_safe' => ['html']]), + new TwigFunction('csrf_token', [FormRenderer::class, 'renderCsrfToken']), new TwigFunction('form_parent', 'Symfony\Bridge\Twig\Extension\twig_get_form_parent'), - new TwigFunction('field_name', [$this, 'getFieldName']), - new TwigFunction('field_value', [$this, 'getFieldValue']), - new TwigFunction('field_label', [$this, 'getFieldLabel']), - new TwigFunction('field_help', [$this, 'getFieldHelp']), - new TwigFunction('field_errors', [$this, 'getFieldErrors']), - new TwigFunction('field_choices', [$this, 'getFieldChoices']), + new TwigFunction('field_name', $this->getFieldName(...)), + new TwigFunction('field_value', $this->getFieldValue(...)), + new TwigFunction('field_label', $this->getFieldLabel(...)), + new TwigFunction('field_help', $this->getFieldHelp(...)), + new TwigFunction('field_errors', $this->getFieldErrors(...)), + new TwigFunction('field_choices', $this->getFieldChoices(...)), ]; } - /** - * {@inheritdoc} - */ public function getFilters(): array { return [ - new TwigFilter('humanize', ['Symfony\Component\Form\FormRenderer', 'humanize']), - new TwigFilter('form_encode_currency', ['Symfony\Component\Form\FormRenderer', 'encodeCurrency'], ['is_safe' => ['html'], 'needs_environment' => true]), + new TwigFilter('humanize', [FormRenderer::class, 'humanize']), + new TwigFilter('form_encode_currency', [FormRenderer::class, 'encodeCurrency'], ['is_safe' => ['html'], 'needs_environment' => true]), ]; } - /** - * {@inheritdoc} - */ public function getTests(): array { return [ @@ -103,10 +93,7 @@ public function getFieldName(FormView $view): string return $view->vars['full_name']; } - /** - * @return string|array - */ - public function getFieldValue(FormView $view) + public function getFieldValue(FormView $view): string|array { return $view->vars['value']; } @@ -158,26 +145,29 @@ public function getFieldChoices(FormView $view): iterable yield from $this->createFieldChoicesList($view->vars['choices'], $view->vars['choice_translation_domain']); } - private function createFieldChoicesList(iterable $choices, $translationDomain): iterable + private function createFieldChoicesList(iterable $choices, string|false|null $translationDomain): iterable { foreach ($choices as $choice) { - $translatableLabel = $this->createFieldTranslation($choice->label, [], $translationDomain); - if ($choice instanceof ChoiceGroupView) { + $translatableLabel = $this->createFieldTranslation($choice->label, [], $translationDomain); yield $translatableLabel => $this->createFieldChoicesList($choice, $translationDomain); continue; } /* @var ChoiceView $choice */ + $translatableLabel = $this->createFieldTranslation($choice->label, $choice->labelTranslationParameters, $translationDomain); yield $translatableLabel => $choice->value; } } - private function createFieldTranslation(?string $value, array $parameters, $domain): ?string + private function createFieldTranslation(TranslatableInterface|string|null $value, array $parameters, string|false|null $domain): ?string { if (!$this->translator || !$value || false === $domain) { - return $value; + return null !== $value ? (string) $value : null; + } + if ($value instanceof TranslatableInterface) { + return $value->trans($this->translator); } return $this->translator->trans($value, $parameters, $domain); @@ -189,11 +179,9 @@ private function createFieldTranslation(?string $value, array $parameters, $doma * * This is a function and not callable due to performance reasons. * - * @param string|array $selectedValue The selected value to compare - * * @see ChoiceView::isSelected() */ -function twig_is_selected_choice(ChoiceView $choice, $selectedValue): bool +function twig_is_selected_choice(ChoiceView $choice, string|array|null $selectedValue): bool { if (\is_array($selectedValue)) { return \in_array($choice->value, $selectedValue, true); diff --git a/Extension/HtmlSanitizerExtension.php b/Extension/HtmlSanitizerExtension.php new file mode 100644 index 00000000..9549c2a3 --- /dev/null +++ b/Extension/HtmlSanitizerExtension.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Psr\Container\ContainerInterface; +use Twig\Extension\AbstractExtension; +use Twig\TwigFilter; + +/** + * @author Titouan Galopin + */ +final class HtmlSanitizerExtension extends AbstractExtension +{ + public function __construct( + private ContainerInterface $sanitizers, + private string $defaultSanitizer = 'default', + ) { + } + + public function getFilters(): array + { + return [ + new TwigFilter('sanitize_html', $this->sanitize(...), ['is_safe' => ['html']]), + ]; + } + + public function sanitize(string $html, ?string $sanitizer = null): string + { + return $this->sanitizers->get($sanitizer ?? $this->defaultSanitizer)->sanitize($html); + } +} diff --git a/Extension/HttpFoundationExtension.php b/Extension/HttpFoundationExtension.php index a9ee05c4..e06f1b39 100644 --- a/Extension/HttpFoundationExtension.php +++ b/Extension/HttpFoundationExtension.php @@ -23,21 +23,16 @@ */ final class HttpFoundationExtension extends AbstractExtension { - private $urlHelper; - - public function __construct(UrlHelper $urlHelper) - { - $this->urlHelper = $urlHelper; + public function __construct( + private UrlHelper $urlHelper, + ) { } - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [ - new TwigFunction('absolute_url', [$this, 'generateAbsoluteUrl']), - new TwigFunction('relative_path', [$this, 'generateRelativePath']), + new TwigFunction('absolute_url', $this->generateAbsoluteUrl(...)), + new TwigFunction('relative_path', $this->generateRelativePath(...)), ]; } diff --git a/Extension/HttpKernelExtension.php b/Extension/HttpKernelExtension.php index 1af9ddb2..4859f4dd 100644 --- a/Extension/HttpKernelExtension.php +++ b/Extension/HttpKernelExtension.php @@ -22,16 +22,13 @@ */ final class HttpKernelExtension extends AbstractExtension { - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [ new TwigFunction('render', [HttpKernelRuntime::class, 'renderFragment'], ['is_safe' => ['html']]), new TwigFunction('render_*', [HttpKernelRuntime::class, 'renderFragmentStrategy'], ['is_safe' => ['html']]), new TwigFunction('fragment_uri', [HttpKernelRuntime::class, 'generateFragmentUri']), - new TwigFunction('controller', static::class.'::controller'), + new TwigFunction('controller', [self::class, 'controller']), ]; } diff --git a/Extension/HttpKernelRuntime.php b/Extension/HttpKernelRuntime.php index 565b37f7..6c488ef7 100644 --- a/Extension/HttpKernelRuntime.php +++ b/Extension/HttpKernelRuntime.php @@ -22,23 +22,18 @@ */ final class HttpKernelRuntime { - private $handler; - private $fragmentUriGenerator; - - public function __construct(FragmentHandler $handler, ?FragmentUriGeneratorInterface $fragmentUriGenerator = null) - { - $this->handler = $handler; - $this->fragmentUriGenerator = $fragmentUriGenerator; + public function __construct( + private FragmentHandler $handler, + private ?FragmentUriGeneratorInterface $fragmentUriGenerator = null, + ) { } /** * Renders a fragment. * - * @param string|ControllerReference $uri A URI as a string or a ControllerReference instance - * * @see FragmentHandler::render() */ - public function renderFragment($uri, array $options = []): string + public function renderFragment(string|ControllerReference $uri, array $options = []): string { $strategy = $options['strategy'] ?? 'inline'; unset($options['strategy']); @@ -49,11 +44,9 @@ public function renderFragment($uri, array $options = []): string /** * Renders a fragment. * - * @param string|ControllerReference $uri A URI as a string or a ControllerReference instance - * * @see FragmentHandler::render() */ - public function renderFragmentStrategy(string $strategy, $uri, array $options = []): string + public function renderFragmentStrategy(string $strategy, string|ControllerReference $uri, array $options = []): string { return $this->handler->render($uri, $strategy, $options); } @@ -61,7 +54,7 @@ public function renderFragmentStrategy(string $strategy, $uri, array $options = public function generateFragmentUri(ControllerReference $controller, bool $absolute = false, bool $strict = true, bool $sign = true): string { if (null === $this->fragmentUriGenerator) { - throw new \LogicException(sprintf('An instance of "%s" must be provided to use "%s()".', FragmentUriGeneratorInterface::class, __METHOD__)); + throw new \LogicException(\sprintf('An instance of "%s" must be provided to use "%s()".', FragmentUriGeneratorInterface::class, __METHOD__)); } return $this->fragmentUriGenerator->generate($controller, null, $absolute, $strict, $sign); diff --git a/Extension/ImportMapExtension.php b/Extension/ImportMapExtension.php new file mode 100644 index 00000000..2156c74d --- /dev/null +++ b/Extension/ImportMapExtension.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * @author Kévin Dunglas + */ +final class ImportMapExtension extends AbstractExtension +{ + public function getFunctions(): array + { + return [ + new TwigFunction('importmap', [ImportMapRuntime::class, 'importmap'], ['is_safe' => ['html']]), + ]; + } +} diff --git a/Extension/ImportMapRuntime.php b/Extension/ImportMapRuntime.php new file mode 100644 index 00000000..902e0a42 --- /dev/null +++ b/Extension/ImportMapRuntime.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Symfony\Component\AssetMapper\ImportMap\ImportMapRenderer; + +/** + * @author Kévin Dunglas + */ +class ImportMapRuntime +{ + public function __construct( + private readonly ImportMapRenderer $importMapRenderer, + ) { + } + + public function importmap(string|array $entryPoint = 'app', array $attributes = []): string + { + return $this->importMapRenderer->render($entryPoint, $attributes); + } +} diff --git a/Extension/LogoutUrlExtension.php b/Extension/LogoutUrlExtension.php index 94e9deda..15089d3c 100644 --- a/Extension/LogoutUrlExtension.php +++ b/Extension/LogoutUrlExtension.php @@ -22,21 +22,16 @@ */ final class LogoutUrlExtension extends AbstractExtension { - private $generator; - - public function __construct(LogoutUrlGenerator $generator) - { - $this->generator = $generator; + public function __construct( + private LogoutUrlGenerator $generator, + ) { } - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [ - new TwigFunction('logout_url', [$this, 'getLogoutUrl']), - new TwigFunction('logout_path', [$this, 'getLogoutPath']), + new TwigFunction('logout_url', $this->getLogoutUrl(...)), + new TwigFunction('logout_path', $this->getLogoutPath(...)), ]; } diff --git a/Extension/ProfilerExtension.php b/Extension/ProfilerExtension.php index c146848c..2dbc4ec4 100644 --- a/Extension/ProfilerExtension.php +++ b/Extension/ProfilerExtension.php @@ -21,18 +21,17 @@ */ final class ProfilerExtension extends BaseProfilerExtension { - private $stopwatch; - /** * @var \SplObjectStorage */ - private $events; + private \SplObjectStorage $events; - public function __construct(Profile $profile, ?Stopwatch $stopwatch = null) - { + public function __construct( + Profile $profile, + private ?Stopwatch $stopwatch = null, + ) { parent::__construct($profile); - $this->stopwatch = $stopwatch; $this->events = new \SplObjectStorage(); } diff --git a/Extension/RoutingExtension.php b/Extension/RoutingExtension.php index 800c22f6..eace5232 100644 --- a/Extension/RoutingExtension.php +++ b/Extension/RoutingExtension.php @@ -25,21 +25,16 @@ */ final class RoutingExtension extends AbstractExtension { - private $generator; - - public function __construct(UrlGeneratorInterface $generator) - { - $this->generator = $generator; + public function __construct( + private UrlGeneratorInterface $generator, + ) { } - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [ - new TwigFunction('url', [$this, 'getUrl'], ['is_safe_callback' => [$this, 'isUrlGenerationSafe']]), - new TwigFunction('path', [$this, 'getPath'], ['is_safe_callback' => [$this, 'isUrlGenerationSafe']]), + new TwigFunction('url', $this->getUrl(...), ['is_safe_callback' => $this->isUrlGenerationSafe(...)]), + new TwigFunction('path', $this->getPath(...), ['is_safe_callback' => $this->isUrlGenerationSafe(...)]), ]; } @@ -58,7 +53,7 @@ public function getUrl(string $name, array $parameters = [], bool $schemeRelativ * saving the unneeded automatic escaping for performance reasons. * * The URL generation process percent encodes non-alphanumeric characters. So there is no risk - * that malicious/invalid characters are part of the URL. The only character within an URL that + * that malicious/invalid characters are part of the URL. The only character within a URL that * must be escaped in html is the ampersand ("&") which separates query params. So we cannot mark * the URL generation as always safe, but only when we are sure there won't be multiple query * params. This is the case when there are none or only one constant parameter given. @@ -82,8 +77,8 @@ public function isUrlGenerationSafe(Node $argsNode): array $argsNode->hasNode(1) ? $argsNode->getNode(1) : null ); - if (null === $paramsNode || $paramsNode instanceof ArrayExpression && \count($paramsNode) <= 2 && - (!$paramsNode->hasNode(1) || $paramsNode->getNode(1) instanceof ConstantExpression) + if (null === $paramsNode || $paramsNode instanceof ArrayExpression && \count($paramsNode) <= 2 + && (!$paramsNode->hasNode(1) || $paramsNode->getNode(1) instanceof ConstantExpression) ) { return ['html']; } diff --git a/Extension/SecurityExtension.php b/Extension/SecurityExtension.php index 8ff7ea79..863df156 100644 --- a/Extension/SecurityExtension.php +++ b/Extension/SecurityExtension.php @@ -25,20 +25,13 @@ */ final class SecurityExtension extends AbstractExtension { - private $securityChecker; - - private $impersonateUrlGenerator; - - public function __construct(?AuthorizationCheckerInterface $securityChecker = null, ?ImpersonateUrlGenerator $impersonateUrlGenerator = null) - { - $this->securityChecker = $securityChecker; - $this->impersonateUrlGenerator = $impersonateUrlGenerator; + public function __construct( + private ?AuthorizationCheckerInterface $securityChecker = null, + private ?ImpersonateUrlGenerator $impersonateUrlGenerator = null, + ) { } - /** - * @param mixed $object - */ - public function isGranted($role, $object = null, ?string $field = null): bool + public function isGranted(mixed $role, mixed $object = null, ?string $field = null): bool { if (null === $this->securityChecker) { return false; @@ -50,7 +43,7 @@ public function isGranted($role, $object = null, ?string $field = null): bool try { return $this->securityChecker->isGranted($role, $object); - } catch (AuthenticationCredentialsNotFoundException $e) { + } catch (AuthenticationCredentialsNotFoundException) { return false; } } @@ -73,15 +66,32 @@ public function getImpersonateExitPath(?string $exitTo = null): string return $this->impersonateUrlGenerator->generateExitPath($exitTo); } - /** - * {@inheritdoc} - */ + public function getImpersonateUrl(string $identifier): string + { + if (null === $this->impersonateUrlGenerator) { + return ''; + } + + return $this->impersonateUrlGenerator->generateImpersonationUrl($identifier); + } + + public function getImpersonatePath(string $identifier): string + { + if (null === $this->impersonateUrlGenerator) { + return ''; + } + + return $this->impersonateUrlGenerator->generateImpersonationPath($identifier); + } + public function getFunctions(): array { return [ - new TwigFunction('is_granted', [$this, 'isGranted']), - new TwigFunction('impersonation_exit_url', [$this, 'getImpersonateExitUrl']), - new TwigFunction('impersonation_exit_path', [$this, 'getImpersonateExitPath']), + new TwigFunction('is_granted', $this->isGranted(...)), + new TwigFunction('impersonation_exit_url', $this->getImpersonateExitUrl(...)), + new TwigFunction('impersonation_exit_path', $this->getImpersonateExitPath(...)), + new TwigFunction('impersonation_url', $this->getImpersonateUrl(...)), + new TwigFunction('impersonation_path', $this->getImpersonatePath(...)), ]; } } diff --git a/Extension/SerializerRuntime.php b/Extension/SerializerRuntime.php index 3a4087aa..22715733 100644 --- a/Extension/SerializerRuntime.php +++ b/Extension/SerializerRuntime.php @@ -19,14 +19,12 @@ */ final class SerializerRuntime implements RuntimeExtensionInterface { - private $serializer; - - public function __construct(SerializerInterface $serializer) - { - $this->serializer = $serializer; + public function __construct( + private SerializerInterface $serializer, + ) { } - public function serialize($data, string $format = 'json', array $context = []): string + public function serialize(mixed $data, string $format = 'json', array $context = []): string { return $this->serializer->serialize($data, $format, $context); } diff --git a/Extension/StopwatchExtension.php b/Extension/StopwatchExtension.php index 4531ee50..ba56d127 100644 --- a/Extension/StopwatchExtension.php +++ b/Extension/StopwatchExtension.php @@ -23,13 +23,10 @@ */ final class StopwatchExtension extends AbstractExtension { - private $stopwatch; - private $enabled; - - public function __construct(?Stopwatch $stopwatch = null, bool $enabled = true) - { - $this->stopwatch = $stopwatch; - $this->enabled = $enabled; + public function __construct( + private ?Stopwatch $stopwatch = null, + private bool $enabled = true, + ) { } public function getStopwatch(): Stopwatch diff --git a/Extension/TranslationExtension.php b/Extension/TranslationExtension.php index f961b3de..73c9ec85 100644 --- a/Extension/TranslationExtension.php +++ b/Extension/TranslationExtension.php @@ -34,23 +34,20 @@ class_exists(TranslatorTrait::class); */ final class TranslationExtension extends AbstractExtension { - private $translator; - private $translationNodeVisitor; - - public function __construct(?TranslatorInterface $translator = null, ?TranslationNodeVisitor $translationNodeVisitor = null) - { - $this->translator = $translator; - $this->translationNodeVisitor = $translationNodeVisitor; + public function __construct( + private ?TranslatorInterface $translator = null, + private ?TranslationNodeVisitor $translationNodeVisitor = null, + ) { } public function getTranslator(): TranslatorInterface { if (null === $this->translator) { if (!interface_exists(TranslatorInterface::class)) { - throw new \LogicException(sprintf('You cannot use the "%s" if the Translation Contracts are not available. Try running "composer require symfony/translation".', __CLASS__)); + throw new \LogicException(\sprintf('You cannot use the "%s" if the Translation Contracts are not available. Try running "composer require symfony/translation".', __CLASS__)); } - $this->translator = new class() implements TranslatorInterface { + $this->translator = new class implements TranslatorInterface { use TranslatorTrait; }; } @@ -58,29 +55,20 @@ public function getTranslator(): TranslatorInterface return $this->translator; } - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [ - new TwigFunction('t', [$this, 'createTranslatable']), + new TwigFunction('t', $this->createTranslatable(...)), ]; } - /** - * {@inheritdoc} - */ public function getFilters(): array { return [ - new TwigFilter('trans', [$this, 'trans']), + new TwigFilter('trans', $this->trans(...)), ]; } - /** - * {@inheritdoc} - */ public function getTokenParsers(): array { return [ @@ -92,9 +80,6 @@ public function getTokenParsers(): array ]; } - /** - * {@inheritdoc} - */ public function getNodeVisitors(): array { return [$this->getTranslationNodeVisitor(), new TranslationDefaultDomainNodeVisitor()]; @@ -106,14 +91,13 @@ public function getTranslationNodeVisitor(): TranslationNodeVisitor } /** - * @param string|\Stringable|TranslatableInterface|null $message - * @param array|string $arguments Can be the locale as a string when $message is a TranslatableInterface + * @param array|string $arguments Can be the locale as a string when $message is a TranslatableInterface */ - public function trans($message, $arguments = [], ?string $domain = null, ?string $locale = null, ?int $count = null): string + public function trans(string|\Stringable|TranslatableInterface|null $message, array|string $arguments = [], ?string $domain = null, ?string $locale = null, ?int $count = null): string { if ($message instanceof TranslatableInterface) { if ([] !== $arguments && !\is_string($arguments)) { - throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be a locale passed as a string when the message is a "%s", "%s" given.', __METHOD__, TranslatableInterface::class, get_debug_type($arguments))); + throw new \TypeError(\sprintf('Argument 2 passed to "%s()" must be a locale passed as a string when the message is a "%s", "%s" given.', __METHOD__, TranslatableInterface::class, get_debug_type($arguments))); } if ($message instanceof TranslatableMessage && '' === $message->getMessage()) { @@ -124,7 +108,7 @@ public function trans($message, $arguments = [], ?string $domain = null, ?string } if (!\is_array($arguments)) { - throw new \TypeError(sprintf('Unless the message is a "%s", argument 2 passed to "%s()" must be an array of parameters, "%s" given.', TranslatableInterface::class, __METHOD__, get_debug_type($arguments))); + throw new \TypeError(\sprintf('Unless the message is a "%s", argument 2 passed to "%s()" must be an array of parameters, "%s" given.', TranslatableInterface::class, __METHOD__, get_debug_type($arguments))); } if ('' === $message = (string) $message) { @@ -141,7 +125,7 @@ public function trans($message, $arguments = [], ?string $domain = null, ?string public function createTranslatable(string $message, array $parameters = [], ?string $domain = null): TranslatableMessage { if (!class_exists(TranslatableMessage::class)) { - throw new \LogicException(sprintf('You cannot use the "%s" as the Translation Component is not installed. Try running "composer require symfony/translation".', __CLASS__)); + throw new \LogicException(\sprintf('You cannot use the "%s" as the Translation Component is not installed. Try running "composer require symfony/translation".', __CLASS__)); } return new TranslatableMessage($message, $parameters, $domain); diff --git a/Extension/WebLinkExtension.php b/Extension/WebLinkExtension.php index 652a7576..9eeb305a 100644 --- a/Extension/WebLinkExtension.php +++ b/Extension/WebLinkExtension.php @@ -24,25 +24,20 @@ */ final class WebLinkExtension extends AbstractExtension { - private $requestStack; - - public function __construct(RequestStack $requestStack) - { - $this->requestStack = $requestStack; + public function __construct( + private RequestStack $requestStack, + ) { } - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [ - new TwigFunction('link', [$this, 'link']), - new TwigFunction('preload', [$this, 'preload']), - new TwigFunction('dns_prefetch', [$this, 'dnsPrefetch']), - new TwigFunction('preconnect', [$this, 'preconnect']), - new TwigFunction('prefetch', [$this, 'prefetch']), - new TwigFunction('prerender', [$this, 'prerender']), + new TwigFunction('link', $this->link(...)), + new TwigFunction('preload', $this->preload(...)), + new TwigFunction('dns_prefetch', $this->dnsPrefetch(...)), + new TwigFunction('preconnect', $this->preconnect(...)), + new TwigFunction('prefetch', $this->prefetch(...)), + new TwigFunction('prerender', $this->prerender(...)), ]; } diff --git a/Extension/WorkflowExtension.php b/Extension/WorkflowExtension.php index 3b783fc7..0fcc9b3f 100644 --- a/Extension/WorkflowExtension.php +++ b/Extension/WorkflowExtension.php @@ -25,26 +25,21 @@ */ final class WorkflowExtension extends AbstractExtension { - private $workflowRegistry; - - public function __construct(Registry $workflowRegistry) - { - $this->workflowRegistry = $workflowRegistry; + public function __construct( + private Registry $workflowRegistry, + ) { } - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [ - new TwigFunction('workflow_can', [$this, 'canTransition']), - new TwigFunction('workflow_transitions', [$this, 'getEnabledTransitions']), - new TwigFunction('workflow_transition', [$this, 'getEnabledTransition']), - new TwigFunction('workflow_has_marked_place', [$this, 'hasMarkedPlace']), - new TwigFunction('workflow_marked_places', [$this, 'getMarkedPlaces']), - new TwigFunction('workflow_metadata', [$this, 'getMetadata']), - new TwigFunction('workflow_transition_blockers', [$this, 'buildTransitionBlockerList']), + new TwigFunction('workflow_can', $this->canTransition(...)), + new TwigFunction('workflow_transitions', $this->getEnabledTransitions(...)), + new TwigFunction('workflow_transition', $this->getEnabledTransition(...)), + new TwigFunction('workflow_has_marked_place', $this->hasMarkedPlace(...)), + new TwigFunction('workflow_marked_places', $this->getMarkedPlaces(...)), + new TwigFunction('workflow_metadata', $this->getMetadata(...)), + new TwigFunction('workflow_transition_blockers', $this->buildTransitionBlockerList(...)), ]; } @@ -102,7 +97,7 @@ public function getMarkedPlaces(object $subject, bool $placesNameOnly = true, ?s * Use a string (the place name) to get place metadata * Use a Transition instance to get transition metadata */ - public function getMetadata(object $subject, string $key, $metadataSubject = null, ?string $name = null) + public function getMetadata(object $subject, string $key, string|Transition|null $metadataSubject = null, ?string $name = null): mixed { return $this ->workflowRegistry diff --git a/Extension/YamlExtension.php b/Extension/YamlExtension.php index 63df1336..cbfcc32a 100644 --- a/Extension/YamlExtension.php +++ b/Extension/YamlExtension.php @@ -22,24 +22,19 @@ */ final class YamlExtension extends AbstractExtension { - /** - * {@inheritdoc} - */ public function getFilters(): array { return [ - new TwigFilter('yaml_encode', [$this, 'encode']), - new TwigFilter('yaml_dump', [$this, 'dump']), + new TwigFilter('yaml_encode', $this->encode(...)), + new TwigFilter('yaml_dump', $this->dump(...)), ]; } - public function encode($input, int $inline = 0, int $dumpObjects = 0): string + public function encode(mixed $input, int $inline = 0, int $dumpObjects = 0): string { static $dumper; - if (null === $dumper) { - $dumper = new YamlDumper(); - } + $dumper ??= new YamlDumper(); if (\defined('Symfony\Component\Yaml\Yaml::DUMP_OBJECT')) { return $dumper->dump($input, $inline, 0, $dumpObjects); @@ -48,7 +43,7 @@ public function encode($input, int $inline = 0, int $dumpObjects = 0): string return $dumper->dump($input, $inline, 0, false, $dumpObjects); } - public function dump($value, int $inline = 0, int $dumpObjects = 0): string + public function dump(mixed $value, int $inline = 0, int $dumpObjects = 0): string { if (\is_resource($value)) { return '%Resource%'; diff --git a/Form/TwigRendererEngine.php b/Form/TwigRendererEngine.php index e94dd2e4..d2936f44 100644 --- a/Form/TwigRendererEngine.php +++ b/Form/TwigRendererEngine.php @@ -21,26 +21,16 @@ */ class TwigRendererEngine extends AbstractRendererEngine { - /** - * @var Environment - */ - private $environment; - - /** - * @var Template - */ - private $template; + private Template $template; - public function __construct(array $defaultThemes, Environment $environment) - { + public function __construct( + array $defaultThemes, + private Environment $environment, + ) { parent::__construct($defaultThemes); - $this->environment = $environment; } - /** - * {@inheritdoc} - */ - public function renderBlock(FormView $view, $resource, string $blockName, array $variables = []) + public function renderBlock(FormView $view, mixed $resource, string $blockName, array $variables = []): string { $cacheKey = $view->vars[self::CACHE_KEY_VAR]; @@ -69,10 +59,8 @@ public function renderBlock(FormView $view, $resource, string $blockName, array * case that the function "block()" is used in the Twig template. * * @see getResourceForBlock() - * - * @return bool */ - protected function loadResourceForBlockName(string $cacheKey, FormView $view, string $blockName) + protected function loadResourceForBlockName(string $cacheKey, FormView $view, string $blockName): bool { // The caller guarantees that $this->resources[$cacheKey][$block] is // not set, but it doesn't have to check whether $this->resources[$cacheKey] @@ -145,20 +133,17 @@ protected function loadResourceForBlockName(string $cacheKey, FormView $view, st * this variable will be kept and be available upon * further calls to this method using the same theme. */ - protected function loadResourcesFromTheme(string $cacheKey, &$theme) + protected function loadResourcesFromTheme(string $cacheKey, mixed &$theme): void { if (!$theme instanceof Template) { - /* @var Template $theme */ $theme = $this->environment->load($theme)->unwrap(); } - if (null === $this->template) { - // Store the first Template instance that we find so that - // we can call displayBlock() later on. It doesn't matter *which* - // template we use for that, since we pass the used blocks manually - // anyway. - $this->template = $theme; - } + // Store the first Template instance that we find so that + // we can call displayBlock() later on. It doesn't matter *which* + // template we use for that, since we pass the used blocks manually + // anyway. + $this->template ??= $theme; // Use a separate variable for the inheritance traversal, because // theme is a reference and we don't want to change it. diff --git a/Mime/BodyRenderer.php b/Mime/BodyRenderer.php index 47901d31..00b7ba00 100644 --- a/Mime/BodyRenderer.php +++ b/Mime/BodyRenderer.php @@ -11,10 +11,14 @@ namespace Symfony\Bridge\Twig\Mime; -use League\HTMLToMarkdown\HtmlConverter; +use League\HTMLToMarkdown\HtmlConverterInterface; use Symfony\Component\Mime\BodyRendererInterface; use Symfony\Component\Mime\Exception\InvalidArgumentException; +use Symfony\Component\Mime\HtmlToTextConverter\DefaultHtmlToTextConverter; +use Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface; +use Symfony\Component\Mime\HtmlToTextConverter\LeagueHtmlToMarkdownConverter; use Symfony\Component\Mime\Message; +use Symfony\Component\Translation\LocaleSwitcher; use Twig\Environment; /** @@ -22,21 +26,15 @@ */ final class BodyRenderer implements BodyRendererInterface { - private $twig; - private $context; - private $converter; - - public function __construct(Environment $twig, array $context = []) - { - $this->twig = $twig; - $this->context = $context; - if (class_exists(HtmlConverter::class)) { - $this->converter = new HtmlConverter([ - 'hard_break' => true, - 'strip_tags' => true, - 'remove_nodes' => 'head style', - ]); - } + private HtmlToTextConverterInterface $converter; + + public function __construct( + private Environment $twig, + private array $context = [], + ?HtmlToTextConverterInterface $converter = null, + private ?LocaleSwitcher $localeSwitcher = null, + ) { + $this->converter = $converter ?: (interface_exists(HtmlConverterInterface::class) ? new LeagueHtmlToMarkdownConverter() : new DefaultHtmlToTextConverter()); } public function render(Message $message): void @@ -45,61 +43,47 @@ public function render(Message $message): void return; } - $messageContext = $message->getContext(); - - $previousRenderingKey = $messageContext[__CLASS__] ?? null; - unset($messageContext[__CLASS__]); - $currentRenderingKey = $this->getFingerPrint($message); - if ($previousRenderingKey === $currentRenderingKey) { + if ($message->isRendered()) { + // email has already been rendered return; } - if (isset($messageContext['email'])) { - throw new InvalidArgumentException(sprintf('A "%s" context cannot have an "email" entry as this is a reserved variable.', get_debug_type($message))); - } + $callback = function () use ($message) { + $messageContext = $message->getContext(); - $vars = array_merge($this->context, $messageContext, [ - 'email' => new WrappedTemplatedEmail($this->twig, $message), - ]); + if (isset($messageContext['email'])) { + throw new InvalidArgumentException(\sprintf('A "%s" context cannot have an "email" entry as this is a reserved variable.', get_debug_type($message))); + } - if ($template = $message->getTextTemplate()) { - $message->text($this->twig->render($template, $vars)); - } + $vars = array_merge($this->context, $messageContext, [ + 'email' => new WrappedTemplatedEmail($this->twig, $message), + ]); - if ($template = $message->getHtmlTemplate()) { - $message->html($this->twig->render($template, $vars)); - } + if ($template = $message->getTextTemplate()) { + $message->text($this->twig->render($template, $vars)); + } - // if text body is empty, compute one from the HTML body - if (!$message->getTextBody() && null !== $html = $message->getHtmlBody()) { - $message->text($this->convertHtmlToText(\is_resource($html) ? stream_get_contents($html) : $html)); - } - $message->context($message->getContext() + [__CLASS__ => $currentRenderingKey]); - } + if ($template = $message->getHtmlTemplate()) { + $message->html($this->twig->render($template, $vars)); + } - private function getFingerPrint(TemplatedEmail $message): string - { - $messageContext = $message->getContext(); - unset($messageContext[__CLASS__]); - - $payload = [$messageContext, $message->getTextTemplate(), $message->getHtmlTemplate()]; - try { - $serialized = serialize($payload); - } catch (\Exception $e) { - // Serialization of 'Closure' is not allowed - // Happens when context contain a closure, in that case, we assume that context always change. - $serialized = random_bytes(8); - } + $message->markAsRendered(); - return md5($serialized); - } + // if text body is empty, compute one from the HTML body + if (!$message->getTextBody() && null !== $html = $message->getHtmlBody()) { + $text = $this->converter->convert(\is_resource($html) ? stream_get_contents($html) : $html, $message->getHtmlCharset()); + $message->text($text, $message->getHtmlCharset()); + } + }; - private function convertHtmlToText(string $html): string - { - if (null !== $this->converter) { - return $this->converter->convert($html); + $locale = $message->getLocale(); + + if ($locale && $this->localeSwitcher) { + $this->localeSwitcher->runWithLocale($locale, $callback); + + return; } - return strip_tags(preg_replace('{<(head|style)\b.*?}is', '', $html)); + $callback(); } } diff --git a/Mime/NotificationEmail.php b/Mime/NotificationEmail.php index 2a661445..4b4e1b26 100644 --- a/Mime/NotificationEmail.php +++ b/Mime/NotificationEmail.php @@ -14,6 +14,7 @@ use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\Mime\Header\Headers; use Symfony\Component\Mime\Part\AbstractPart; +use Symfony\Component\Mime\Part\DataPart; use Twig\Extra\CssInliner\CssInlinerExtension; use Twig\Extra\Inky\InkyExtension; use Twig\Extra\Markdown\MarkdownExtension; @@ -28,8 +29,8 @@ class NotificationEmail extends TemplatedEmail public const IMPORTANCE_MEDIUM = 'medium'; public const IMPORTANCE_LOW = 'low'; - private $theme = 'default'; - private $context = [ + private string $theme = 'default'; + private array $context = [ 'importance' => self::IMPORTANCE_LOW, 'content' => '', 'exception' => false, @@ -37,8 +38,9 @@ class NotificationEmail extends TemplatedEmail 'action_url' => null, 'markdown' => false, 'raw' => false, - 'footer_text' => 'Notification e-mail sent by Symfony', + 'footer_text' => 'Notification email sent by Symfony', ]; + private bool $rendered = false; public function __construct(?Headers $headers = null, ?AbstractPart $body = null) { @@ -52,7 +54,7 @@ public function __construct(?Headers $headers = null, ?AbstractPart $body = null } if ($missingPackages) { - throw new \LogicException(sprintf('You cannot use "%s" if the "%s" Twig extension%s not available; try running "%s".', static::class, implode('" and "', $missingPackages), \count($missingPackages) > 1 ? 's are' : ' is', 'composer require '.implode(' ', array_keys($missingPackages)))); + throw new \LogicException(\sprintf('You cannot use "%s" if the "%s" Twig extension%s not available. Try running "%s".', static::class, implode('" and "', $missingPackages), \count($missingPackages) > 1 ? 's are' : ' is', 'composer require '.implode(' ', array_keys($missingPackages)))); } parent::__construct($headers, $body); @@ -72,7 +74,7 @@ public static function asPublicEmail(?Headers $headers = null, ?AbstractPart $bo /** * @return $this */ - public function markAsPublic(): self + public function markAsPublic(): static { $this->context['importance'] = null; $this->context['footer_text'] = null; @@ -83,10 +85,10 @@ public function markAsPublic(): self /** * @return $this */ - public function markdown(string $content) + public function markdown(string $content): static { if (!class_exists(MarkdownExtension::class)) { - throw new \LogicException(sprintf('You cannot use "%s" if the Markdown Twig extension is not available; try running "composer require twig/markdown-extra".', __METHOD__)); + throw new \LogicException(\sprintf('You cannot use "%s" if the Markdown Twig extension is not available. Try running "composer require twig/markdown-extra".', __METHOD__)); } $this->context['markdown'] = true; @@ -97,7 +99,7 @@ public function markdown(string $content) /** * @return $this */ - public function content(string $content, bool $raw = false) + public function content(string $content, bool $raw = false): static { $this->context['content'] = $content; $this->context['raw'] = $raw; @@ -108,7 +110,7 @@ public function content(string $content, bool $raw = false) /** * @return $this */ - public function action(string $text, string $url) + public function action(string $text, string $url): static { $this->context['action_text'] = $text; $this->context['action_url'] = $url; @@ -119,7 +121,7 @@ public function action(string $text, string $url) /** * @return $this */ - public function importance(string $importance) + public function importance(string $importance): static { $this->context['importance'] = $importance; @@ -127,20 +129,14 @@ public function importance(string $importance) } /** - * @param \Throwable|FlattenException $exception - * * @return $this */ - public function exception($exception) + public function exception(\Throwable|FlattenException $exception): static { - if (!$exception instanceof \Throwable && !$exception instanceof FlattenException) { - throw new \LogicException(sprintf('"%s" accepts "%s" or "%s" instances.', __METHOD__, \Throwable::class, FlattenException::class)); - } - $exceptionAsString = $this->getExceptionAsString($exception); $this->context['exception'] = true; - $this->attach($exceptionAsString, 'exception.txt', 'text/plain'); + $this->addPart(new DataPart($exceptionAsString, 'exception.txt', 'text/plain')); $this->importance(self::IMPORTANCE_URGENT); if (!$this->getSubject()) { @@ -153,7 +149,7 @@ public function exception($exception) /** * @return $this */ - public function theme(string $theme) + public function theme(string $theme): static { $this->theme = $theme; @@ -181,7 +177,7 @@ public function getHtmlTemplate(): ?string /** * @return $this */ - public function context(array $context) + public function context(array $context): static { $parentContext = []; @@ -203,6 +199,18 @@ public function getContext(): array return array_merge($this->context, parent::getContext()); } + public function isRendered(): bool + { + return $this->rendered; + } + + public function markAsRendered(): void + { + parent::markAsRendered(); + + $this->rendered = true; + } + public function getPreparedHeaders(): Headers { $headers = parent::getPreparedHeaders(); @@ -210,7 +218,7 @@ public function getPreparedHeaders(): Headers $importance = $this->context['importance'] ?? self::IMPORTANCE_LOW; $this->priority($this->determinePriority($importance)); if ($this->context['importance']) { - $headers->setHeaderBody('Text', 'Subject', sprintf('[%s] %s', strtoupper($importance), $this->getSubject())); + $headers->setHeaderBody('Text', 'Subject', \sprintf('[%s] %s', strtoupper($importance), $this->getSubject())); } return $headers; @@ -218,20 +226,15 @@ public function getPreparedHeaders(): Headers private function determinePriority(string $importance): int { - switch ($importance) { - case self::IMPORTANCE_URGENT: - return self::PRIORITY_HIGHEST; - case self::IMPORTANCE_HIGH: - return self::PRIORITY_HIGH; - case self::IMPORTANCE_MEDIUM: - return self::PRIORITY_NORMAL; - case self::IMPORTANCE_LOW: - default: - return self::PRIORITY_LOW; - } + return match ($importance) { + self::IMPORTANCE_URGENT => self::PRIORITY_HIGHEST, + self::IMPORTANCE_HIGH => self::PRIORITY_HIGH, + self::IMPORTANCE_MEDIUM => self::PRIORITY_NORMAL, + default => self::PRIORITY_LOW, + }; } - private function getExceptionAsString($exception): string + private function getExceptionAsString(\Throwable|FlattenException $exception): string { if (class_exists(FlattenException::class)) { $exception = $exception instanceof FlattenException ? $exception : FlattenException::createFromThrowable($exception); @@ -239,7 +242,7 @@ private function getExceptionAsString($exception): string return $exception->getAsString(); } - $message = \get_class($exception); + $message = $exception::class; if ('' !== $exception->getMessage()) { $message .= ': '.$exception->getMessage(); } @@ -255,7 +258,7 @@ private function getExceptionAsString($exception): string */ public function __serialize(): array { - return [$this->context, $this->theme, parent::__serialize()]; + return [$this->context, $this->theme, $this->rendered, parent::__serialize()]; } /** @@ -263,7 +266,9 @@ public function __serialize(): array */ public function __unserialize(array $data): void { - if (3 === \count($data)) { + if (4 === \count($data)) { + [$this->context, $this->theme, $this->rendered, $parentData] = $data; + } elseif (3 === \count($data)) { [$this->context, $this->theme, $parentData] = $data; } else { // Backwards compatibility for deserializing data structures that were serialized without the theme diff --git a/Mime/TemplatedEmail.php b/Mime/TemplatedEmail.php index 6dd9202d..2d308947 100644 --- a/Mime/TemplatedEmail.php +++ b/Mime/TemplatedEmail.php @@ -18,14 +18,15 @@ */ class TemplatedEmail extends Email { - private $htmlTemplate; - private $textTemplate; - private $context = []; + private ?string $htmlTemplate = null; + private ?string $textTemplate = null; + private ?string $locale = null; + private array $context = []; /** * @return $this */ - public function textTemplate(?string $template) + public function textTemplate(?string $template): static { $this->textTemplate = $template; @@ -35,13 +36,23 @@ public function textTemplate(?string $template) /** * @return $this */ - public function htmlTemplate(?string $template) + public function htmlTemplate(?string $template): static { $this->htmlTemplate = $template; return $this; } + /** + * @return $this + */ + public function locale(?string $locale): static + { + $this->locale = $locale; + + return $this; + } + public function getTextTemplate(): ?string { return $this->textTemplate; @@ -52,10 +63,15 @@ public function getHtmlTemplate(): ?string return $this->htmlTemplate; } + public function getLocale(): ?string + { + return $this->locale; + } + /** * @return $this */ - public function context(array $context) + public function context(array $context): static { $this->context = $context; @@ -67,12 +83,24 @@ public function getContext(): array return $this->context; } + public function isRendered(): bool + { + return null === $this->htmlTemplate && null === $this->textTemplate; + } + + public function markAsRendered(): void + { + $this->textTemplate = null; + $this->htmlTemplate = null; + $this->context = []; + } + /** * @internal */ public function __serialize(): array { - return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize()]; + return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale]; } /** @@ -81,6 +109,7 @@ public function __serialize(): array public function __unserialize(array $data): void { [$this->htmlTemplate, $this->textTemplate, $this->context, $parentData] = $data; + $this->locale = $data[4] ?? null; parent::__unserialize($parentData); } diff --git a/Mime/WrappedTemplatedEmail.php b/Mime/WrappedTemplatedEmail.php index dcdb4e34..a327e94b 100644 --- a/Mime/WrappedTemplatedEmail.php +++ b/Mime/WrappedTemplatedEmail.php @@ -12,6 +12,8 @@ namespace Symfony\Bridge\Twig\Mime; use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Part\DataPart; +use Symfony\Component\Mime\Part\File; use Twig\Environment; /** @@ -21,13 +23,10 @@ */ final class WrappedTemplatedEmail { - private $twig; - private $message; - - public function __construct(Environment $twig, TemplatedEmail $message) - { - $this->twig = $twig; - $this->message = $message; + public function __construct( + private Environment $twig, + private TemplatedEmail $message, + ) { } public function toName(): string @@ -44,11 +43,8 @@ public function toName(): string public function image(string $image, ?string $contentType = null): string { $file = $this->twig->getLoader()->getSourceContext($image); - if ($path = $file->getPath()) { - $this->message->embedFromPath($path, $image, $contentType); - } else { - $this->message->embed($file->getCode(), $image, $contentType); - } + $body = $file->getPath() ? new File($file->getPath()) : $file->getCode(); + $this->message->addPart((new DataPart($body, $image, $contentType))->asInline()); return 'cid:'.$image; } @@ -63,17 +59,14 @@ public function image(string $image, ?string $contentType = null): string public function attach(string $file, ?string $name = null, ?string $contentType = null): void { $file = $this->twig->getLoader()->getSourceContext($file); - if ($path = $file->getPath()) { - $this->message->attachFromPath($path, $name, $contentType); - } else { - $this->message->attach($file->getCode(), $name, $contentType); - } + $body = $file->getPath() ? new File($file->getPath()) : $file->getCode(); + $this->message->addPart(new DataPart($body, $name, $contentType)); } /** * @return $this */ - public function setSubject(string $subject): self + public function setSubject(string $subject): static { $this->message->subject($subject); @@ -88,7 +81,7 @@ public function getSubject(): ?string /** * @return $this */ - public function setReturnPath(string $address): self + public function setReturnPath(string $address): static { $this->message->returnPath($address); @@ -103,7 +96,7 @@ public function getReturnPath(): string /** * @return $this */ - public function addFrom(string $address, string $name = ''): self + public function addFrom(string $address, string $name = ''): static { $this->message->addFrom(new Address($address, $name)); @@ -121,7 +114,7 @@ public function getFrom(): array /** * @return $this */ - public function addReplyTo(string $address): self + public function addReplyTo(string $address): static { $this->message->addReplyTo($address); @@ -139,7 +132,7 @@ public function getReplyTo(): array /** * @return $this */ - public function addTo(string $address, string $name = ''): self + public function addTo(string $address, string $name = ''): static { $this->message->addTo(new Address($address, $name)); @@ -157,7 +150,7 @@ public function getTo(): array /** * @return $this */ - public function addCc(string $address, string $name = ''): self + public function addCc(string $address, string $name = ''): static { $this->message->addCc(new Address($address, $name)); @@ -175,7 +168,7 @@ public function getCc(): array /** * @return $this */ - public function addBcc(string $address, string $name = ''): self + public function addBcc(string $address, string $name = ''): static { $this->message->addBcc(new Address($address, $name)); @@ -193,7 +186,7 @@ public function getBcc(): array /** * @return $this */ - public function setPriority(int $priority): self + public function setPriority(int $priority): static { $this->message->priority($priority); diff --git a/Node/DumpNode.php b/Node/DumpNode.php index b4313b1a..3aaa510a 100644 --- a/Node/DumpNode.php +++ b/Node/DumpNode.php @@ -11,7 +11,6 @@ namespace Symfony\Bridge\Twig\Node; -use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\Variable\LocalVariable; @@ -23,32 +22,17 @@ #[YieldReady] final class DumpNode extends Node { - /** - * @var LocalVariable|string - */ - private $varPrefix; - - /** - * @param LocalVariable|string $varPrefix - */ - public function __construct($varPrefix, ?Node $values, int $lineno, ?string $tag = null) - { - if (!\is_string($varPrefix) && !$varPrefix instanceof LocalVariable) { - throw new \TypeError(sprintf('Expected a string or an instance of "%s", but got "%s".', LocalVariable::class, get_debug_type($varPrefix))); - } - + public function __construct( + private LocalVariable|string $varPrefix, + ?Node $values, + int $lineno, + ) { $nodes = []; if (null !== $values) { $nodes['values'] = $values; } - if (class_exists(FirstClassTwigCallableReady::class)) { - parent::__construct($nodes, [], $lineno); - } else { - parent::__construct($nodes, [], $lineno, $tag); - } - - $this->varPrefix = $varPrefix; + parent::__construct($nodes, [], $lineno); } public function compile(Compiler $compiler): void @@ -66,18 +50,18 @@ public function compile(Compiler $compiler): void if (!$this->hasNode('values')) { // remove embedded templates (macros) from the context $compiler - ->write(sprintf('$%svars = [];'."\n", $varPrefix)) - ->write(sprintf('foreach ($context as $%1$skey => $%1$sval) {'."\n", $varPrefix)) + ->write(\sprintf('$%svars = [];'."\n", $varPrefix)) + ->write(\sprintf('foreach ($context as $%1$skey => $%1$sval) {'."\n", $varPrefix)) ->indent() - ->write(sprintf('if (!$%sval instanceof \Twig\Template) {'."\n", $varPrefix)) + ->write(\sprintf('if (!$%sval instanceof \Twig\Template) {'."\n", $varPrefix)) ->indent() - ->write(sprintf('$%1$svars[$%1$skey] = $%1$sval;'."\n", $varPrefix)) + ->write(\sprintf('$%1$svars[$%1$skey] = $%1$sval;'."\n", $varPrefix)) ->outdent() ->write("}\n") ->outdent() ->write("}\n") ->addDebugInfo($this) - ->write(sprintf('\Symfony\Component\VarDumper\VarDumper::dump($%svars);'."\n", $varPrefix)); + ->write(\sprintf('\Symfony\Component\VarDumper\VarDumper::dump($%svars);'."\n", $varPrefix)); } elseif (($values = $this->getNode('values')) && 1 === $values->count()) { $compiler ->addDebugInfo($this) diff --git a/Node/FormThemeNode.php b/Node/FormThemeNode.php index 1d077097..9d9bce1e 100644 --- a/Node/FormThemeNode.php +++ b/Node/FormThemeNode.php @@ -12,7 +12,6 @@ namespace Symfony\Bridge\Twig\Node; use Symfony\Component\Form\FormRenderer; -use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Node; @@ -23,13 +22,19 @@ #[YieldReady] final class FormThemeNode extends Node { - public function __construct(Node $form, Node $resources, int $lineno, ?string $tag = null, bool $only = false) + /** + * @param bool $only + */ + public function __construct(Node $form, Node $resources, int $lineno, $only = false) { - if (class_exists(FirstClassTwigCallableReady::class)) { - parent::__construct(['form' => $form, 'resources' => $resources], ['only' => $only], $lineno); - } else { - parent::__construct(['form' => $form, 'resources' => $resources], ['only' => $only], $lineno, $tag); + if (null === $only || \is_string($only)) { + trigger_deprecation('symfony/twig-bridge', '3.12', 'Passing a tag to %s() is deprecated.', __METHOD__); + $only = \func_num_args() > 4 ? func_get_arg(4) : true; + } elseif (!\is_bool($only)) { + throw new \TypeError(\sprintf('Argument 4 passed to "%s()" must be a boolean, "%s" given.', __METHOD__, get_debug_type($only))); } + + parent::__construct(['form' => $form, 'resources' => $resources], ['only' => $only], $lineno); } public function compile(Compiler $compiler): void diff --git a/Node/StopwatchNode.php b/Node/StopwatchNode.php index e8ac13d6..472b6280 100644 --- a/Node/StopwatchNode.php +++ b/Node/StopwatchNode.php @@ -11,7 +11,6 @@ namespace Symfony\Bridge\Twig\Node; -use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AssignNameExpression; @@ -26,20 +25,9 @@ #[YieldReady] final class StopwatchNode extends Node { - /** - * @param AssignNameExpression|LocalVariable $var - */ - public function __construct(Node $name, Node $body, $var, int $lineno = 0, ?string $tag = null) + public function __construct(Node $name, Node $body, AssignNameExpression|LocalVariable $var, int $lineno = 0) { - if (!$var instanceof AssignNameExpression && !$var instanceof LocalVariable) { - throw new \TypeError(sprintf('Expected an instance of "%s" or "%s", but got "%s".', AssignNameExpression::class, LocalVariable::class, get_debug_type($var))); - } - - if (class_exists(FirstClassTwigCallableReady::class)) { - parent::__construct(['body' => $body, 'name' => $name, 'var' => $var], [], $lineno); - } else { - parent::__construct(['body' => $body, 'name' => $name, 'var' => $var], [], $lineno, $tag); - } + parent::__construct(['body' => $body, 'name' => $name, 'var' => $var], [], $lineno); } public function compile(Compiler $compiler): void diff --git a/Node/TransDefaultDomainNode.php b/Node/TransDefaultDomainNode.php index 28cb6f1b..04349839 100644 --- a/Node/TransDefaultDomainNode.php +++ b/Node/TransDefaultDomainNode.php @@ -11,7 +11,6 @@ namespace Symfony\Bridge\Twig\Node; -use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; @@ -23,13 +22,9 @@ #[YieldReady] final class TransDefaultDomainNode extends Node { - public function __construct(AbstractExpression $expr, int $lineno = 0, ?string $tag = null) + public function __construct(AbstractExpression $expr, int $lineno = 0) { - if (class_exists(FirstClassTwigCallableReady::class)) { - parent::__construct(['expr' => $expr], [], $lineno); - } else { - parent::__construct(['expr' => $expr], [], $lineno, $tag); - } + parent::__construct(['expr' => $expr], [], $lineno); } public function compile(Compiler $compiler): void diff --git a/Node/TransNode.php b/Node/TransNode.php index c1080fec..4064491f 100644 --- a/Node/TransNode.php +++ b/Node/TransNode.php @@ -11,7 +11,6 @@ namespace Symfony\Bridge\Twig\Node; -use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; @@ -28,7 +27,7 @@ #[YieldReady] final class TransNode extends Node { - public function __construct(Node $body, ?Node $domain = null, ?AbstractExpression $count = null, ?AbstractExpression $vars = null, ?AbstractExpression $locale = null, int $lineno = 0, ?string $tag = null) + public function __construct(Node $body, ?Node $domain = null, ?AbstractExpression $count = null, ?AbstractExpression $vars = null, ?AbstractExpression $locale = null, int $lineno = 0) { $nodes = ['body' => $body]; if (null !== $domain) { @@ -44,11 +43,7 @@ public function __construct(Node $body, ?Node $domain = null, ?AbstractExpressio $nodes['locale'] = $locale; } - if (class_exists(FirstClassTwigCallableReady::class)) { - parent::__construct($nodes, [], $lineno); - } else { - parent::__construct($nodes, [], $lineno, $tag); - } + parent::__construct($nodes, [], $lineno); } public function compile(Compiler $compiler): void @@ -61,10 +56,8 @@ public function compile(Compiler $compiler): void $vars = null; } [$msg, $defaults] = $this->compileString($this->getNode('body'), $defaults, (bool) $vars); - $display = class_exists(YieldReady::class) ? 'yield' : 'echo'; - $compiler - ->write($display.' $this->env->getExtension(\'Symfony\Bridge\Twig\Extension\TranslationExtension\')->trans(') + ->write('yield $this->env->getExtension(\'Symfony\Bridge\Twig\Extension\TranslationExtension\')->trans(') ->subcompile($msg) ; diff --git a/NodeVisitor/Scope.php b/NodeVisitor/Scope.php index 3e86f482..4914506f 100644 --- a/NodeVisitor/Scope.php +++ b/NodeVisitor/Scope.php @@ -16,31 +16,26 @@ */ class Scope { - private $parent; - private $data = []; - private $left = false; + private array $data = []; + private bool $left = false; - public function __construct(?self $parent = null) - { - $this->parent = $parent; + public function __construct( + private ?self $parent = null, + ) { } /** * Opens a new child scope. - * - * @return self */ - public function enter() + public function enter(): self { return new self($this); } /** * Closes current scope and returns parent one. - * - * @return self|null */ - public function leave() + public function leave(): ?self { $this->left = true; @@ -54,7 +49,7 @@ public function leave() * * @throws \LogicException */ - public function set(string $key, $value) + public function set(string $key, mixed $value): static { if ($this->left) { throw new \LogicException('Left scope is not mutable.'); @@ -67,10 +62,8 @@ public function set(string $key, $value) /** * Tests if a data is visible from current scope. - * - * @return bool */ - public function has(string $key) + public function has(string $key): bool { if (\array_key_exists($key, $this->data)) { return true; @@ -85,10 +78,8 @@ public function has(string $key) /** * Returns data visible from current scope. - * - * @return mixed */ - public function get(string $key, $default = null) + public function get(string $key, mixed $default = null): mixed { if (\array_key_exists($key, $this->data)) { return $this->data[$key]; diff --git a/NodeVisitor/TranslationDefaultDomainNodeVisitor.php b/NodeVisitor/TranslationDefaultDomainNodeVisitor.php index 671af9be..3b8196fa 100644 --- a/NodeVisitor/TranslationDefaultDomainNodeVisitor.php +++ b/NodeVisitor/TranslationDefaultDomainNodeVisitor.php @@ -34,7 +34,7 @@ */ final class TranslationDefaultDomainNodeVisitor implements NodeVisitorInterface { - private $scope; + private Scope $scope; public function __construct() { @@ -52,17 +52,25 @@ public function enterNode(Node $node, Environment $env): Node $this->scope->set('domain', $node->getNode('expr')); return $node; - } else { - $var = $this->getVarName(); - $name = class_exists(AssignContextVariable::class) ? new AssignContextVariable($var, $node->getTemplateLine()) : new AssignNameExpression($var, $node->getTemplateLine()); - $this->scope->set('domain', class_exists(ContextVariable::class) ? new ContextVariable($var, $node->getTemplateLine()) : new NameExpression($var, $node->getTemplateLine())); - - if (class_exists(Nodes::class)) { - return new SetNode(false, new Nodes([$name]), new Nodes([$node->getNode('expr')]), $node->getTemplateLine()); - } else { - return new SetNode(false, new Node([$name]), new Node([$node->getNode('expr')]), $node->getTemplateLine()); - } } + + if (null === $templateName = $node->getTemplateName()) { + throw new \LogicException('Cannot traverse a node without a template name.'); + } + + $var = '__internal_trans_default_domain'.hash('xxh128', $templateName); + + if (class_exists(Nodes::class)) { + $name = new AssignContextVariable($var, $node->getTemplateLine()); + $this->scope->set('domain', new ContextVariable($var, $node->getTemplateLine())); + + return new SetNode(false, new Nodes([$name]), new Nodes([$node->getNode('expr')]), $node->getTemplateLine()); + } + + $name = new AssignNameExpression($var, $node->getTemplateLine()); + $this->scope->set('domain', new NameExpression($var, $node->getTemplateLine())); + + return new SetNode(false, new Node([$name]), new Node([$node->getNode('expr')]), $node->getTemplateLine()); } if (!$this->scope->has('domain')) { @@ -110,9 +118,6 @@ public function leaveNode(Node $node, Environment $env): ?Node return $node; } - /** - * {@inheritdoc} - */ public function getPriority(): int { return -10; @@ -128,9 +133,4 @@ private function isNamedArguments(Node $arguments): bool return false; } - - private function getVarName(): string - { - return sprintf('__internal_%s', hash('sha256', uniqid(mt_rand(), true), false)); - } } diff --git a/NodeVisitor/TranslationNodeVisitor.php b/NodeVisitor/TranslationNodeVisitor.php index 274f6111..f2b8f197 100644 --- a/NodeVisitor/TranslationNodeVisitor.php +++ b/NodeVisitor/TranslationNodeVisitor.php @@ -29,8 +29,8 @@ final class TranslationNodeVisitor implements NodeVisitorInterface { public const UNDEFINED_DOMAIN = '_undefined'; - private $enabled = false; - private $messages = []; + private bool $enabled = false; + private array $messages = []; public function enable(): void { @@ -56,9 +56,9 @@ public function enterNode(Node $node, Environment $env): Node } if ( - $node instanceof FilterExpression && - 'trans' === ($node->hasAttribute('twig_callable') ? $node->getAttribute('twig_callable')->getName() : $node->getNode('filter')->getAttribute('value')) && - $node->getNode('node') instanceof ConstantExpression + $node instanceof FilterExpression + && 'trans' === ($node->hasAttribute('twig_callable') ? $node->getAttribute('twig_callable')->getName() : $node->getNode('filter')->getAttribute('value')) + && $node->getNode('node') instanceof ConstantExpression ) { // extract constant nodes with a trans filter $this->messages[] = [ @@ -66,8 +66,8 @@ public function enterNode(Node $node, Environment $env): Node $this->getReadDomainFromArguments($node->getNode('arguments'), 1), ]; } elseif ( - $node instanceof FunctionExpression && - 't' === $node->getAttribute('name') + $node instanceof FunctionExpression + && 't' === $node->getAttribute('name') ) { $nodeArguments = $node->getNode('arguments'); @@ -84,10 +84,10 @@ public function enterNode(Node $node, Environment $env): Node $node->hasNode('domain') ? $this->getReadDomainFromNode($node->getNode('domain')) : null, ]; } elseif ( - $node instanceof FilterExpression && - 'trans' === ($node->hasAttribute('twig_callable') ? $node->getAttribute('twig_callable')->getName() : $node->getNode('filter')->getAttribute('value')) && - $node->getNode('node') instanceof ConcatBinary && - $message = $this->getConcatValueFromNode($node->getNode('node'), null) + $node instanceof FilterExpression + && 'trans' === ($node->hasAttribute('twig_callable') ? $node->getAttribute('twig_callable')->getName() : $node->getNode('filter')->getAttribute('value')) + && $node->getNode('node') instanceof ConcatBinary + && $message = $this->getConcatValueFromNode($node->getNode('node'), null) ) { $this->messages[] = [ $message, @@ -103,9 +103,6 @@ public function leaveNode(Node $node, Environment $env): ?Node return $node; } - /** - * {@inheritdoc} - */ public function getPriority(): int { return 0; diff --git a/Resources/views/Email/zurb_2/main.css b/Resources/views/Email/zurb_2/main.css index dab0df58..7828ce78 100644 --- a/Resources/views/Email/zurb_2/main.css +++ b/Resources/views/Email/zurb_2/main.css @@ -1,7 +1,7 @@ /* * Copyright (c) 2017 ZURB, inc. -- MIT License * - * https://github.com/foundation/foundation-emails/blob/v2.2.1/dist/foundation-emails.css + * https://github.com/foundation/foundation-emails/blob/v2.4.0/dist/foundation-emails.css */ .wrapper { @@ -34,6 +34,7 @@ body { .ExternalClass span, .ExternalClass font, .ExternalClass td, +.ExternalClass th, .ExternalClass div { line-height: 100%; } @@ -58,34 +59,33 @@ img { center { width: 100%; - min-width: 580px; } a img { border: none; } -p { - margin: 0 0 0 10px; - Margin: 0 0 0 10px; -} - table { border-spacing: 0; border-collapse: collapse; } -td { +td, +th { word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + box-sizing: border-box; } table, tr, -td { +td, +th { padding: 0; vertical-align: top; text-align: left; @@ -140,27 +140,38 @@ th.column { padding-bottom: 16px; } -td.columns .column, -td.columns .columns, -td.column .column, -td.column .columns, -th.columns .column, -th.columns .columns, -th.column .column, -th.column .columns { +td.columns .column.first, +td.columns .columns.first, +td.column .column.first, +td.column .columns.first, +th.columns .column.first, +th.columns .columns.first, +th.column .column.first, +th.column .columns.first { padding-left: 0 !important; +} + +td.columns .column.last, +td.columns .columns.last, +td.column .column.last, +td.column .columns.last, +th.columns .column.last, +th.columns .columns.last, +th.column .column.last, +th.column .columns.last { padding-right: 0 !important; } -td.columns .column center, -td.columns .columns center, -td.column .column center, -td.column .columns center, -th.columns .column center, -th.columns .columns center, -th.column .column center, -th.column .columns center { - min-width: none !important; +td.columns .column:not([class*=large-offset]), +td.columns .columns:not([class*=large-offset]), +td.column .column:not([class*=large-offset]), +td.column .columns:not([class*=large-offset]), +th.columns .column:not([class*=large-offset]), +th.columns .columns:not([class*=large-offset]), +th.column .column:not([class*=large-offset]), +th.column .columns:not([class*=large-offset]) { + padding-left: 0 !important; + padding-right: 0 !important; } td.columns.last, @@ -170,16 +181,34 @@ th.column.last { padding-right: 16px; } -td.columns table:not(.button), -td.column table:not(.button), -th.columns table:not(.button), -th.column table:not(.button) { +td.columns table, +td.column table, +th.columns table, +th.column table { + width: 100%; +} + +td.columns table.button, +td.column table.button, +th.columns table.button, +th.column table.button { + width: auto; +} + +td.columns table.button.expand, +td.columns table.button.expanded, +td.column table.button.expand, +td.column table.button.expanded, +th.columns table.button.expand, +th.columns table.button.expanded, +th.column table.button.expand, +th.column table.button.expanded { width: 100%; } td.large-1, th.large-1 { - width: 32.33333px; + width: 32.3333333333px; padding-left: 8px; padding-right: 8px; } @@ -194,35 +223,30 @@ th.large-1.last { padding-right: 16px; } -.collapse>tbody>tr>td.large-1, -.collapse>tbody>tr>th.large-1 { +.collapse>tbody>tr>td.large-1:not([class*=large-offset]), +.collapse>tbody>tr>th.large-1:not([class*=large-offset]) { padding-right: 0; padding-left: 0; - width: 48.33333px; -} - -.collapse td.large-1.first, -.collapse th.large-1.first, -.collapse td.large-1.last, -.collapse th.large-1.last { - width: 56.33333px; + width: 48.3333333333px; } -td.large-1 center, -th.large-1 center { - min-width: 0.33333px; +.collapse>tbody>tr td.large-1.first, +.collapse>tbody>tr th.large-1.first, +.collapse>tbody>tr td.large-1.last, +.collapse>tbody>tr th.large-1.last { + width: 56.3333333333px; } .body .columns td.large-1, .body .column td.large-1, .body .columns th.large-1, .body .column th.large-1 { - width: 8.33333%; + width: 8.333333%; } td.large-2, th.large-2 { - width: 80.66667px; + width: 80.6666666667px; padding-left: 8px; padding-right: 8px; } @@ -237,30 +261,25 @@ th.large-2.last { padding-right: 16px; } -.collapse>tbody>tr>td.large-2, -.collapse>tbody>tr>th.large-2 { +.collapse>tbody>tr>td.large-2:not([class*=large-offset]), +.collapse>tbody>tr>th.large-2:not([class*=large-offset]) { padding-right: 0; padding-left: 0; - width: 96.66667px; + width: 96.6666666667px; } -.collapse td.large-2.first, -.collapse th.large-2.first, -.collapse td.large-2.last, -.collapse th.large-2.last { - width: 104.66667px; -} - -td.large-2 center, -th.large-2 center { - min-width: 48.66667px; +.collapse>tbody>tr td.large-2.first, +.collapse>tbody>tr th.large-2.first, +.collapse>tbody>tr td.large-2.last, +.collapse>tbody>tr th.large-2.last { + width: 104.6666666667px; } .body .columns td.large-2, .body .column td.large-2, .body .columns th.large-2, .body .column th.large-2 { - width: 16.66667%; + width: 16.666666%; } td.large-3, @@ -280,25 +299,20 @@ th.large-3.last { padding-right: 16px; } -.collapse>tbody>tr>td.large-3, -.collapse>tbody>tr>th.large-3 { +.collapse>tbody>tr>td.large-3:not([class*=large-offset]), +.collapse>tbody>tr>th.large-3:not([class*=large-offset]) { padding-right: 0; padding-left: 0; width: 145px; } -.collapse td.large-3.first, -.collapse th.large-3.first, -.collapse td.large-3.last, -.collapse th.large-3.last { +.collapse>tbody>tr td.large-3.first, +.collapse>tbody>tr th.large-3.first, +.collapse>tbody>tr td.large-3.last, +.collapse>tbody>tr th.large-3.last { width: 153px; } -td.large-3 center, -th.large-3 center { - min-width: 97px; -} - .body .columns td.large-3, .body .column td.large-3, .body .columns th.large-3, @@ -308,7 +322,7 @@ th.large-3 center { td.large-4, th.large-4 { - width: 177.33333px; + width: 177.3333333333px; padding-left: 8px; padding-right: 8px; } @@ -323,35 +337,30 @@ th.large-4.last { padding-right: 16px; } -.collapse>tbody>tr>td.large-4, -.collapse>tbody>tr>th.large-4 { +.collapse>tbody>tr>td.large-4:not([class*=large-offset]), +.collapse>tbody>tr>th.large-4:not([class*=large-offset]) { padding-right: 0; padding-left: 0; - width: 193.33333px; -} - -.collapse td.large-4.first, -.collapse th.large-4.first, -.collapse td.large-4.last, -.collapse th.large-4.last { - width: 201.33333px; + width: 193.3333333333px; } -td.large-4 center, -th.large-4 center { - min-width: 145.33333px; +.collapse>tbody>tr td.large-4.first, +.collapse>tbody>tr th.large-4.first, +.collapse>tbody>tr td.large-4.last, +.collapse>tbody>tr th.large-4.last { + width: 201.3333333333px; } .body .columns td.large-4, .body .column td.large-4, .body .columns th.large-4, .body .column th.large-4 { - width: 33.33333%; + width: 33.333333%; } td.large-5, th.large-5 { - width: 225.66667px; + width: 225.6666666667px; padding-left: 8px; padding-right: 8px; } @@ -366,30 +375,25 @@ th.large-5.last { padding-right: 16px; } -.collapse>tbody>tr>td.large-5, -.collapse>tbody>tr>th.large-5 { +.collapse>tbody>tr>td.large-5:not([class*=large-offset]), +.collapse>tbody>tr>th.large-5:not([class*=large-offset]) { padding-right: 0; padding-left: 0; - width: 241.66667px; + width: 241.6666666667px; } -.collapse td.large-5.first, -.collapse th.large-5.first, -.collapse td.large-5.last, -.collapse th.large-5.last { - width: 249.66667px; -} - -td.large-5 center, -th.large-5 center { - min-width: 193.66667px; +.collapse>tbody>tr td.large-5.first, +.collapse>tbody>tr th.large-5.first, +.collapse>tbody>tr td.large-5.last, +.collapse>tbody>tr th.large-5.last { + width: 249.6666666667px; } .body .columns td.large-5, .body .column td.large-5, .body .columns th.large-5, .body .column th.large-5 { - width: 41.66667%; + width: 41.666666%; } td.large-6, @@ -409,25 +413,20 @@ th.large-6.last { padding-right: 16px; } -.collapse>tbody>tr>td.large-6, -.collapse>tbody>tr>th.large-6 { +.collapse>tbody>tr>td.large-6:not([class*=large-offset]), +.collapse>tbody>tr>th.large-6:not([class*=large-offset]) { padding-right: 0; padding-left: 0; width: 290px; } -.collapse td.large-6.first, -.collapse th.large-6.first, -.collapse td.large-6.last, -.collapse th.large-6.last { +.collapse>tbody>tr td.large-6.first, +.collapse>tbody>tr th.large-6.first, +.collapse>tbody>tr td.large-6.last, +.collapse>tbody>tr th.large-6.last { width: 298px; } -td.large-6 center, -th.large-6 center { - min-width: 242px; -} - .body .columns td.large-6, .body .column td.large-6, .body .columns th.large-6, @@ -437,7 +436,7 @@ th.large-6 center { td.large-7, th.large-7 { - width: 322.33333px; + width: 322.3333333333px; padding-left: 8px; padding-right: 8px; } @@ -452,35 +451,30 @@ th.large-7.last { padding-right: 16px; } -.collapse>tbody>tr>td.large-7, -.collapse>tbody>tr>th.large-7 { +.collapse>tbody>tr>td.large-7:not([class*=large-offset]), +.collapse>tbody>tr>th.large-7:not([class*=large-offset]) { padding-right: 0; padding-left: 0; - width: 338.33333px; -} - -.collapse td.large-7.first, -.collapse th.large-7.first, -.collapse td.large-7.last, -.collapse th.large-7.last { - width: 346.33333px; + width: 338.3333333333px; } -td.large-7 center, -th.large-7 center { - min-width: 290.33333px; +.collapse>tbody>tr td.large-7.first, +.collapse>tbody>tr th.large-7.first, +.collapse>tbody>tr td.large-7.last, +.collapse>tbody>tr th.large-7.last { + width: 346.3333333333px; } .body .columns td.large-7, .body .column td.large-7, .body .columns th.large-7, .body .column th.large-7 { - width: 58.33333%; + width: 58.333333%; } td.large-8, th.large-8 { - width: 370.66667px; + width: 370.6666666667px; padding-left: 8px; padding-right: 8px; } @@ -495,30 +489,25 @@ th.large-8.last { padding-right: 16px; } -.collapse>tbody>tr>td.large-8, -.collapse>tbody>tr>th.large-8 { +.collapse>tbody>tr>td.large-8:not([class*=large-offset]), +.collapse>tbody>tr>th.large-8:not([class*=large-offset]) { padding-right: 0; padding-left: 0; - width: 386.66667px; -} - -.collapse td.large-8.first, -.collapse th.large-8.first, -.collapse td.large-8.last, -.collapse th.large-8.last { - width: 394.66667px; + width: 386.6666666667px; } -td.large-8 center, -th.large-8 center { - min-width: 338.66667px; +.collapse>tbody>tr td.large-8.first, +.collapse>tbody>tr th.large-8.first, +.collapse>tbody>tr td.large-8.last, +.collapse>tbody>tr th.large-8.last { + width: 394.6666666667px; } .body .columns td.large-8, .body .column td.large-8, .body .columns th.large-8, .body .column th.large-8 { - width: 66.66667%; + width: 66.666666%; } td.large-9, @@ -538,25 +527,20 @@ th.large-9.last { padding-right: 16px; } -.collapse>tbody>tr>td.large-9, -.collapse>tbody>tr>th.large-9 { +.collapse>tbody>tr>td.large-9:not([class*=large-offset]), +.collapse>tbody>tr>th.large-9:not([class*=large-offset]) { padding-right: 0; padding-left: 0; width: 435px; } -.collapse td.large-9.first, -.collapse th.large-9.first, -.collapse td.large-9.last, -.collapse th.large-9.last { +.collapse>tbody>tr td.large-9.first, +.collapse>tbody>tr th.large-9.first, +.collapse>tbody>tr td.large-9.last, +.collapse>tbody>tr th.large-9.last { width: 443px; } -td.large-9 center, -th.large-9 center { - min-width: 387px; -} - .body .columns td.large-9, .body .column td.large-9, .body .columns th.large-9, @@ -566,7 +550,7 @@ th.large-9 center { td.large-10, th.large-10 { - width: 467.33333px; + width: 467.3333333333px; padding-left: 8px; padding-right: 8px; } @@ -581,35 +565,30 @@ th.large-10.last { padding-right: 16px; } -.collapse>tbody>tr>td.large-10, -.collapse>tbody>tr>th.large-10 { +.collapse>tbody>tr>td.large-10:not([class*=large-offset]), +.collapse>tbody>tr>th.large-10:not([class*=large-offset]) { padding-right: 0; padding-left: 0; - width: 483.33333px; + width: 483.3333333333px; } -.collapse td.large-10.first, -.collapse th.large-10.first, -.collapse td.large-10.last, -.collapse th.large-10.last { - width: 491.33333px; -} - -td.large-10 center, -th.large-10 center { - min-width: 435.33333px; +.collapse>tbody>tr td.large-10.first, +.collapse>tbody>tr th.large-10.first, +.collapse>tbody>tr td.large-10.last, +.collapse>tbody>tr th.large-10.last { + width: 491.3333333333px; } .body .columns td.large-10, .body .column td.large-10, .body .columns th.large-10, .body .column th.large-10 { - width: 83.33333%; + width: 83.333333%; } td.large-11, th.large-11 { - width: 515.66667px; + width: 515.6666666667px; padding-left: 8px; padding-right: 8px; } @@ -624,30 +603,25 @@ th.large-11.last { padding-right: 16px; } -.collapse>tbody>tr>td.large-11, -.collapse>tbody>tr>th.large-11 { +.collapse>tbody>tr>td.large-11:not([class*=large-offset]), +.collapse>tbody>tr>th.large-11:not([class*=large-offset]) { padding-right: 0; padding-left: 0; - width: 531.66667px; -} - -.collapse td.large-11.first, -.collapse th.large-11.first, -.collapse td.large-11.last, -.collapse th.large-11.last { - width: 539.66667px; + width: 531.6666666667px; } -td.large-11 center, -th.large-11 center { - min-width: 483.66667px; +.collapse>tbody>tr td.large-11.first, +.collapse>tbody>tr th.large-11.first, +.collapse>tbody>tr td.large-11.last, +.collapse>tbody>tr th.large-11.last { + width: 539.6666666667px; } .body .columns td.large-11, .body .column td.large-11, .body .columns th.large-11, .body .column th.large-11 { - width: 91.66667%; + width: 91.666666%; } td.large-12, @@ -667,25 +641,20 @@ th.large-12.last { padding-right: 16px; } -.collapse>tbody>tr>td.large-12, -.collapse>tbody>tr>th.large-12 { +.collapse>tbody>tr>td.large-12:not([class*=large-offset]), +.collapse>tbody>tr>th.large-12:not([class*=large-offset]) { padding-right: 0; padding-left: 0; width: 580px; } -.collapse td.large-12.first, -.collapse th.large-12.first, -.collapse td.large-12.last, -.collapse th.large-12.last { +.collapse>tbody>tr td.large-12.first, +.collapse>tbody>tr th.large-12.first, +.collapse>tbody>tr td.large-12.last, +.collapse>tbody>tr th.large-12.last { width: 588px; } -td.large-12 center, -th.large-12 center { - min-width: 532px; -} - .body .columns td.large-12, .body .column td.large-12, .body .columns th.large-12, @@ -699,7 +668,7 @@ td.large-offset-1.last, th.large-offset-1, th.large-offset-1.first, th.large-offset-1.last { - padding-left: 64.33333px; + padding-left: 64.3333333333px; } td.large-offset-2, @@ -708,7 +677,7 @@ td.large-offset-2.last, th.large-offset-2, th.large-offset-2.first, th.large-offset-2.last { - padding-left: 112.66667px; + padding-left: 112.6666666667px; } td.large-offset-3, @@ -726,7 +695,7 @@ td.large-offset-4.last, th.large-offset-4, th.large-offset-4.first, th.large-offset-4.last { - padding-left: 209.33333px; + padding-left: 209.3333333333px; } td.large-offset-5, @@ -735,7 +704,7 @@ td.large-offset-5.last, th.large-offset-5, th.large-offset-5.first, th.large-offset-5.last { - padding-left: 257.66667px; + padding-left: 257.6666666667px; } td.large-offset-6, @@ -753,7 +722,7 @@ td.large-offset-7.last, th.large-offset-7, th.large-offset-7.first, th.large-offset-7.last { - padding-left: 354.33333px; + padding-left: 354.3333333333px; } td.large-offset-8, @@ -762,7 +731,7 @@ td.large-offset-8.last, th.large-offset-8, th.large-offset-8.first, th.large-offset-8.last { - padding-left: 402.66667px; + padding-left: 402.6666666667px; } td.large-offset-9, @@ -780,7 +749,7 @@ td.large-offset-10.last, th.large-offset-10, th.large-offset-10.first, th.large-offset-10.last { - padding-left: 499.33333px; + padding-left: 499.3333333333px; } td.large-offset-11, @@ -789,7 +758,7 @@ td.large-offset-11.last, th.large-offset-11, th.large-offset-11.first, th.large-offset-11.last { - padding-left: 547.66667px; + padding-left: 547.6666666667px; } td.expander, @@ -896,12 +865,15 @@ span.text-center { float: none !important; text-align: center !important; } + .small-text-center { text-align: center !important; } + .small-text-left { text-align: left !important; } + .small-text-right { text-align: right !important; } @@ -934,8 +906,22 @@ th.float-center { text-align: center; } +td.columns[valign=bottom], +td.column[valign=bottom], +th.columns[valign=bottom], +th.column[valign=bottom] { + vertical-align: bottom; +} + +td.columns[valign=middle], +td.column[valign=middle], +th.columns[valign=middle], +th.column[valign=middle] { + vertical-align: middle; +} + .hide-for-large { - display: none !important; + display: none; mso-hide: all; overflow: hidden; max-height: 0; @@ -960,6 +946,7 @@ table.body table.container .hide-for-large * { } @media only screen and (max-width: 596px) { + table.body table.container .hide-for-large, table.body table.container .row.hide-for-large { display: table !important; @@ -993,8 +980,7 @@ h5, h6, p, td, -th, -a { +th { color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-weight: normal; @@ -1002,7 +988,7 @@ a { margin: 0; Margin: 0; text-align: left; - line-height: 1.3; + line-height: 130%; } h1, @@ -1036,7 +1022,7 @@ h4 { } h5 { - font-size: 20px; + font-size: 19px; } h6 { @@ -1049,7 +1035,7 @@ p, td, th { font-size: 16px; - line-height: 1.3; + line-height: 130%; } p { @@ -1059,7 +1045,7 @@ p { p.lead { font-size: 20px; - line-height: 1.6; + line-height: 160%; } p.subheader { @@ -1072,7 +1058,33 @@ p.subheader { color: #8a8a8a; } -small { +p a { + margin: default; + Margin: default; +} + +.text-xs { + font-size: 11.1111111111px; +} + +.text-sm { + font-size: 13.3333333333px; +} + +.text-lg { + font-size: 19.2px; +} + +.text-xl { + font-size: 23.04px; +} + +.text-xxl { + font-size: 27.648px; +} + +small, +.small { font-size: 80%; color: #cacaca; } @@ -1080,6 +1092,11 @@ small { a { color: #2199e8; text-decoration: none; + font-family: Helvetica, Arial, sans-serif; + font-weight: normal; + padding: 0; + text-align: left; + line-height: 130%; } a:hover { @@ -1129,20 +1146,42 @@ pre code span.callout-strong { font-weight: bold; } -table.hr { - width: 100%; +td.columns table.hr table, +td.column table.hr table, +th.columns table.hr table, +th.column table.hr table, +td.columns table.h-line table, +td.column table.h-line table, +th.columns table.h-line table, +th.column table.h-line table { + width: auto; +} + +table.hr th, +table.h-line th { + padding-bottom: 20px; + text-align: center; } -table.hr th { +table.hr table, +table.h-line table { + display: inline-block; + margin: 0; + Margin: 0; +} + +table.hr th, +table.h-line th { + width: 580px; height: 0; - max-width: 580px; + padding-top: 20px; + clear: both; border-top: 0; border-right: 0; border-bottom: 1px solid #0a0a0a; border-left: 0; - margin: 20px auto; - Margin: 20px auto; - clear: both; + font-size: 0; + line-height: 0; } .stat { @@ -1168,6 +1207,17 @@ span.preheader { overflow: hidden; } +@media only screen { + a[x-apple-data-detectors] { + color: inherit !important; + text-decoration: none !important; + font-size: inherit !important; + font-family: inherit !important; + font-weight: inherit !important; + line-height: inherit !important; + } +} + table.button { width: auto; margin: 0 0 16px 0; @@ -1187,6 +1237,7 @@ table.button table td a { font-weight: bold; color: #fefefe; text-decoration: none; + text-align: left; display: inline-block; padding: 8px 16px 8px 16px; border: 0 solid #2199e8; @@ -1203,6 +1254,10 @@ table.button.rounded table td { border: none; } +table.button:not(.expand):not(.expanded) table { + width: auto; +} + table.button:hover table tr td a, table.button:active table tr td a, table.button table tr td a:visited, @@ -1241,7 +1296,7 @@ table.button.large table a { table.button.expand, table.button.expanded { - width: 100% !important; + width: 100%; } table.button.expand table, @@ -1372,7 +1427,7 @@ th.callout-inner { th.callout-inner.primary { background: #def0fc; - border: 1px solid #444444; + border: 1px solid #0f5f94; color: #0a0a0a; } @@ -1385,19 +1440,19 @@ th.callout-inner.secondary { th.callout-inner.success { background: #e1faea; border: 1px solid #1b9448; - color: #fefefe; + color: #0a0a0a; } th.callout-inner.warning { background: #fff3d9; border: 1px solid #996800; - color: #fefefe; + color: #0a0a0a; } th.callout-inner.alert { background: #fce6e2; border: 1px solid #b42912; - color: #fefefe; + color: #0a0a0a; } .thumbnail { @@ -1422,8 +1477,10 @@ table.menu { table.menu td.menu-item, table.menu th.menu-item { - padding: 10px; + padding-top: 10px; padding-right: 10px; + padding-bottom: 10px; + padding-left: 10px; } table.menu td.menu-item a, @@ -1433,8 +1490,10 @@ table.menu th.menu-item a { table.menu.vertical td.menu-item, table.menu.vertical th.menu-item { - padding: 10px; + padding-top: 10px; padding-right: 0; + padding-bottom: 10px; + padding-left: 10px; display: block; } @@ -1454,8 +1513,32 @@ table.menu.text-center a { text-align: center; } -.menu[align="center"] { - width: auto !important; +.menu[align=center] { + width: auto; +} + +.menu[align=center] tr { + text-align: center; +} + +.menu:not(.float-center) .menu-item:first-child { + padding-left: 0 !important; +} + +.menu:not(.float-center) .menu-item:last-child { + padding-right: 0 !important; +} + +.menu.vertical .menu-item { + padding-left: 0 !important; + padding-right: 0 !important; +} + +@media only screen and (max-width: 596px) { + .menu.small-vertical .menu-item { + padding-left: 0 !important; + padding-right: 0 !important; + } } body.outlook p { @@ -1467,12 +1550,15 @@ body.outlook p { width: auto; height: auto; } + table.body center { min-width: 0 !important; } + table.body .container { width: 95% !important; } + table.body .columns, table.body .column { height: auto !important; @@ -1482,78 +1568,85 @@ body.outlook p { padding-left: 16px !important; padding-right: 16px !important; } - table.body .columns .column, - table.body .columns .columns, - table.body .column .column, - table.body .column .columns { - padding-left: 0 !important; - padding-right: 0 !important; - } - table.body .collapse .columns, - table.body .collapse .column { + + table.body .collapse>tbody>tr>.columns, + table.body .collapse>tbody>tr>.column { padding-left: 0 !important; padding-right: 0 !important; } + td.small-1, th.small-1 { display: inline-block !important; - width: 8.33333% !important; + width: 8.333333% !important; } + td.small-2, th.small-2 { display: inline-block !important; - width: 16.66667% !important; + width: 16.666666% !important; } + td.small-3, th.small-3 { display: inline-block !important; width: 25% !important; } + td.small-4, th.small-4 { display: inline-block !important; - width: 33.33333% !important; + width: 33.333333% !important; } + td.small-5, th.small-5 { display: inline-block !important; - width: 41.66667% !important; + width: 41.666666% !important; } + td.small-6, th.small-6 { display: inline-block !important; width: 50% !important; } + td.small-7, th.small-7 { display: inline-block !important; - width: 58.33333% !important; + width: 58.333333% !important; } + td.small-8, th.small-8 { display: inline-block !important; - width: 66.66667% !important; + width: 66.666666% !important; } + td.small-9, th.small-9 { display: inline-block !important; width: 75% !important; } + td.small-10, th.small-10 { display: inline-block !important; - width: 83.33333% !important; + width: 83.333333% !important; } + td.small-11, th.small-11 { display: inline-block !important; - width: 91.66667% !important; + width: 91.666666% !important; } + td.small-12, th.small-12 { display: inline-block !important; width: 100% !important; } + .columns td.small-12, .column td.small-12, .columns th.small-12, @@ -1561,98 +1654,119 @@ body.outlook p { display: block !important; width: 100% !important; } + table.body td.small-offset-1, table.body th.small-offset-1 { - margin-left: 8.33333% !important; - Margin-left: 8.33333% !important; + margin-left: 8.333333% !important; + Margin-left: 8.333333% !important; } + table.body td.small-offset-2, table.body th.small-offset-2 { - margin-left: 16.66667% !important; - Margin-left: 16.66667% !important; + margin-left: 16.666666% !important; + Margin-left: 16.666666% !important; } + table.body td.small-offset-3, table.body th.small-offset-3 { margin-left: 25% !important; Margin-left: 25% !important; } + table.body td.small-offset-4, table.body th.small-offset-4 { - margin-left: 33.33333% !important; - Margin-left: 33.33333% !important; + margin-left: 33.333333% !important; + Margin-left: 33.333333% !important; } + table.body td.small-offset-5, table.body th.small-offset-5 { - margin-left: 41.66667% !important; - Margin-left: 41.66667% !important; + margin-left: 41.666666% !important; + Margin-left: 41.666666% !important; } + table.body td.small-offset-6, table.body th.small-offset-6 { margin-left: 50% !important; Margin-left: 50% !important; } + table.body td.small-offset-7, table.body th.small-offset-7 { - margin-left: 58.33333% !important; - Margin-left: 58.33333% !important; + margin-left: 58.333333% !important; + Margin-left: 58.333333% !important; } + table.body td.small-offset-8, table.body th.small-offset-8 { - margin-left: 66.66667% !important; - Margin-left: 66.66667% !important; + margin-left: 66.666666% !important; + Margin-left: 66.666666% !important; } + table.body td.small-offset-9, table.body th.small-offset-9 { margin-left: 75% !important; Margin-left: 75% !important; } + table.body td.small-offset-10, table.body th.small-offset-10 { - margin-left: 83.33333% !important; - Margin-left: 83.33333% !important; + margin-left: 83.333333% !important; + Margin-left: 83.333333% !important; } + table.body td.small-offset-11, table.body th.small-offset-11 { - margin-left: 91.66667% !important; - Margin-left: 91.66667% !important; + margin-left: 91.666666% !important; + Margin-left: 91.666666% !important; } + table.body table.columns td.expander, table.body table.columns th.expander { display: none !important; } + table.body .right-text-pad, table.body .text-pad-right { padding-left: 10px !important; } + table.body .left-text-pad, table.body .text-pad-left { padding-right: 10px !important; } + table.menu { width: 100% !important; } + table.menu td, table.menu th { width: auto !important; display: inline-block !important; } + table.menu.vertical td, table.menu.vertical th, table.menu.small-vertical td, table.menu.small-vertical th { display: block !important; } - table.menu[align="center"] { + + table.menu[align=center] { width: auto !important; } + table.button.small-expand, table.button.small-expanded { width: 100% !important; } + table.button.small-expand table, table.button.small-expanded table { width: 100%; } + table.button.small-expand table a, table.button.small-expanded table a { text-align: center !important; @@ -1660,8 +1774,13 @@ body.outlook p { padding-left: 0 !important; padding-right: 0 !important; } + table.button.small-expand center, table.button.small-expanded center { min-width: 0; } -} + + th.callout-inner { + padding: 10px !important; + } +} \ No newline at end of file diff --git a/Resources/views/Form/bootstrap_4_layout.html.twig b/Resources/views/Form/bootstrap_4_layout.html.twig index a7ce3e23..458cc684 100644 --- a/Resources/views/Form/bootstrap_4_layout.html.twig +++ b/Resources/views/Form/bootstrap_4_layout.html.twig @@ -233,30 +233,8 @@ {% if required -%} {% set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' required')|trim}) %} {%- endif -%} - {% if label is empty -%} - {%- if label_format is not empty -%} - {% set label = label_format|replace({ - '%name%': name, - '%id%': id, - }) %} - {%- else -%} - {% set label = name|humanize %} - {%- endif -%} - {%- endif -%} <{{ element|default('label') }}{% if label_attr %}{% with { attr: label_attr } %}{{ block('attributes') }}{% endwith %}{% endif %}> - {%- if translation_domain is same as(false) -%} - {%- if label_html is same as(false) -%} - {{- label -}} - {%- else -%} - {{- label|raw -}} - {%- endif -%} - {%- else -%} - {%- if label_html is same as(false) -%} - {{- label|trans(label_translation_parameters, translation_domain) -}} - {%- else -%} - {{- label|trans(label_translation_parameters, translation_domain)|raw -}} - {%- endif -%} - {%- endif -%} + {{- block('form_label_content') -}} {% block form_label_errors %}{{- form_errors(form) -}}{% endblock form_label_errors %} {%- else -%} {%- if errors|length > 0 -%} @@ -287,33 +265,11 @@ {% set embed_label_classes = parent_label_class|split(' ')|filter(class => class in ['checkbox-inline', 'radio-inline']) %} {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ embed_label_classes|join(' '))|trim}) -%} {% endif %} - {%- if label is not same as(false) and label is empty -%} - {%- if label_format is not empty -%} - {%- set label = label_format|replace({ - '%name%': name, - '%id%': id, - }) -%} - {%- else -%} - {%- set label = name|humanize -%} - {%- endif -%} - {%- endif -%} {{ widget|raw }} {%- if label is not same as(false) -%} - {%- if translation_domain is same as(false) -%} - {%- if label_html is same as(false) -%} - {{- label -}} - {%- else -%} - {{- label|raw -}} - {%- endif -%} - {%- else -%} - {%- if label_html is same as(false) -%} - {{- label|trans(label_translation_parameters, translation_domain) -}} - {%- else -%} - {{- label|trans(label_translation_parameters, translation_domain)|raw -}} - {%- endif -%} - {%- endif -%} + {{- block('form_label_content') -}} {%- endif -%} {{- form_errors(form) -}} @@ -357,19 +313,7 @@ {%- if help is not empty -%} {%- set help_attr = help_attr|merge({class: (help_attr.class|default('') ~ ' form-text text-muted')|trim}) -%} - {%- if translation_domain is same as(false) -%} - {%- if help_html is same as(false) -%} - {{- help -}} - {%- else -%} - {{- help|raw -}} - {%- endif -%} - {%- else -%} - {%- if help_html is same as(false) -%} - {{- help|trans(help_translation_parameters, translation_domain) -}} - {%- else -%} - {{- help|trans(help_translation_parameters, translation_domain)|raw -}} - {%- endif -%} - {%- endif -%} + {{- block('form_help_content') -}} {%- endif -%} {%- endblock form_help %} diff --git a/Resources/views/Form/bootstrap_5_layout.html.twig b/Resources/views/Form/bootstrap_5_layout.html.twig index 852335ea..17b28fc9 100644 --- a/Resources/views/Form/bootstrap_5_layout.html.twig +++ b/Resources/views/Form/bootstrap_5_layout.html.twig @@ -209,30 +209,48 @@ {%- endblock submit_widget %} {%- block checkbox_widget -%} - {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-check-input')|trim}) -%} + {%- set attr_class = attr_class|default(attr.class|default('')) -%} + {%- set row_class = '' -%} + {%- if 'btn-check' not in attr_class -%} + {%- set attr_class = attr_class ~ ' form-check-input' -%} + {%- set row_class = 'form-check' -%} + {%- endif -%} + {%- set attr = attr|merge({class: attr_class|trim}) -%} {%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%} - {%- set row_class = 'form-check' -%} {%- if 'checkbox-inline' in parent_label_class %} {%- set row_class = row_class ~ ' form-check-inline' -%} {% endif -%} {%- if 'checkbox-switch' in parent_label_class %} {%- set row_class = row_class ~ ' form-switch' -%} {% endif -%} -
- {{- form_label(form, null, { widget: parent() }) -}} -
+ {%- if row_class is not empty -%} +
+ {%- endif -%} + {{- form_label(form, null, { widget: parent() }) -}} + {%- if row_class is not empty -%} +
+ {%- endif -%} {%- endblock checkbox_widget %} {%- block radio_widget -%} - {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-check-input')|trim}) -%} + {%- set attr_class = attr_class|default(attr.class|default('')) -%} + {%- set row_class = '' -%} + {%- if 'btn-check' not in attr_class -%} + {%- set attr_class = attr_class ~ ' form-check-input' -%} + {%- set row_class = 'form-check' -%} + {%- endif -%} + {%- set attr = attr|merge({class: attr_class|trim}) -%} {%- set parent_label_class = parent_label_class|default(label_attr.class|default('')) -%} - {%- set row_class = 'form-check' -%} {%- if 'radio-inline' in parent_label_class -%} {%- set row_class = row_class ~ ' form-check-inline' -%} {%- endif -%} -
- {{- form_label(form, null, { widget: parent() }) -}} -
+ {%- if row_class is not empty -%} +
+ {%- endif -%} + {{- form_label(form, null, { widget: parent() }) -}} + {%- if row_class is not empty -%} +
+ {%- endif -%} {%- endblock radio_widget %} {%- block choice_widget_collapsed -%} @@ -276,7 +294,11 @@ {%- block checkbox_radio_label -%} {#- Do not display the label if widget is not defined in order to prevent double label rendering -#} {%- if widget is defined -%} - {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' form-check-label')|trim}) -%} + {%- set label_attr_class = label_attr_class|default(label_attr.class|default('')) -%} + {%- if 'btn' not in label_attr_class -%} + {%- set label_attr_class = label_attr_class ~ ' form-check-label' -%} + {%- endif -%} + {%- set label_attr = label_attr|merge({class: label_attr_class|trim}) -%} {%- if not compound -%} {% set label_attr = label_attr|merge({'for': id}) %} {%- endif -%} @@ -286,33 +308,11 @@ {%- if parent_label_class is defined -%} {%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' ' ~ parent_label_class)|replace({'checkbox-inline': '', 'radio-inline': ''})|trim}) -%} {%- endif -%} - {%- if label is not same as(false) and label is empty -%} - {%- if label_format is not empty -%} - {%- set label = label_format|replace({ - '%name%': name, - '%id%': id, - }) -%} - {%- else -%} - {%- set label = name|humanize -%} - {%- endif -%} - {%- endif -%} {{ widget|raw }} {%- if label is not same as(false) -%} - {%- if translation_domain is same as(false) -%} - {%- if label_html is same as(false) -%} - {{- label -}} - {%- else -%} - {{- label|raw -}} - {%- endif -%} - {%- else -%} - {%- if label_html is same as(false) -%} - {{- label|trans(label_translation_parameters, translation_domain) -}} - {%- else -%} - {{- label|trans(label_translation_parameters, translation_domain)|raw -}} - {%- endif -%} - {%- endif -%} + {{- block('form_label_content') -}} {%- endif -%} {%- endif -%} @@ -353,7 +353,7 @@ {%- block form_errors -%} {%- if errors|length > 0 -%} {%- for error in errors -%} -
{{ error.message }}
+
{{ error.message }}
{%- endfor -%} {%- endif %} {%- endblock form_errors %} diff --git a/Resources/views/Form/form_div_layout.html.twig b/Resources/views/Form/form_div_layout.html.twig index 94f87dc1..537849fa 100644 --- a/Resources/views/Form/form_div_layout.html.twig +++ b/Resources/views/Form/form_div_layout.html.twig @@ -61,14 +61,18 @@ {%- endif -%} {% if placeholder is not none -%} - + {%- endif %} {%- if preferred_choices|length > 0 -%} {% set options = preferred_choices %} {% set render_preferred_choices = true %} {{- block('choice_widget_options') -}} {% if choices|length > 0 and separator is not none -%} - + {%- if separator_html is not defined or separator_html is same as(false) -%} + + {% else %} + {{ separator|raw }} + {% endif %} {%- endif %} {%- endif -%} {% set options = choices -%} @@ -269,7 +273,9 @@ {% endif %} {{ widget|raw }} - {{ translation_domain is same as(false) ? label : label|trans(label_translation_parameters, translation_domain) }} + {%- if label is not same as(false) -%} + {{- block('form_label_content') -}} + {%- endif -%} {%- endblock checkbox_radio_label %} diff --git a/Test/FormLayoutTestCase.php b/Test/FormLayoutTestCase.php new file mode 100644 index 00000000..bd8123a3 --- /dev/null +++ b/Test/FormLayoutTestCase.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Test; + +use Symfony\Bridge\Twig\Form\TwigRendererEngine; +use Symfony\Bridge\Twig\Test\Traits\RuntimeLoaderProvider; +use Symfony\Component\Form\FormRenderer; +use Symfony\Component\Form\FormRendererInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\Form\Test\FormIntegrationTestCase; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Twig\Environment; +use Twig\Extension\ExtensionInterface; +use Twig\Loader\FilesystemLoader; + +/** + * @author Romain Monteil + */ +abstract class FormLayoutTestCase extends FormIntegrationTestCase +{ + use RuntimeLoaderProvider; + + protected FormRendererInterface $renderer; + + protected function setUp(): void + { + parent::setUp(); + + $loader = new FilesystemLoader($this->getTemplatePaths()); + + $environment = new Environment($loader, ['strict_variables' => true]); + $environment->setExtensions($this->getTwigExtensions()); + + foreach ($this->getTwigGlobals() as $name => $value) { + $environment->addGlobal($name, $value); + } + + $rendererEngine = new TwigRendererEngine($this->getThemes(), $environment); + $this->renderer = new FormRenderer($rendererEngine, $this->createMock(CsrfTokenManagerInterface::class)); + $this->registerTwigRuntimeLoader($environment, $this->renderer); + } + + protected function assertMatchesXpath($html, $expression, $count = 1): void + { + $dom = new \DOMDocument('UTF-8'); + + try { + // Wrap in node so we can load HTML with multiple tags at + // the top level + $dom->loadXML(''.$html.''); + } catch (\Exception $e) { + $this->fail(\sprintf( + "Failed loading HTML:\n\n%s\n\nError: %s", + $html, + $e->getMessage() + )); + } + $xpath = new \DOMXPath($dom); + $nodeList = $xpath->evaluate('/root'.$expression); + + if ($nodeList->length != $count) { + $dom->formatOutput = true; + $this->fail(\sprintf( + "Failed asserting that \n\n%s\n\nmatches exactly %s. Matches %s in \n\n%s", + $expression, + 1 == $count ? 'once' : $count.' times', + 1 == $nodeList->length ? 'once' : $nodeList->length.' times', + // strip away and + substr($dom->saveHTML(), 6, -8) + )); + } else { + $this->addToAssertionCount(1); + } + } + + /** + * @return string[] + */ + abstract protected function getTemplatePaths(): array; + + /** + * @return ExtensionInterface[] + */ + abstract protected function getTwigExtensions(): array; + + /** + * @return array + */ + protected function getTwigGlobals(): array + { + return []; + } + + /** + * @return string[] + */ + abstract protected function getThemes(): array; + + protected function renderForm(FormView $view, array $vars = []): string + { + return $this->renderer->renderBlock($view, 'form', $vars); + } + + protected function renderLabel(FormView $view, $label = null, array $vars = []): string + { + if (null !== $label) { + $vars += ['label' => $label]; + } + + return $this->renderer->searchAndRenderBlock($view, 'label', $vars); + } + + protected function renderHelp(FormView $view): string + { + return $this->renderer->searchAndRenderBlock($view, 'help'); + } + + protected function renderErrors(FormView $view): string + { + return $this->renderer->searchAndRenderBlock($view, 'errors'); + } + + protected function renderWidget(FormView $view, array $vars = []): string + { + return $this->renderer->searchAndRenderBlock($view, 'widget', $vars); + } + + protected function renderRow(FormView $view, array $vars = []): string + { + return $this->renderer->searchAndRenderBlock($view, 'row', $vars); + } + + protected function renderRest(FormView $view, array $vars = []): string + { + return $this->renderer->searchAndRenderBlock($view, 'rest', $vars); + } + + protected function renderStart(FormView $view, array $vars = []): string + { + return $this->renderer->renderBlock($view, 'form_start', $vars); + } + + protected function renderEnd(FormView $view, array $vars = []): string + { + return $this->renderer->renderBlock($view, 'form_end', $vars); + } + + protected function setTheme(FormView $view, array $themes, $useDefaultThemes = true): void + { + $this->renderer->setTheme($view, $themes, $useDefaultThemes); + } +} diff --git a/Tests/Extension/RuntimeLoaderProvider.php b/Test/Traits/RuntimeLoaderProvider.php similarity index 94% rename from Tests/Extension/RuntimeLoaderProvider.php rename to Test/Traits/RuntimeLoaderProvider.php index b0dbf86a..52f84a7d 100644 --- a/Tests/Extension/RuntimeLoaderProvider.php +++ b/Test/Traits/RuntimeLoaderProvider.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\Bridge\Twig\Tests\Extension; +namespace Symfony\Bridge\Twig\Test\Traits; use Symfony\Component\Form\FormRenderer; use Twig\Environment; diff --git a/Tests/AppVariableTest.php b/Tests/AppVariableTest.php index 9378e87c..0367f770 100644 --- a/Tests/AppVariableTest.php +++ b/Tests/AppVariableTest.php @@ -20,13 +20,11 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Translation\LocaleSwitcher; class AppVariableTest extends TestCase { - /** - * @var AppVariable - */ - protected $appVariable; + protected AppVariable $appVariable; protected function setUp(): void { @@ -104,14 +102,21 @@ public function testGetUser() $this->assertEquals($user, $this->appVariable->getUser()); } - /** - * @group legacy - */ - public function testGetUserWithUsernameAsTokenUser() + public function testGetLocale() { - $this->setTokenStorage('username'); + $localeSwitcher = $this->createMock(LocaleSwitcher::class); + $this->appVariable->setLocaleSwitcher($localeSwitcher); - $this->assertNull($this->appVariable->getUser()); + $localeSwitcher->method('getLocale')->willReturn('fr'); + + self::assertEquals('fr', $this->appVariable->getLocale()); + } + + public function testGetEnabledLocales() + { + $this->appVariable->setEnabledLocales(['en', 'fr']); + + self::assertSame(['en', 'fr'], $this->appVariable->getEnabled_locales()); } public function testGetTokenWithNoToken() @@ -166,6 +171,20 @@ public function testGetSessionWithRequestStackNotSet() $this->appVariable->getSession(); } + public function testGetLocaleWithLocaleSwitcherNotSet() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The "app.locale" variable is not available.'); + $this->appVariable->getLocale(); + } + + public function testGetEnabledLocalesWithEnabledLocalesNotSet() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The "app.enabled_locales" variable is not available.'); + $this->appVariable->getEnabled_locales(); + } + public function testGetFlashesWithNoRequest() { $this->setRequestStack(null); @@ -238,12 +257,49 @@ public function testGetFlashes() ); } - protected function setRequestStack($request) + public function testGetCurrentRoute() { - $requestStackMock = $this->createMock(RequestStack::class); - $requestStackMock->method('getCurrentRequest')->willReturn($request); + $this->setRequestStack(new Request(attributes: ['_route' => 'some_route'])); + + $this->assertSame('some_route', $this->appVariable->getCurrent_route()); + } + + public function testGetCurrentRouteWithRequestStackNotSet() + { + $this->expectException(\RuntimeException::class); + $this->appVariable->getCurrent_route(); + } + + public function testGetCurrentRouteParameters() + { + $routeParams = ['some_param' => true]; + $this->setRequestStack(new Request(attributes: ['_route_params' => $routeParams])); + + $this->assertSame($routeParams, $this->appVariable->getCurrent_route_parameters()); + } + + public function testGetCurrentRouteParametersWithoutAttribute() + { + $this->setRequestStack(new Request()); + + $this->assertSame([], $this->appVariable->getCurrent_route_parameters()); + } + + public function testGetCurrentRouteParametersWithRequestStackNotSet() + { + $this->expectException(\RuntimeException::class); + $this->appVariable->getCurrent_route_parameters(); + } + + protected function setRequestStack(?Request $request) + { + $requestStack = new RequestStack(); + + if (null !== $request) { + $requestStack->push($request); + } - $this->appVariable->setRequestStack($requestStackMock); + $this->appVariable->setRequestStack($requestStack); } protected function setTokenStorage($user) diff --git a/Tests/Command/DebugCommandTest.php b/Tests/Command/DebugCommandTest.php index b04b8a6a..7ba828c6 100644 --- a/Tests/Command/DebugCommandTest.php +++ b/Tests/Command/DebugCommandTest.php @@ -299,10 +299,6 @@ public function testWithFilter() */ public function testComplete(array $input, array $expectedSuggestions) { - if (!class_exists(CommandCompletionTester::class)) { - $this->markTestSkipped('Test command completion requires symfony/console 5.4+.'); - } - $projectDir = \dirname(__DIR__).\DIRECTORY_SEPARATOR.'Fixtures'; $loader = new FilesystemLoader([], $projectDir); $environment = new Environment($loader); @@ -318,7 +314,7 @@ public function testComplete(array $input, array $expectedSuggestions) public static function provideCompletionSuggestions(): iterable { yield 'name' => [['email'], []]; - yield 'option --format' => [['--format', ''], ['text', 'json']]; + yield 'option --format' => [['--format', ''], ['txt', 'json']]; } private function createCommandTester(array $paths = [], array $bundleMetadata = [], ?string $defaultPath = null, bool $useChainLoader = false, array $globals = []): CommandTester diff --git a/Tests/Command/LintCommandTest.php b/Tests/Command/LintCommandTest.php index 3c09f1b9..3b0b453d 100644 --- a/Tests/Command/LintCommandTest.php +++ b/Tests/Command/LintCommandTest.php @@ -25,7 +25,7 @@ class LintCommandTest extends TestCase { - private $files; + private array $files; public function testLintCorrectFile() { @@ -142,10 +142,6 @@ public function testLintAutodetectsGithubActionEnvironment() */ public function testComplete(array $input, array $expectedSuggestions) { - if (!class_exists(CommandCompletionTester::class)) { - $this->markTestSkipped('Test command completion requires symfony/console 5.4+.'); - } - $tester = new CommandCompletionTester($this->createCommand()); $this->assertSame($expectedSuggestions, $tester->complete($input)); @@ -169,9 +165,7 @@ private function createCommand(): Command } else { $options = ['deprecated' => true]; } - $environment->addFilter(new TwigFilter('deprecated_filter', function ($v) { - return $v; - }, $options)); + $environment->addFilter(new TwigFilter('deprecated_filter', fn ($v) => $v, $options)); $command = new LintCommand($environment); diff --git a/Tests/EventListener/TemplateAttributeListenerTest.php b/Tests/EventListener/TemplateAttributeListenerTest.php new file mode 100644 index 00000000..478f285e --- /dev/null +++ b/Tests/EventListener/TemplateAttributeListenerTest.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Twig\Attribute\Template; +use Symfony\Bridge\Twig\EventListener\TemplateAttributeListener; +use Symfony\Bridge\Twig\Tests\Fixtures\TemplateAttributeController; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Twig\Environment; +use Twig\Loader\ArrayLoader; + +class TemplateAttributeListenerTest extends TestCase +{ + public function testAttribute() + { + $twig = $this->createMock(Environment::class); + $twig->expects($this->exactly(3)) + ->method('render') + ->willReturnCallback(function (...$args) { + static $series = [ + ['templates/foo.html.twig', ['foo' => 'bar']], + ['templates/foo.html.twig', ['bar' => 'Bar', 'buz' => 'def']], + ['templates/foo.html.twig', []], + ]; + + $this->assertSame(array_shift($series), $args); + + return 'Bar'; + }) + ; + + $request = new Request(); + $kernel = $this->createMock(HttpKernelInterface::class); + $controllerArgumentsEvent = new ControllerArgumentsEvent($kernel, [new TemplateAttributeController(), 'foo'], ['Bar'], $request, null); + $listener = new TemplateAttributeListener($twig); + + $event = new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, ['foo' => 'bar'], $controllerArgumentsEvent); + $listener->onKernelView($event); + $this->assertSame('Bar', $event->getResponse()->getContent()); + + $event = new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, null, $controllerArgumentsEvent); + $listener->onKernelView($event); + $this->assertSame('Bar', $event->getResponse()->getContent()); + + $event = new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, null); + $listener->onKernelView($event); + $this->assertNull($event->getResponse()); + + $request->attributes->set('_template', new Template('templates/foo.html.twig')); + $event = new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, []); + $listener->onKernelView($event); + $this->assertSame('Bar', $event->getResponse()->getContent()); + } + + public function testAttributeWithBlock() + { + $twig = new Environment(new ArrayLoader([ + 'foo.html.twig' => 'ERROR {% block bar %}FOOBAR{% endblock %}', + ])); + + $request = new Request(); + $kernel = $this->createMock(HttpKernelInterface::class); + $controllerArgumentsEvent = new ControllerArgumentsEvent($kernel, [new TemplateAttributeController(), 'foo'], ['Bar'], $request, null); + $listener = new TemplateAttributeListener($twig); + + $request->attributes->set('_template', new Template('foo.html.twig', [], false, 'bar')); + $event = new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, ['foo' => 'bar'], $controllerArgumentsEvent); + $listener->onKernelView($event); + $this->assertSame('FOOBAR', $event->getResponse()->getContent()); + + $request->attributes->set('_template', new Template('foo.html.twig', [], true, 'bar')); + $event = new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, ['foo' => 'bar'], $controllerArgumentsEvent); + $listener->onKernelView($event); + $this->assertInstanceOf(StreamedResponse::class, $event->getResponse()); + + $request->attributes->set('_template', new Template('foo.html.twig', [], false, 'not_a_block')); + $event = new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, ['foo' => 'bar'], $controllerArgumentsEvent); + $this->expectExceptionMessage('Block "not_a_block" on template "foo.html.twig" does not exist in "foo.html.twig".'); + $listener->onKernelView($event); + } + + public function testForm() + { + $request = new Request(); + $kernel = $this->createMock(HttpKernelInterface::class); + $controllerArgumentsEvent = new ControllerArgumentsEvent($kernel, [new TemplateAttributeController(), 'foo'], [], $request, null); + $listener = new TemplateAttributeListener($this->createMock(Environment::class)); + + $form = $this->createMock(FormInterface::class); + $form->expects($this->once())->method('createView'); + $form->expects($this->once())->method('isSubmitted')->willReturn(true); + $form->expects($this->once())->method('isValid')->willReturn(false); + + $event = new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, ['bar' => $form], $controllerArgumentsEvent); + $listener->onKernelView($event); + + $this->assertSame(422, $event->getResponse()->getStatusCode()); + } +} diff --git a/Tests/Extension/AbstractBootstrap3HorizontalLayoutTestCase.php b/Tests/Extension/AbstractBootstrap3HorizontalLayoutTestCase.php index e79b0c31..db0789db 100644 --- a/Tests/Extension/AbstractBootstrap3HorizontalLayoutTestCase.php +++ b/Tests/Extension/AbstractBootstrap3HorizontalLayoutTestCase.php @@ -15,7 +15,7 @@ abstract class AbstractBootstrap3HorizontalLayoutTestCase extends AbstractBootst { public function testLabelOnForm() { - $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateType'); + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateType', null, ['widget' => 'choice']); $view = $form->createView(); $this->renderWidget($view, ['label' => 'foo']); $html = $this->renderLabel($view); @@ -163,9 +163,9 @@ public function testStartTagWithOverriddenVars() public function testStartTagForMultipartForm() { $form = $this->factory->createBuilder('Symfony\Component\Form\Extension\Core\Type\FormType', null, [ - 'method' => 'get', - 'action' => 'http://example.com/directory', - ]) + 'method' => 'get', + 'action' => 'http://example.com/directory', + ]) ->add('file', 'Symfony\Component\Form\Extension\Core\Type\FileType') ->getForm(); diff --git a/Tests/Extension/AbstractBootstrap3LayoutTestCase.php b/Tests/Extension/AbstractBootstrap3LayoutTestCase.php index 16cb900b..a25bd542 100644 --- a/Tests/Extension/AbstractBootstrap3LayoutTestCase.php +++ b/Tests/Extension/AbstractBootstrap3LayoutTestCase.php @@ -13,13 +13,12 @@ use Symfony\Component\Form\Extension\Core\Type\PercentType; use Symfony\Component\Form\FormError; -use Symfony\Component\Form\Tests\AbstractLayoutTestCase; abstract class AbstractBootstrap3LayoutTestCase extends AbstractLayoutTestCase { public function testLabelOnForm() { - $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateType'); + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateType', null, ['widget' => 'choice']); $view = $form->createView(); $this->renderWidget($view, ['label' => 'foo']); $html = $this->renderLabel($view); @@ -577,6 +576,31 @@ public function testSingleChoiceWithPreferred() ); } + public function testSingleChoiceWithPreferredIsNotDuplicated() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&b'], + 'duplicate_preferred_choices' => false, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --', 'attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-control"] + [not(@required)] + [ + ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + /following-sibling::option[@disabled="disabled"][not(@selected)][.="-- sep --"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + ] + [count(./option)=3] +' + ); + } + public function testSingleChoiceWithSelectedPreferred() { $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ @@ -1039,9 +1063,7 @@ public function testSingleChoiceExpandedWithLabelsSetFalseByCallable() { $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], - 'choice_label' => function () { - return false; - }, + 'choice_label' => fn () => false, 'multiple' => false, 'expanded' => true, ]); @@ -1404,9 +1426,7 @@ public function testMultipleChoiceExpandedWithLabelsSetFalseByCallable() { $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', ['&a'], [ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], - 'choice_label' => function () { - return false; - }, + 'choice_label' => fn () => false, 'multiple' => true, 'expanded' => true, ]); @@ -1565,6 +1585,7 @@ public function testDateTime() $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateTimeType', date('Y').'-02-03 04:05:06', [ 'input' => 'string', 'with_seconds' => false, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1602,6 +1623,7 @@ public function testDateTimeWithPlaceholderGlobal() 'input' => 'string', 'placeholder' => 'Change&Me', 'required' => false, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1641,6 +1663,7 @@ public function testDateTimeWithHourAndMinute() $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateTimeType', $data, [ 'input' => 'array', 'required' => false, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1678,6 +1701,7 @@ public function testDateTimeWithSeconds() $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateTimeType', date('Y').'-02-03 04:05:06', [ 'input' => 'string', 'with_seconds' => true, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1757,7 +1781,7 @@ public function testDateTimeWithWidgetSingleText() [@type="datetime-local"] [@name="name"] [@class="my&class form-control"] - [@value="2011-02-03T04:05:06"] + [@value="2011-02-03T04:05"] ' ); } @@ -1911,6 +1935,7 @@ public function testBirthDay() { $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\BirthdayType', '2000-02-03', [ 'input' => 'string', + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1941,6 +1966,7 @@ public function testBirthDayWithPlaceholder() 'input' => 'string', 'placeholder' => '', 'required' => false, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -2469,6 +2495,7 @@ public function testTime() $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TimeType', '04:05:06', [ 'input' => 'string', 'with_seconds' => false, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -2496,6 +2523,7 @@ public function testTimeWithSeconds() $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TimeType', '04:05:06', [ 'input' => 'string', 'with_seconds' => true, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -2583,6 +2611,7 @@ public function testTimeWithPlaceholderGlobal() 'input' => 'string', 'placeholder' => 'Change&Me', 'required' => false, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -2610,6 +2639,7 @@ public function testTimeWithPlaceholderOnYear() 'input' => 'string', 'required' => false, 'placeholder' => ['hour' => 'Change&Me'], + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -2839,8 +2869,6 @@ public function testColor() public function testWeekSingleText() { - $this->requiresFeatureSet(404); - $form = $this->factory->createNamed('holidays', 'Symfony\Component\Form\Extension\Core\Type\WeekType', '1970-W01', [ 'input' => 'string', 'widget' => 'single_text', @@ -2859,8 +2887,6 @@ public function testWeekSingleText() public function testWeekSingleTextNoHtml5() { - $this->requiresFeatureSet(404); - $form = $this->factory->createNamed('holidays', 'Symfony\Component\Form\Extension\Core\Type\WeekType', '1970-W01', [ 'input' => 'string', 'widget' => 'single_text', @@ -2880,8 +2906,6 @@ public function testWeekSingleTextNoHtml5() public function testWeekChoices() { - $this->requiresFeatureSet(404); - $data = ['year' => (int) date('Y'), 'week' => 1]; $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\WeekType', $data, [ @@ -2908,8 +2932,6 @@ public function testWeekChoices() public function testWeekText() { - $this->requiresFeatureSet(404); - $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\WeekType', '2000-W01', [ 'input' => 'string', 'widget' => 'text', diff --git a/Tests/Extension/AbstractBootstrap4HorizontalLayoutTestCase.php b/Tests/Extension/AbstractBootstrap4HorizontalLayoutTestCase.php index ce8a7336..9b202e92 100644 --- a/Tests/Extension/AbstractBootstrap4HorizontalLayoutTestCase.php +++ b/Tests/Extension/AbstractBootstrap4HorizontalLayoutTestCase.php @@ -47,7 +47,7 @@ public function testRow() public function testLabelOnForm() { - $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateType'); + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateType', null, ['widget' => 'choice']); $view = $form->createView(); $this->renderWidget($view, ['label' => 'foo']); $html = $this->renderLabel($view); @@ -214,9 +214,9 @@ public function testStartTagWithOverriddenVars() public function testStartTagForMultipartForm() { $form = $this->factory->createBuilder('Symfony\Component\Form\Extension\Core\Type\FormType', null, [ - 'method' => 'get', - 'action' => 'http://example.com/directory', - ]) + 'method' => 'get', + 'action' => 'http://example.com/directory', + ]) ->add('file', 'Symfony\Component\Form\Extension\Core\Type\FileType') ->getForm(); diff --git a/Tests/Extension/AbstractBootstrap4LayoutTestCase.php b/Tests/Extension/AbstractBootstrap4LayoutTestCase.php index e329b5d4..781d1c92 100644 --- a/Tests/Extension/AbstractBootstrap4LayoutTestCase.php +++ b/Tests/Extension/AbstractBootstrap4LayoutTestCase.php @@ -57,7 +57,7 @@ public function testRow() public function testLabelOnForm() { - $form = $this->factory->createNamed('name', DateType::class); + $form = $this->factory->createNamed('name', DateType::class, null, ['widget' => 'choice']); $view = $form->createView(); $this->renderWidget($view, ['label' => 'foo']); $html = $this->renderLabel($view); @@ -580,9 +580,7 @@ public function testSingleChoiceExpandedWithLabelsSetFalseByCallable() { $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], - 'choice_label' => function () { - return false; - }, + 'choice_label' => fn () => false, 'multiple' => false, 'expanded' => true, ]); @@ -901,9 +899,7 @@ public function testMultipleChoiceExpandedWithLabelsSetFalseByCallable() { $form = $this->factory->createNamed('name', ChoiceType::class, ['&a'], [ 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], - 'choice_label' => function () { - return false; - }, + 'choice_label' => fn () => false, 'multiple' => true, 'expanded' => true, ]); diff --git a/Tests/Extension/AbstractBootstrap5HorizontalLayoutTestCase.php b/Tests/Extension/AbstractBootstrap5HorizontalLayoutTestCase.php index ddaa4cdc..1c02f9e1 100644 --- a/Tests/Extension/AbstractBootstrap5HorizontalLayoutTestCase.php +++ b/Tests/Extension/AbstractBootstrap5HorizontalLayoutTestCase.php @@ -28,9 +28,9 @@ abstract class AbstractBootstrap5HorizontalLayoutTestCase extends AbstractBootst { public function testRow() { - $form = $this->factory->createNamed('name', TextType::class); - $form->addError(new FormError('[trans]Error![/trans]')); - $html = $this->renderRow($form->createView()); + $form = $this->factory->createNamed('')->add('name', TextType::class); + $form->get('name')->addError(new FormError('[trans]Error![/trans]')); + $html = $this->renderRow($form->get('name')->createView()); $this->assertMatchesXpath($html, '/div @@ -55,9 +55,9 @@ public function testRow() public function testRowWithCustomClass() { - $form = $this->factory->createNamed('name', TextType::class); - $form->addError(new FormError('[trans]Error![/trans]')); - $html = $this->renderRow($form->createView(), [ + $form = $this->factory->createNamed('')->add('name', TextType::class); + $form->get('name')->addError(new FormError('[trans]Error![/trans]')); + $html = $this->renderRow($form->get('name')->createView(), [ 'row_attr' => [ 'class' => 'mb-5', ], @@ -86,7 +86,7 @@ public function testRowWithCustomClass() public function testLabelOnForm() { - $form = $this->factory->createNamed('name', DateType::class); + $form = $this->factory->createNamed('name', DateType::class, null, ['widget' => 'choice']); $view = $form->createView(); $this->renderWidget($view, ['label' => 'foo']); $html = $this->renderLabel($view); @@ -245,7 +245,7 @@ public function testCheckboxRowWithHelp() ./div[@class="col-sm-2" or @class="col-sm-10"] /following-sibling::div[@class="col-sm-2" or @class="col-sm-10"] [ - ./p + ./div [@class="form-text mb-0 help-text"] [.="[trans]really helpful text[/trans]"] ] @@ -266,7 +266,7 @@ public function testRadioRowWithHelp() ./div[@class="col-sm-2" or @class="col-sm-10"] /following-sibling::div[@class="col-sm-2" or @class="col-sm-10"] [ - ./p + ./div [@class="form-text mb-0 help-text"] [.="[trans]really helpful text[/trans]"] ] diff --git a/Tests/Extension/AbstractBootstrap5LayoutTestCase.php b/Tests/Extension/AbstractBootstrap5LayoutTestCase.php index 25349e14..4b3aa7fa 100644 --- a/Tests/Extension/AbstractBootstrap5LayoutTestCase.php +++ b/Tests/Extension/AbstractBootstrap5LayoutTestCase.php @@ -40,9 +40,9 @@ abstract class AbstractBootstrap5LayoutTestCase extends AbstractBootstrap4Layout { public function testRow() { - $form = $this->factory->createNamed('name', TextType::class); - $form->addError(new FormError('[trans]Error![/trans]')); - $html = $this->renderRow($form->createView()); + $form = $this->factory->createNamed('')->add('name', TextType::class); + $form->get('name')->addError(new FormError('[trans]Error![/trans]')); + $html = $this->renderRow($form->get('name')->createView()); $this->assertMatchesXpath($html, '/div @@ -61,9 +61,9 @@ public function testRow() public function testRowWithCustomClass() { - $form = $this->factory->createNamed('name', TextType::class); - $form->addError(new FormError('[trans]Error![/trans]')); - $html = $this->renderRow($form->createView(), [ + $form = $this->factory->createNamed('')->add('name', TextType::class); + $form->get('name')->addError(new FormError('[trans]Error![/trans]')); + $html = $this->renderRow($form->get('name')->createView(), [ 'row_attr' => [ 'class' => 'mb-5', ], @@ -198,7 +198,7 @@ public function testHelp() $html = $this->renderHelp($view); $this->assertMatchesXpath($html, - '/p + '/div [@id="name_help"] [@class="form-text mb-0 help-text"] [.="[trans]Help text test![/trans]"] @@ -218,7 +218,7 @@ public function testHelpAttr() $html = $this->renderHelp($view); $this->assertMatchesXpath($html, - '/p + '/div [@id="name_help"] [@class="class-test form-text mb-0 help-text"] [.="[trans]Help text test![/trans]"] @@ -236,7 +236,7 @@ public function testHelpHtmlDefaultIsFalse() $html = $this->renderHelp($view); $this->assertMatchesXpath($html, - '/p + '/div [@id="name_help"] [@class="form-text mb-0 help-text"] [.="[trans]Help text test![/trans]"] @@ -244,7 +244,7 @@ public function testHelpHtmlDefaultIsFalse() ); $this->assertMatchesXpath($html, - '/p + '/div [@id="name_help"] [@class="form-text mb-0 help-text"] /b @@ -264,7 +264,7 @@ public function testHelpHtmlIsFalse() $html = $this->renderHelp($view); $this->assertMatchesXpath($html, - '/p + '/div [@id="name_help"] [@class="form-text mb-0 help-text"] [.="[trans]Help text test![/trans]"] @@ -272,7 +272,7 @@ public function testHelpHtmlIsFalse() ); $this->assertMatchesXpath($html, - '/p + '/div [@id="name_help"] [@class="form-text mb-0 help-text"] /b @@ -290,7 +290,7 @@ public function testHelpHtmlIsTrue() $html = $this->renderHelp($form->createView()); $this->assertMatchesXpath($html, - '/p + '/div [@id="name_help"] [@class="form-text mb-0 help-text"] [.="[trans]Help text test![/trans]"] @@ -298,7 +298,7 @@ public function testHelpHtmlIsTrue() ); $this->assertMatchesXpath($html, - '/p + '/div [@id="name_help"] [@class="form-text mb-0 help-text"] /b @@ -309,11 +309,34 @@ public function testHelpHtmlIsTrue() public function testErrors() { - $form = $this->factory->createNamed('name', TextType::class); + self::markTestSkipped('This method has been split into testRootErrors() and testRowErrors().'); + } + + public function testRootErrors() + { + $form = $this->factory->createNamed(''); $form->addError(new FormError('[trans]Error 1[/trans]')); $form->addError(new FormError('[trans]Error 2[/trans]')); $html = $this->renderErrors($form->createView()); + $this->assertMatchesXpath($html, + '/div + [@class="alert alert-danger d-block"] + [.="[trans]Error 1[/trans]"] + /following-sibling::div + [@class="alert alert-danger d-block"] + [.="[trans]Error 2[/trans]"] +' + ); + } + + public function testRowErrors() + { + $form = $this->factory->createNamed('')->add('name', TextType::class); + $form->get('name')->addError(new FormError('[trans]Error 1[/trans]')); + $form->get('name')->addError(new FormError('[trans]Error 2[/trans]')); + $html = $this->renderErrors($form->get('name')->createView()); + $this->assertMatchesXpath($html, '/div [@class="invalid-feedback d-block"] @@ -377,7 +400,7 @@ public function testCheckboxRowWithHelp() [@for="name"] [.="[trans]foo[/trans]"] ] - /following-sibling::p + /following-sibling::div [@class="form-text mb-0 help-text"] [.="[trans]really helpful text[/trans]"] ] @@ -404,6 +427,21 @@ public function testCheckboxSwitchWithValue() ); } + public function testCheckboxToggleWithValue() + { + $form = $this->factory->createNamed('name', CheckboxType::class, false, [ + 'value' => 'foo&bar', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['id' => 'my&id', 'attr' => ['class' => 'btn-check my&class'], 'label_attr' => ['class' => 'btn btn-primary']], + '/input[@type="checkbox"][@name="name"][@id="my&id"][@class="btn-check my&class"][@value="foo&bar"] + /following-sibling::label + [@class="btn btn-primary required"] + [.="[trans]Name[/trans]"] +' + ); + } + public function testMultipleChoiceSkipsPlaceholder() { $form = $this->factory->createNamed('name', ChoiceType::class, ['&a'], [ @@ -546,6 +584,31 @@ public function testSingleChoiceWithPreferred() ); } + public function testSingleChoiceWithPreferredIsNotDuplicated() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&b'], + 'duplicate_preferred_choices' => false, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --', 'attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + /following-sibling::option[@disabled="disabled"][not(@selected)][.="-- sep --"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + ] + [count(./option)=3] +' + ); + } + public function testSingleChoiceWithSelectedPreferred() { $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ @@ -871,7 +934,7 @@ public function testRadioRowWithHelp() '/div [@class="mb-3"] [ - ./p + ./div [@class="form-text mb-0 help-text"] [.="[trans]really helpful text[/trans]"] ] @@ -968,6 +1031,7 @@ public function testDateTime() $form = $this->factory->createNamed('name', DateTimeType::class, date('Y').'-02-03 04:05:06', [ 'input' => 'string', 'with_seconds' => false, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1021,6 +1085,7 @@ public function testDateTimeWithPlaceholderGlobal() 'input' => 'string', 'placeholder' => 'Change&Me', 'required' => false, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1074,6 +1139,7 @@ public function testDateTimeWithHourAndMinute() $form = $this->factory->createNamed('name', DateTimeType::class, $data, [ 'input' => 'array', 'required' => false, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1125,6 +1191,7 @@ public function testDateTimeWithSeconds() $form = $this->factory->createNamed('name', DateTimeType::class, date('Y').'-02-03 04:05:06', [ 'input' => 'string', 'with_seconds' => true, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1349,6 +1416,7 @@ public function testBirthDay() { $form = $this->factory->createNamed('name', BirthdayType::class, '2000-02-03', [ 'input' => 'string', + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1383,6 +1451,7 @@ public function testBirthDayWithPlaceholder() 'input' => 'string', 'placeholder' => '', 'required' => false, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1560,6 +1629,7 @@ public function testTime() $form = $this->factory->createNamed('name', TimeType::class, '04:05:06', [ 'input' => 'string', 'with_seconds' => false, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1593,6 +1663,7 @@ public function testTimeWithSeconds() $form = $this->factory->createNamed('name', TimeType::class, '04:05:06', [ 'input' => 'string', 'with_seconds' => true, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1676,6 +1747,7 @@ public function testTimeWithPlaceholderGlobal() 'input' => 'string', 'placeholder' => 'Change&Me', 'required' => false, + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1709,6 +1781,7 @@ public function testTimeWithPlaceholderOnYear() 'input' => 'string', 'required' => false, 'placeholder' => ['hour' => 'Change&Me'], + 'widget' => 'choice', ]); $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], @@ -1769,8 +1842,6 @@ public function testTimezoneWithPlaceholder() public function testWeekChoices() { - $this->requiresFeatureSet(404); - $data = ['year' => (int) date('Y'), 'week' => 1]; $form = $this->factory->createNamed('name', WeekType::class, $data, [ diff --git a/Tests/Extension/AbstractDivLayoutTestCase.php b/Tests/Extension/AbstractDivLayoutTestCase.php new file mode 100644 index 00000000..28e8997a --- /dev/null +++ b/Tests/Extension/AbstractDivLayoutTestCase.php @@ -0,0 +1,994 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Extension; + +use Symfony\Component\Form\FormError; +use Symfony\Component\Security\Csrf\CsrfToken; + +abstract class AbstractDivLayoutTestCase extends AbstractLayoutTestCase +{ + public function testRow() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $form->addError(new FormError('[trans]Error![/trans]')); + $view = $form->createView(); + $html = $this->renderRow($view); + + $this->assertMatchesXpath($html, + '/div + [ + ./label[@for="name"] + /following-sibling::ul + [./li[.="[trans]Error![/trans]"]] + [count(./li)=1] + /following-sibling::input[@id="name"] + ] +' + ); + } + + public function testRowOverrideVariables() + { + $view = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType')->createView(); + $html = $this->renderRow($view, [ + 'attr' => ['class' => 'my&class'], + 'label' => 'foo&bar', + 'label_attr' => ['class' => 'my&label&class'], + ]); + + $this->assertMatchesXpath($html, + '/div + [ + ./label[@for="name"][@class="my&label&class required"][.="[trans]foo&bar[/trans]"] + /following-sibling::input[@id="name"][@class="my&class"] + ] +' + ); + } + + public function testRepeatedRow() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType'); + $form->addError(new FormError('[trans]Error![/trans]')); + $view = $form->createView(); + $html = $this->renderRow($view); + + // The errors of the form are not rendered by intention! + // In practice, repeated fields cannot have errors as all errors + // on them are mapped to the first child. + // (see RepeatedTypeValidatorExtension) + + $this->assertMatchesXpath($html, + '/div + [ + ./label[@for="name_first"] + /following-sibling::input[@id="name_first"] + ] +/following-sibling::div + [ + ./label[@for="name_second"] + /following-sibling::input[@id="name_second"] + ] +' + ); + } + + public function testButtonRow() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ButtonType'); + $view = $form->createView(); + $html = $this->renderRow($view); + + $this->assertMatchesXpath($html, + '/div + [ + ./button[@type="button"][@name="name"] + ] + [count(//label)=0] +' + ); + } + + public function testRest() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('field1', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('field2', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType') + ->add('field3', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('field4', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm() + ->createView(); + + // Render field2 row -> does not implicitly call renderWidget because + // it is a repeated field! + $this->renderRow($view['field2']); + + // Render field3 widget + $this->renderWidget($view['field3']); + + // Rest should only contain field1 and field4 + $html = $this->renderRest($view); + + $this->assertMatchesXpath($html, + '/div + [ + ./label[@for="name_field1"] + /following-sibling::input[@type="text"][@id="name_field1"] + ] +/following-sibling::div + [ + ./label[@for="name_field4"] + /following-sibling::input[@type="text"][@id="name_field4"] + ] + [count(../div)=2] + [count(..//label)=2] + [count(..//input)=3] +/following-sibling::input + [@type="hidden"] + [@id="name__token"] +' + ); + } + + public function testRestWithChildrenForms() + { + $child1 = $this->factory->createNamedBuilder('child1', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('field1', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('field2', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + + $child2 = $this->factory->createNamedBuilder('child2', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('field1', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('field2', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + + $view = $this->factory->createNamedBuilder('parent', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add($child1) + ->add($child2) + ->getForm() + ->createView(); + + // Render child1.field1 row + $this->renderRow($view['child1']['field1']); + + // Render child2.field2 widget (remember that widget don't render label) + $this->renderWidget($view['child2']['field2']); + + // Rest should only contain child1.field2 and child2.field1 + $html = $this->renderRest($view); + + $this->assertMatchesXpath($html, + '/div + [ + ./label[not(@for)] + /following-sibling::div[@id="parent_child1"] + [ + ./div + [ + ./label[@for="parent_child1_field2"] + /following-sibling::input[@id="parent_child1_field2"] + ] + ] + ] + +/following-sibling::div + [ + ./label[not(@for)] + /following-sibling::div[@id="parent_child2"] + [ + ./div + [ + ./label[@for="parent_child2_field1"] + /following-sibling::input[@id="parent_child2_field1"] + ] + ] + ] + [count(//label)=4] + [count(//input[@type="text"])=2] +/following-sibling::input[@type="hidden"][@id="parent__token"] +' + ); + } + + public function testRestAndRepeatedWithRow() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('first', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('password', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType') + ->getForm() + ->createView(); + + $this->renderRow($view['password']); + + $html = $this->renderRest($view); + + $this->assertMatchesXpath($html, + '/div + [ + ./label[@for="name_first"] + /following-sibling::input[@type="text"][@id="name_first"] + ] + [count(.//input)=1] +/following-sibling::input + [@type="hidden"] + [@id="name__token"] +' + ); + } + + public function testRestAndRepeatedWithRowPerChild() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('first', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('password', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType') + ->getForm() + ->createView(); + + $this->renderRow($view['password']['first']); + $this->renderRow($view['password']['second']); + + $html = $this->renderRest($view); + + $this->assertMatchesXpath($html, + '/div + [ + ./label[@for="name_first"] + /following-sibling::input[@type="text"][@id="name_first"] + ] + [count(.//input)=1] + [count(.//label)=1] +/following-sibling::input + [@type="hidden"] + [@id="name__token"] +' + ); + } + + public function testRestAndRepeatedWithWidgetPerChild() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('first', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('password', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType') + ->getForm() + ->createView(); + + // The password form is considered as rendered as all its children + // are rendered + $this->renderWidget($view['password']['first']); + $this->renderWidget($view['password']['second']); + + $html = $this->renderRest($view); + + $this->assertMatchesXpath($html, + '/div + [ + ./label[@for="name_first"] + /following-sibling::input[@type="text"][@id="name_first"] + ] + [count(//input)=2] + [count(//label)=1] +/following-sibling::input + [@type="hidden"] + [@id="name__token"] +' + ); + } + + public function testCollection() + { + $form = $this->factory->createNamed('names', 'Symfony\Component\Form\Extension\Core\Type\CollectionType', ['a', 'b'], [ + 'entry_type' => 'Symfony\Component\Form\Extension\Core\Type\TextType', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div[./input[@type="text"][@value="a"]] + /following-sibling::div[./input[@type="text"][@value="b"]] + ] + [count(./div[./input])=2] +' + ); + } + + // https://github.com/symfony/symfony/issues/5038 + public function testCollectionWithAlternatingRowTypes() + { + $data = [ + ['title' => 'a'], + ['title' => 'b'], + ]; + $form = $this->factory->createNamed('names', 'Symfony\Component\Form\Extension\Core\Type\CollectionType', $data, [ + 'entry_type' => 'Symfony\Component\Form\Tests\Fixtures\AlternatingRowType', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div[./div/div/input[@type="text"][@value="a"]] + /following-sibling::div[./div/div/textarea[.="b"]] + ] + [count(./div[./div/div/input])=1] + [count(./div[./div/div/textarea])=1] +' + ); + } + + public function testEmptyCollection() + { + $form = $this->factory->createNamed('names', 'Symfony\Component\Form\Extension\Core\Type\CollectionType', [], [ + 'entry_type' => 'Symfony\Component\Form\Extension\Core\Type\TextType', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [./input[@type="hidden"][@id="names__token"]] + [count(./div)=0] +' + ); + } + + public function testCollectionRow() + { + $collection = $this->factory->createNamedBuilder( + 'collection', + 'Symfony\Component\Form\Extension\Core\Type\CollectionType', + ['a', 'b'], + ['entry_type' => 'Symfony\Component\Form\Extension\Core\Type\TextType'] + ); + + $form = $this->factory->createNamedBuilder('form', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add($collection) + ->getForm(); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div + [ + ./label[not(@for)] + /following-sibling::div + [ + ./div + [ + ./label[@for="form_collection_0"] + /following-sibling::input[@type="text"][@value="a"] + ] + /following-sibling::div + [ + ./label[@for="form_collection_1"] + /following-sibling::input[@type="text"][@value="b"] + ] + ] + ] + /following-sibling::input[@type="hidden"][@id="form__token"] + ] + [count(.//input)=3] +' + ); + } + + public function testForm() + { + $form = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->setMethod('PUT') + ->setAction('http://example.com') + ->add('firstName', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('lastName', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm(); + + // include ampersands everywhere to validate escaping + $html = $this->renderForm($form->createView(), [ + 'id' => 'my&id', + 'attr' => ['class' => 'my&class'], + ]); + + $this->assertMatchesXpath($html, + '/form + [ + ./input[@type="hidden"][@name="_method"][@value="PUT"] + /following-sibling::div + [ + ./div + [ + ./label[@for="name_firstName"] + /following-sibling::input[@type="text"][@id="name_firstName"] + ] + /following-sibling::div + [ + ./label[@for="name_lastName"] + /following-sibling::input[@type="text"][@id="name_lastName"] + ] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(.//input)=3] + [@id="my&id"] + [@class="my&class"] + ] + [@method="post"] + [@action="http://example.com"] + [@class="my&class"] +' + ); + } + + public function testFormWidget() + { + $form = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('firstName', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('lastName', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm(); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div + [ + ./label[@for="name_firstName"] + /following-sibling::input[@type="text"][@id="name_firstName"] + ] + /following-sibling::div + [ + ./label[@for="name_lastName"] + /following-sibling::input[@type="text"][@id="name_lastName"] + ] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(.//input)=3] +' + ); + } + + // https://github.com/symfony/symfony/issues/2308 + public function testNestedFormError() + { + $form = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add($this->factory + ->createNamedBuilder('child', 'Symfony\Component\Form\Extension\Core\Type\FormType', null, ['error_bubbling' => false]) + ->add('grandChild', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ) + ->getForm(); + + $form->get('child')->addError(new FormError('[trans]Error![/trans]')); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div/label + /following-sibling::ul[./li[.="[trans]Error![/trans]"]] + ] + [count(.//li[.="[trans]Error![/trans]"])=1] +' + ); + } + + public function testCsrf() + { + $this->csrfTokenManager->expects($this->any()) + ->method('getToken') + ->willReturn(new CsrfToken('token_id', 'foo&bar')); + + $form = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add($this->factory + // No CSRF protection on nested forms + ->createNamedBuilder('child', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add($this->factory->createNamedBuilder('grandchild', 'Symfony\Component\Form\Extension\Core\Type\TextType')) + ) + ->getForm(); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div + /following-sibling::input[@type="hidden"][@id="name__token"][@value="foo&bar"] + ] + [count(.//input[@type="hidden"])=1] +' + ); + } + + public function testRepeated() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType', 'foobar', [ + 'type' => 'Symfony\Component\Form\Extension\Core\Type\TextType', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div + [ + ./label[@for="name_first"] + /following-sibling::input[@type="text"][@id="name_first"] + ] + /following-sibling::div + [ + ./label[@for="name_second"] + /following-sibling::input[@type="text"][@id="name_second"] + ] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(.//input)=3] +' + ); + } + + public function testRepeatedWithCustomOptions() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType', null, [ + // the global required value cannot be overridden + 'first_options' => ['label' => 'Test', 'required' => false], + 'second_options' => ['label' => 'Test2'], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div + [ + ./label[@for="name_first"][.="[trans]Test[/trans]"] + /following-sibling::input[@type="text"][@id="name_first"][@required="required"] + ] + /following-sibling::div + [ + ./label[@for="name_second"][.="[trans]Test2[/trans]"] + /following-sibling::input[@type="text"][@id="name_second"][@required="required"] + ] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(.//input)=3] +' + ); + } + + public function testSearchInputName() + { + $form = $this->factory->createNamedBuilder('full', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('name', 'Symfony\Component\Form\Extension\Core\Type\SearchType') + ->getForm(); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div + [ + ./label[@for="full_name"] + /following-sibling::input[@type="search"][@id="full_name"][@name="full[name]"] + ] + /following-sibling::input[@type="hidden"][@id="full__token"] + ] + [count(//input)=2] +' + ); + } + + public function testLabelHasNoId() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $html = $this->renderRow($form->createView()); + + $this->assertMatchesXpath($html, + '/div + [ + ./label[@for="name"][not(@id)] + /following-sibling::input[@id="name"] + ] +' + ); + } + + public function testLabelIsNotRenderedWhenSetToFalse() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'label' => false, + ]); + $html = $this->renderRow($form->createView()); + + $this->assertMatchesXpath($html, + '/div + [ + ./input[@id="name"] + ] + [count(//label)=0] +' + ); + } + + /** + * @dataProvider themeBlockInheritanceProvider + */ + public function testThemeBlockInheritance($theme) + { + $view = $this->factory + ->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\EmailType') + ->createView() + ; + + $this->setTheme($view, $theme); + + $this->assertMatchesXpath( + $this->renderWidget($view), + '/input[@type="email"][@rel="theme"]' + ); + } + + public static function themeBlockInheritanceProvider(): array + { + return [ + [['theme.html.twig']], + ]; + } + + /** + * @dataProvider themeInheritanceProvider + */ + public function testThemeInheritance($parentTheme, $childTheme) + { + $child = $this->factory->createNamedBuilder('child', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('field', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + + $view = $this->factory->createNamedBuilder('parent', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('field', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add($child) + ->getForm() + ->createView() + ; + + $this->setTheme($view, $parentTheme); + $this->setTheme($view['child'], $childTheme); + + $this->assertWidgetMatchesXpath($view, [], + '/div + [ + ./div + [ + ./label[.="parent"] + /following-sibling::input[@type="text"] + ] + /following-sibling::div + [ + ./label[.="child"] + /following-sibling::div + [ + ./div + [ + ./label[.="child"] + /following-sibling::input[@type="text"] + ] + ] + ] + /following-sibling::input[@type="hidden"] + ] +' + ); + } + + public static function themeInheritanceProvider(): array + { + return [ + [['parent_label.html.twig'], ['child_label.html.twig']], + ]; + } + + /** + * The block "_name_child_label" should be overridden in the theme of the + * implemented driver. + */ + public function testCollectionRowWithCustomBlock() + { + $collection = ['one', 'two', 'three']; + $form = $this->factory->createNamedBuilder('names', 'Symfony\Component\Form\Extension\Core\Type\CollectionType', $collection) + ->getForm(); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div[./label[.="Custom label: [trans]0[/trans]"]] + /following-sibling::div[./label[.="Custom label: [trans]1[/trans]"]] + /following-sibling::div[./label[.="Custom label: [trans]2[/trans]"]] + ] +' + ); + } + + /** + * The block "_name_c_entry_label" should be overridden in the theme of the + * implemented driver. + */ + public function testChoiceRowWithCustomBlock() + { + $form = $this->factory->createNamedBuilder('name_c', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', 'a', [ + 'choices' => ['ChoiceA' => 'a', 'ChoiceB' => 'b'], + 'expanded' => true, + ]) + ->getForm(); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./label[.="Custom name label: [trans]ChoiceA[/trans]"] + /following-sibling::label[.="Custom name label: [trans]ChoiceB[/trans]"] + ] +' + ); + } + + public function testSingleChoiceExpandedWithLabelsAsFalse() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'choice_label' => false, + 'multiple' => false, + 'expanded' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="radio"][@name="name"][@id="name_0"][@value="&a"][@checked] + /following-sibling::input[@type="radio"][@name="name"][@id="name_1"][@value="&b"][not(@checked)] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=3] + [count(./label)=1] +' + ); + } + + public function testSingleChoiceExpandedWithLabelsSetByCallable() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b', 'Choice&C' => '&c'], + 'choice_label' => function ($choice, $label, $value) { + if ('&b' === $choice) { + return false; + } + + return 'label.'.$value; + }, + 'multiple' => false, + 'expanded' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="radio"][@name="name"][@id="name_0"][@value="&a"][@checked] + /following-sibling::label[@for="name_0"][.="[trans]label.&a[/trans]"] + /following-sibling::input[@type="radio"][@name="name"][@id="name_1"][@value="&b"][not(@checked)] + /following-sibling::input[@type="radio"][@name="name"][@id="name_2"][@value="&c"][not(@checked)] + /following-sibling::label[@for="name_2"][.="[trans]label.&c[/trans]"] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=4] + [count(./label)=3] +' + ); + } + + public function testSingleChoiceExpandedWithLabelsSetFalseByCallable() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'choice_label' => fn () => false, + 'multiple' => false, + 'expanded' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="radio"][@name="name"][@id="name_0"][@value="&a"][@checked] + /following-sibling::input[@type="radio"][@name="name"][@id="name_1"][@value="&b"][not(@checked)] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=3] + [count(./label)=1] +' + ); + } + + public function testMultipleChoiceExpandedWithLabelsAsFalse() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', ['&a'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'choice_label' => false, + 'multiple' => true, + 'expanded' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="checkbox"][@name="name[]"][@id="name_0"][@value="&a"][@checked] + /following-sibling::input[@type="checkbox"][@name="name[]"][@id="name_1"][@value="&b"][not(@checked)] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=3] + [count(./label)=1] +' + ); + } + + public function testMultipleChoiceExpandedWithLabelsSetByCallable() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', ['&a'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b', 'Choice&C' => '&c'], + 'choice_label' => function ($choice, $label, $value) { + if ('&b' === $choice) { + return false; + } + + return 'label.'.$value; + }, + 'multiple' => true, + 'expanded' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="checkbox"][@name="name[]"][@id="name_0"][@value="&a"][@checked] + /following-sibling::label[@for="name_0"][.="[trans]label.&a[/trans]"] + /following-sibling::input[@type="checkbox"][@name="name[]"][@id="name_1"][@value="&b"][not(@checked)] + /following-sibling::input[@type="checkbox"][@name="name[]"][@id="name_2"][@value="&c"][not(@checked)] + /following-sibling::label[@for="name_2"][.="[trans]label.&c[/trans]"] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=4] + [count(./label)=3] +' + ); + } + + public function testMultipleChoiceExpandedWithLabelsSetFalseByCallable() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', ['&a'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'choice_label' => fn () => false, + 'multiple' => true, + 'expanded' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="checkbox"][@name="name[]"][@id="name_0"][@value="&a"][@checked] + /following-sibling::input[@type="checkbox"][@name="name[]"][@id="name_1"][@value="&b"][not(@checked)] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=3] + [count(./label)=1] +' + ); + } + + public function testSingleChoiceWithoutDuplicatePreferredIsSelected() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&d', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b', 'Choice&C' => '&c', 'Choice&D' => '&d'], + 'preferred_choices' => ['&b', '&d'], + 'duplicate_preferred_choices' => false, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --'], + '/select + [@name="name"] + [ + ./option[@value="&d"][@selected="selected"] + /following-sibling::option[@disabled="disabled"][.="-- sep --"] + /following-sibling::option[@value="&a"][not(@selected)] + /following-sibling::option[@value="&c"][not(@selected)] + ] + [count(./option)=5] +' + ); + } + + public function testSingleChoiceWithoutDuplicateNotPreferredIsSelected() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&d', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b', 'Choice&C' => '&c', 'Choice&D' => '&d'], + 'preferred_choices' => ['&b', '&d'], + 'duplicate_preferred_choices' => true, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --'], + '/select + [@name="name"] + [ + ./option[@value="&d"][not(@selected)] + /following-sibling::option[@disabled="disabled"][.="-- sep --"] + /following-sibling::option[@value="&a"][not(@selected)] + /following-sibling::option[@value="&b"][not(@selected)] + /following-sibling::option[@value="&c"][not(@selected)] + /following-sibling::option[@value="&d"][@selected="selected"] + ] + [count(./option)=7] +' + ); + } + + public function testFormEndWithRest() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('field1', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('field2', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm() + ->createView(); + + $this->renderWidget($view['field1']); + + // Rest should only contain field2 + $html = $this->renderEnd($view); + + // Insert the start tag, the end tag should be rendered by the helper + $this->assertMatchesXpath('
'.$html, + '/form + [ + ./div + [ + ./label[@for="name_field2"] + /following-sibling::input[@type="text"][@id="name_field2"] + ] + /following-sibling::input + [@type="hidden"] + [@id="name__token"] + ] +' + ); + } + + public function testFormEndWithoutRest() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('field1', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('field2', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm() + ->createView(); + + $this->renderWidget($view['field1']); + + // Rest should only contain field2, but isn't rendered + $html = $this->renderEnd($view, ['render_rest' => false]); + + $this->assertEquals('
', $html); + } + + public function testWidgetContainerAttributes() + { + $form = $this->factory->createNamed('form', 'Symfony\Component\Form\Extension\Core\Type\FormType', null, [ + 'attr' => ['class' => 'foobar', 'data-foo' => 'bar'], + ]); + + $form->add('text', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + + $html = $this->renderWidget($form->createView()); + + // compare plain HTML to check the whitespace + $this->assertStringContainsString('
', $html); + } + + public function testWidgetContainerAttributeNameRepeatedIfTrue() + { + $form = $this->factory->createNamed('form', 'Symfony\Component\Form\Extension\Core\Type\FormType', null, [ + 'attr' => ['foo' => true], + ]); + + $html = $this->renderWidget($form->createView()); + + // foo="foo" + $this->assertStringContainsString('
', $html); + } +} diff --git a/Tests/Extension/AbstractLayoutTestCase.php b/Tests/Extension/AbstractLayoutTestCase.php new file mode 100644 index 00000000..5a541d7b --- /dev/null +++ b/Tests/Extension/AbstractLayoutTestCase.php @@ -0,0 +1,2828 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Extension; + +use PHPUnit\Framework\MockObject\MockObject; +use Symfony\Bridge\Twig\Test\FormLayoutTestCase; +use Symfony\Component\Form\Extension\Core\Type\PercentType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Csrf\CsrfExtension; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormExtensionInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Component\Translation\TranslatableMessage; +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +abstract class AbstractLayoutTestCase extends FormLayoutTestCase +{ + protected MockObject&CsrfTokenManagerInterface $csrfTokenManager; + protected array $testableFeatures = []; + + private string $defaultLocale; + + protected function setUp(): void + { + if (!\extension_loaded('intl')) { + $this->markTestSkipped('Extension intl is required.'); + } + + $this->defaultLocale = \Locale::getDefault(); + \Locale::setDefault('en'); + + $this->csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + + parent::setUp(); + } + + /** + * @return FormExtensionInterface[] + */ + protected function getExtensions(): array + { + return [ + new CsrfExtension($this->csrfTokenManager), + ]; + } + + protected function tearDown(): void + { + \Locale::setDefault($this->defaultLocale); + } + + protected function assertWidgetMatchesXpath(FormView $view, array $vars, $xpath) + { + // include ampersands everywhere to validate escaping + $html = $this->renderWidget($view, array_merge([ + 'id' => 'my&id', + 'attr' => ['class' => 'my&class'], + ], $vars)); + + if (!isset($vars['id'])) { + $xpath = trim($xpath).' + [@id="my&id"]'; + } + + if (!isset($vars['attr']['class'])) { + $xpath .= ' + [@class="my&class"]'; + } + + $this->assertMatchesXpath($html, $xpath); + } + + public function testLabel() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $view = $form->createView(); + $this->renderWidget($view, ['label' => 'foo']); + $html = $this->renderLabel($view); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [.="[trans]Name[/trans]"] +' + ); + } + + public function testLabelWithoutTranslation() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'translation_domain' => false, + ]); + + $this->assertMatchesXpath($this->renderLabel($form->createView()), + '/label + [@for="name"] + [.="Name"] +' + ); + } + + public function testLabelOnForm() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateType', null, ['widget' => 'choice']); + $view = $form->createView(); + $this->renderWidget($view, ['label' => 'foo']); + $html = $this->renderLabel($view); + + $this->assertMatchesXpath($html, + '/label + [@class="required"] + [.="[trans]Name[/trans]"] +' + ); + } + + public function testLabelWithCustomTextPassedAsOption() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'label' => 'Custom label', + ]); + $html = $this->renderLabel($form->createView()); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [.="[trans]Custom label[/trans]"] +' + ); + } + + public function testLabelWithCustomTextPassedDirectly() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $html = $this->renderLabel($form->createView(), 'Custom label'); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [.="[trans]Custom label[/trans]"] +' + ); + } + + public function testLabelWithCustomTextPassedAsOptionAndDirectly() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'label' => 'Custom label', + ]); + $html = $this->renderLabel($form->createView(), 'Overridden label'); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [.="[trans]Overridden label[/trans]"] +' + ); + } + + public function testLabelDoesNotRenderFieldAttributes() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $html = $this->renderLabel($form->createView(), null, [ + 'attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [@class="required"] +' + ); + } + + public function testLabelWithCustomAttributesPassedDirectly() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $html = $this->renderLabel($form->createView(), null, [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [@class="my&class required"] +' + ); + } + + public function testLabelWithCustomTextAndCustomAttributesPassedDirectly() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $html = $this->renderLabel($form->createView(), 'Custom label', [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [@class="my&class required"] + [.="[trans]Custom label[/trans]"] +' + ); + } + + // https://github.com/symfony/symfony/issues/5029 + public function testLabelWithCustomTextAsOptionAndCustomAttributesPassedDirectly() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'label' => 'Custom label', + ]); + $html = $this->renderLabel($form->createView(), null, [ + 'label_attr' => [ + 'class' => 'my&class', + ], + ]); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [@class="my&class required"] + [.="[trans]Custom label[/trans]"] +' + ); + } + + public function testLabelFormatName() + { + $form = $this->factory->createNamedBuilder('myform') + ->add('myfield', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm(); + $view = $form->get('myfield')->createView(); + $html = $this->renderLabel($view, null, ['label_format' => 'form.%name%']); + + $this->assertMatchesXpath($html, + '/label + [@for="myform_myfield"] + [.="[trans]form.myfield[/trans]"] +' + ); + } + + public function testLabelFormatId() + { + $form = $this->factory->createNamedBuilder('myform') + ->add('myfield', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm(); + $view = $form->get('myfield')->createView(); + $html = $this->renderLabel($view, null, ['label_format' => 'form.%id%']); + + $this->assertMatchesXpath($html, + '/label + [@for="myform_myfield"] + [.="[trans]form.myform_myfield[/trans]"] +' + ); + } + + public function testLabelFormatAsFormOption() + { + $options = ['label_format' => 'form.%name%']; + + $form = $this->factory->createNamedBuilder('myform', 'Symfony\Component\Form\Extension\Core\Type\FormType', null, $options) + ->add('myfield', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm(); + $view = $form->get('myfield')->createView(); + $html = $this->renderLabel($view); + + $this->assertMatchesXpath($html, + '/label + [@for="myform_myfield"] + [.="[trans]form.myfield[/trans]"] +' + ); + } + + public function testLabelFormatOverriddenOption() + { + $options = ['label_format' => 'form.%name%']; + + $form = $this->factory->createNamedBuilder('myform', 'Symfony\Component\Form\Extension\Core\Type\FormType', null, $options) + ->add('myfield', 'Symfony\Component\Form\Extension\Core\Type\TextType', ['label_format' => 'field.%name%']) + ->getForm(); + $view = $form->get('myfield')->createView(); + $html = $this->renderLabel($view); + + $this->assertMatchesXpath($html, + '/label + [@for="myform_myfield"] + [.="[trans]field.myfield[/trans]"] +' + ); + } + + public function testLabelWithoutTranslationOnButton() + { + $form = $this->factory->createNamedBuilder('myform', 'Symfony\Component\Form\Extension\Core\Type\FormType', null, [ + 'translation_domain' => false, + ]) + ->add('mybutton', 'Symfony\Component\Form\Extension\Core\Type\ButtonType') + ->getForm(); + $view = $form->get('mybutton')->createView(); + $html = $this->renderWidget($view); + + $this->assertMatchesXpath($html, + '/button + [@type="button"] + [@name="myform[mybutton]"] + [.="Mybutton"] +' + ); + } + + public function testLabelFormatOnButton() + { + $form = $this->factory->createNamedBuilder('myform') + ->add('mybutton', 'Symfony\Component\Form\Extension\Core\Type\ButtonType') + ->getForm(); + $view = $form->get('mybutton')->createView(); + $html = $this->renderWidget($view, ['label_format' => 'form.%name%']); + + $this->assertMatchesXpath($html, + '/button + [@type="button"] + [@name="myform[mybutton]"] + [.="[trans]form.mybutton[/trans]"] +' + ); + } + + public function testLabelFormatOnButtonId() + { + $form = $this->factory->createNamedBuilder('myform') + ->add('mybutton', 'Symfony\Component\Form\Extension\Core\Type\ButtonType') + ->getForm(); + $view = $form->get('mybutton')->createView(); + $html = $this->renderWidget($view, ['label_format' => 'form.%id%']); + + $this->assertMatchesXpath($html, + '/button + [@type="button"] + [@name="myform[mybutton]"] + [.="[trans]form.myform_mybutton[/trans]"] +' + ); + } + + public function testHelp() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'help' => 'Help text test!', + ]); + $view = $form->createView(); + $html = $this->renderHelp($view); + + $this->assertMatchesXpath($html, + '/*[self::div or self::p] + [@id="name_help"] + [@class="help-text"] + [.="[trans]Help text test![/trans]"] +' + ); + } + + public function testHelpNotSet() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $view = $form->createView(); + $html = $this->renderHelp($view); + + $this->assertMatchesXpath($html, '/p', 0); + } + + public function testHelpSetLinkFromWidget() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'help' => 'Help text test!', + ]); + $view = $form->createView(); + $html = $this->renderRow($view); + + // Test if renderHelp method is implemented (throw SkippedTestError if not) + $this->renderHelp($view); + + $this->assertMatchesXpath($html, + '//input + [@aria-describedby="name_help"] +' + ); + } + + public function testHelpNotSetNotLinkedFromWidget() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $view = $form->createView(); + $html = $this->renderRow($view); + + // Test if renderHelp method is implemented (throw SkippedTestError if not) + $this->renderHelp($view); + + $this->assertMatchesXpath($html, + '//input + [not(@aria-describedby)] +' + ); + } + + public function testErrors() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $form->addError(new FormError('[trans]Error 1[/trans]')); + $form->addError(new FormError('[trans]Error 2[/trans]')); + $view = $form->createView(); + $html = $this->renderErrors($view); + + $this->assertMatchesXpath($html, + '/ul + [ + ./li[.="[trans]Error 1[/trans]"] + /following-sibling::li[.="[trans]Error 2[/trans]"] + ] + [count(./li)=2] +' + ); + } + + public function testOverrideWidgetBlock() + { + // see custom_widgets.html.twig + $form = $this->factory->createNamed('text_id', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $html = $this->renderWidget($form->createView()); + + $this->assertMatchesXpath($html, + '/div + [ + ./input + [@type="text"] + [@id="text_id"] + ] + [@id="container"] +' + ); + } + + public function testCheckedCheckbox() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\CheckboxType', true); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="checkbox"] + [@name="name"] + [@checked="checked"] + [@value="1"] +' + ); + } + + public function testUncheckedCheckbox() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\CheckboxType', false); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="checkbox"] + [@name="name"] + [not(@checked)] +' + ); + } + + public function testCheckboxWithValue() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\CheckboxType', false, [ + 'value' => 'foo&bar', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="checkbox"] + [@name="name"] + [@value="foo&bar"] +' + ); + } + + public function testSingleChoice() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => false, + ]); + + // If the field is collapsed, has no "multiple" attribute, is required but + // has *no* empty value, the "required" must not be added, otherwise + // the resulting HTML is invalid. + // https://github.com/symfony/symfony/issues/8942 + + // HTML 5 spec + // http://www.w3.org/html/wg/drafts/html/master/forms.html#placeholder-label-option + + // "If a select element has a required attribute specified, does not + // have a multiple attribute specified, and has a display size of 1, + // then the select element must have a placeholder label option." + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [not(@required)] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testSelectWithSizeBiggerThanOneCanBeRequired() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', null, [ + 'choices' => ['a', 'b'], + 'multiple' => false, + 'expanded' => false, + 'attr' => ['size' => 2], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [@required="required"] + [@size="2"] + [count(./option)=2] +' + ); + } + + public function testSingleChoiceWithoutTranslation() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => false, + 'choice_translation_domain' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [not(@required)] + [ + ./option[@value="&a"][@selected="selected"][.="Choice&A"] + /following-sibling::option[@value="&b"][not(@selected)][.="Choice&B"] + ] + [count(./option)=2] +' + ); + } + + public function testSingleChoiceWithPlaceholderWithoutTranslation() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => false, + 'required' => false, + 'translation_domain' => false, + 'placeholder' => 'Placeholder&Not&Translated', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [not(@required)] + [ + ./option[@value=""][not(@selected)][not(@disabled)][.="Placeholder&Not&Translated"] + /following-sibling::option[@value="&a"][@selected="selected"][.="Choice&A"] + /following-sibling::option[@value="&b"][not(@selected)][.="Choice&B"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceAttributes() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'choice_attr' => ['Choice&B' => ['class' => 'foo&bar']], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [not(@required)] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][@class="foo&bar"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testSingleChoiceAttributesWithMainAttributes() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => false, + 'attr' => ['class' => 'bar&baz'], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'bar&baz']], + '/select + [@name="name"] + [@class="bar&baz"] + [not(@required)] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"][not(@id)][not(@name)] + /following-sibling::option[@value="&b"][not(@class)][not(@selected)][.="[trans]Choice&B[/trans]"][not(@id)][not(@name)] + ] + [count(./option)=2] +' + ); + } + + public function testSingleExpandedChoiceAttributesWithMainAttributes() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => true, + 'attr' => ['class' => 'bar&baz'], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'bar&baz']], + '/div + [@class="bar&baz"] + [ + ./input[@type="radio"][@name="name"][@id="name_0"][@value="&a"][@checked] + /following-sibling::label[@for="name_0"][.="[trans]Choice&A[/trans]"] + /following-sibling::input[@type="radio"][@name="name"][@id="name_1"][@value="&b"][not(@checked)] + /following-sibling::label[@for="name_1"][.="[trans]Choice&B[/trans]"] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=3] +' + ); + } + + public function testSingleChoiceWithPreferred() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&b'], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --'], + '/select + [@name="name"] + [not(@required)] + [ + ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + /following-sibling::option[@disabled="disabled"][not(@selected)][.="-- sep --"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=4] +' + ); + } + + public function testSingleChoiceWithPreferredAndNoSeparator() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&b'], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => null], + '/select + [@name="name"] + [not(@required)] + [ + ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceWithPreferredAndBlankSeparator() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&b'], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => ''], + '/select + [@name="name"] + [not(@required)] + [ + ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + /following-sibling::option[@disabled="disabled"][not(@selected)][.=""] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=4] +' + ); + } + + public function testChoiceWithOnlyPreferred() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&a', '&b'], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [count(./option)=5] +' + ); + } + + public function testSingleChoiceNonRequired() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'required' => false, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [not(@required)] + [ + ./option[@value=""][.=""] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceNonRequiredNoneSelected() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', null, [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'required' => false, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [not(@required)] + [ + ./option[@value=""][.=""] + /following-sibling::option[@value="&a"][not(@selected)][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceNonRequiredWithPlaceholder() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => false, + 'required' => false, + 'placeholder' => 'Select&Anything&Not&Me', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [not(@required)] + [ + ./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Select&Anything&Not&Me[/trans]"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceRequiredWithPlaceholder() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'required' => true, + 'multiple' => false, + 'expanded' => false, + 'placeholder' => 'Test&Me', + ]); + + // The "disabled" attribute was removed again due to a bug in the + // BlackBerry 10 browser. + // See https://github.com/symfony/symfony/pull/7678 + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [@required="required"] + [ + ./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Test&Me[/trans]"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceRequiredWithPlaceholderViaView() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'required' => true, + 'multiple' => false, + 'expanded' => false, + ]); + + // The "disabled" attribute was removed again due to a bug in the + // BlackBerry 10 browser. + // See https://github.com/symfony/symfony/pull/7678 + $this->assertWidgetMatchesXpath($form->createView(), ['placeholder' => ''], + '/select + [@name="name"] + [@required="required"] + [ + ./option[@value=""][not(@selected)][not(@disabled)][.=""] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=3] +' + ); + } + + public function testSingleChoiceGrouped() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => [ + 'Group&1' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'Group&2' => ['Choice&C' => '&c'], + ], + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [./optgroup[@label="[trans]Group&1[/trans]"] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] + ] + [./optgroup[@label="[trans]Group&2[/trans]"] + [./option[@value="&c"][not(@selected)][.="[trans]Choice&C[/trans]"]] + [count(./option)=1] + ] + [count(./optgroup)=2] +' + ); + } + + public function testMultipleChoice() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', ['&a'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'required' => true, + 'multiple' => true, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name[]"] + [@required="required"] + [@multiple="multiple"] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testMultipleChoiceAttributes() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', ['&a'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'choice_attr' => ['Choice&B' => ['class' => 'foo&bar']], + 'required' => true, + 'multiple' => true, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name[]"] + [@required="required"] + [@multiple="multiple"] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][@class="foo&bar"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testMultipleChoiceSkipsPlaceholder() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', ['&a'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => true, + 'expanded' => false, + 'placeholder' => 'Test&Me', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name[]"] + [@multiple="multiple"] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testMultipleChoiceNonRequired() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', ['&a'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'required' => false, + 'multiple' => true, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name[]"] + [@multiple="multiple"] + [ + ./option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + /following-sibling::option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + ] + [count(./option)=2] +' + ); + } + + public function testSingleChoiceExpanded() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="radio"][@name="name"][@id="name_0"][@value="&a"][@checked] + /following-sibling::label[@for="name_0"][.="[trans]Choice&A[/trans]"] + /following-sibling::input[@type="radio"][@name="name"][@id="name_1"][@value="&b"][not(@checked)] + /following-sibling::label[@for="name_1"][.="[trans]Choice&B[/trans]"] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=3] +' + ); + } + + public function testSingleChoiceExpandedWithoutTranslation() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => true, + 'choice_translation_domain' => false, + 'placeholder' => 'Placeholder&Not&Translated', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="radio"][@name="name"][@id="name_0"][@value="&a"][@checked] + /following-sibling::label[@for="name_0"][.="Choice&A"] + /following-sibling::input[@type="radio"][@name="name"][@id="name_1"][@value="&b"][not(@checked)] + /following-sibling::label[@for="name_1"][.="Choice&B"] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=3] +' + ); + } + + public function testSingleChoiceExpandedAttributes() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'choice_attr' => ['Choice&B' => ['class' => 'foo&bar']], + 'multiple' => false, + 'expanded' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="radio"][@name="name"][@id="name_0"][@value="&a"][@checked] + /following-sibling::label[@for="name_0"][.="[trans]Choice&A[/trans]"] + /following-sibling::input[@type="radio"][@name="name"][@id="name_1"][@value="&b"][@class="foo&bar"][not(@checked)] + /following-sibling::label[@for="name_1"][.="[trans]Choice&B[/trans]"] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=3] +' + ); + } + + public function testSingleChoiceExpandedWithPlaceholder() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => true, + 'placeholder' => 'Test&Me', + 'required' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="radio"][@name="name"][@id="name_placeholder"][not(@checked)] + /following-sibling::label[@for="name_placeholder"][.="[trans]Test&Me[/trans]"] + /following-sibling::input[@type="radio"][@name="name"][@id="name_0"][@checked] + /following-sibling::label[@for="name_0"][.="[trans]Choice&A[/trans]"] + /following-sibling::input[@type="radio"][@name="name"][@id="name_1"][not(@checked)] + /following-sibling::label[@for="name_1"][.="[trans]Choice&B[/trans]"] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=4] +' + ); + } + + public function testSingleChoiceExpandedWithPlaceholderWithoutTranslation() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'multiple' => false, + 'expanded' => true, + 'required' => false, + 'choice_translation_domain' => false, + 'placeholder' => 'Placeholder&Not&Translated', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="radio"][@name="name"][@id="name_placeholder"][not(@checked)] + /following-sibling::label[@for="name_placeholder"][.="Placeholder&Not&Translated"] + /following-sibling::input[@type="radio"][@name="name"][@id="name_0"][@checked] + /following-sibling::label[@for="name_0"][.="Choice&A"] + /following-sibling::input[@type="radio"][@name="name"][@id="name_1"][not(@checked)] + /following-sibling::label[@for="name_1"][.="Choice&B"] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=4] +' + ); + } + + public function testSingleChoiceExpandedWithBooleanValue() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', true, [ + 'choices' => ['Choice&A' => '1', 'Choice&B' => '0'], + 'multiple' => false, + 'expanded' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="radio"][@name="name"][@id="name_0"][@checked] + /following-sibling::label[@for="name_0"][.="[trans]Choice&A[/trans]"] + /following-sibling::input[@type="radio"][@name="name"][@id="name_1"][not(@checked)] + /following-sibling::label[@for="name_1"][.="[trans]Choice&B[/trans]"] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=3] +' + ); + } + + public function testMultipleChoiceExpanded() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', ['&a', '&c'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b', 'Choice&C' => '&c'], + 'multiple' => true, + 'expanded' => true, + 'required' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="checkbox"][@name="name[]"][@id="name_0"][@checked][not(@required)] + /following-sibling::label[@for="name_0"][.="[trans]Choice&A[/trans]"] + /following-sibling::input[@type="checkbox"][@name="name[]"][@id="name_1"][not(@checked)][not(@required)] + /following-sibling::label[@for="name_1"][.="[trans]Choice&B[/trans]"] + /following-sibling::input[@type="checkbox"][@name="name[]"][@id="name_2"][@checked][not(@required)] + /following-sibling::label[@for="name_2"][.="[trans]Choice&C[/trans]"] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=4] +' + ); + } + + public function testMultipleChoiceExpandedWithoutTranslation() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', ['&a', '&c'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b', 'Choice&C' => '&c'], + 'multiple' => true, + 'expanded' => true, + 'required' => true, + 'choice_translation_domain' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="checkbox"][@name="name[]"][@id="name_0"][@checked][not(@required)] + /following-sibling::label[@for="name_0"][.="Choice&A"] + /following-sibling::input[@type="checkbox"][@name="name[]"][@id="name_1"][not(@checked)][not(@required)] + /following-sibling::label[@for="name_1"][.="Choice&B"] + /following-sibling::input[@type="checkbox"][@name="name[]"][@id="name_2"][@checked][not(@required)] + /following-sibling::label[@for="name_2"][.="Choice&C"] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=4] +' + ); + } + + public function testMultipleChoiceExpandedAttributes() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', ['&a', '&c'], [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b', 'Choice&C' => '&c'], + 'choice_attr' => ['Choice&B' => ['class' => 'foo&bar']], + 'multiple' => true, + 'expanded' => true, + 'required' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input[@type="checkbox"][@name="name[]"][@id="name_0"][@checked][not(@required)] + /following-sibling::label[@for="name_0"][.="[trans]Choice&A[/trans]"] + /following-sibling::input[@type="checkbox"][@name="name[]"][@id="name_1"][@class="foo&bar"][not(@checked)][not(@required)] + /following-sibling::label[@for="name_1"][.="[trans]Choice&B[/trans]"] + /following-sibling::input[@type="checkbox"][@name="name[]"][@id="name_2"][@checked][not(@required)] + /following-sibling::label[@for="name_2"][.="[trans]Choice&C[/trans]"] + /following-sibling::input[@type="hidden"][@id="name__token"] + ] + [count(./input)=4] +' + ); + } + + public function testCountry() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\CountryType', 'AT'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [./option[@value="AT"][@selected="selected"][.="Austria"]] + [count(./option)>200] +' + ); + } + + public function testCountryWithPlaceholder() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\CountryType', 'AT', [ + 'placeholder' => 'Select&Country', + 'required' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Select&Country[/trans]"]] + [./option[@value="AT"][@selected="selected"][.="Austria"]] + [count(./option)>201] +' + ); + } + + public function testDateTime() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateTimeType', date('Y').'-02-03 04:05:06', [ + 'input' => 'string', + 'with_seconds' => false, + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div + [@id="name_date"] + [ + ./select + [@id="name_date_month"] + [./option[@value="2"][@selected="selected"]] + /following-sibling::select + [@id="name_date_day"] + [./option[@value="3"][@selected="selected"]] + /following-sibling::select + [@id="name_date_year"] + [./option[@value="'.date('Y').'"][@selected="selected"]] + ] + /following-sibling::div + [@id="name_time"] + [ + ./select + [@id="name_time_hour"] + [./option[@value="4"][@selected="selected"]] + /following-sibling::select + [@id="name_time_minute"] + [./option[@value="5"][@selected="selected"]] + ] + ] + [count(.//select)=5] +' + ); + } + + public function testDateTimeWithPlaceholderGlobal() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateTimeType', null, [ + 'input' => 'string', + 'placeholder' => 'Change&Me', + 'required' => false, + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div + [@id="name_date"] + [ + ./select + [@id="name_date_month"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + /following-sibling::select + [@id="name_date_day"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + /following-sibling::select + [@id="name_date_year"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + ] + /following-sibling::div + [@id="name_time"] + [ + ./select + [@id="name_time_hour"] + [./option[@value=""][.="[trans]Change&Me[/trans]"]] + /following-sibling::select + [@id="name_time_minute"] + [./option[@value=""][.="[trans]Change&Me[/trans]"]] + ] + ] + [count(.//select)=5] +' + ); + } + + public function testDateTimeWithHourAndMinute() + { + $data = ['year' => date('Y'), 'month' => '2', 'day' => '3', 'hour' => '4', 'minute' => '5']; + + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateTimeType', $data, [ + 'input' => 'array', + 'required' => false, + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div + [@id="name_date"] + [ + ./select + [@id="name_date_month"] + [./option[@value="2"][@selected="selected"]] + /following-sibling::select + [@id="name_date_day"] + [./option[@value="3"][@selected="selected"]] + /following-sibling::select + [@id="name_date_year"] + [./option[@value="'.date('Y').'"][@selected="selected"]] + ] + /following-sibling::div + [@id="name_time"] + [ + ./select + [@id="name_time_hour"] + [./option[@value="4"][@selected="selected"]] + /following-sibling::select + [@id="name_time_minute"] + [./option[@value="5"][@selected="selected"]] + ] + ] + [count(.//select)=5] +' + ); + } + + public function testDateTimeWithSeconds() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateTimeType', date('Y').'-02-03 04:05:06', [ + 'input' => 'string', + 'with_seconds' => true, + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./div + [@id="name_date"] + [ + ./select + [@id="name_date_month"] + [./option[@value="2"][@selected="selected"]] + /following-sibling::select + [@id="name_date_day"] + [./option[@value="3"][@selected="selected"]] + /following-sibling::select + [@id="name_date_year"] + [./option[@value="'.date('Y').'"][@selected="selected"]] + ] + /following-sibling::div + [@id="name_time"] + [ + ./select + [@id="name_time_hour"] + [./option[@value="4"][@selected="selected"]] + /following-sibling::select + [@id="name_time_minute"] + [./option[@value="5"][@selected="selected"]] + /following-sibling::select + [@id="name_time_second"] + [./option[@value="6"][@selected="selected"]] + ] + ] + [count(.//select)=6] +' + ); + } + + public function testDateTimeSingleText() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateTimeType', '2011-02-03 04:05:06', [ + 'input' => 'string', + 'date_widget' => 'single_text', + 'time_widget' => 'single_text', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input + [@type="date"] + [@id="name_date"] + [@name="name[date]"] + [@value="2011-02-03"] + /following-sibling::input + [@type="time"] + [@id="name_time"] + [@name="name[time]"] + [@value="04:05"] + ] +' + ); + } + + public function testDateTimeWithWidgetSingleText() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateTimeType', '2011-02-03 04:05:06', [ + 'input' => 'string', + 'widget' => 'single_text', + 'model_timezone' => 'UTC', + 'view_timezone' => 'UTC', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="datetime-local"] + [@name="name"] + [@value="2011-02-03T04:05"] +' + ); + } + + public function testDateChoice() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateType', date('Y').'-02-03', [ + 'input' => 'string', + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./select + [@id="name_month"] + [./option[@value="2"][@selected="selected"]] + /following-sibling::select + [@id="name_day"] + [./option[@value="3"][@selected="selected"]] + /following-sibling::select + [@id="name_year"] + [./option[@value="'.date('Y').'"][@selected="selected"]] + ] + [count(./select)=3] +' + ); + } + + public function testDateChoiceWithPlaceholderGlobal() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateType', null, [ + 'input' => 'string', + 'widget' => 'choice', + 'placeholder' => 'Change&Me', + 'required' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./select + [@id="name_month"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + /following-sibling::select + [@id="name_day"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + /following-sibling::select + [@id="name_year"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + ] + [count(./select)=3] +' + ); + } + + public function testDateChoiceWithPlaceholderOnYear() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateType', null, [ + 'input' => 'string', + 'widget' => 'choice', + 'required' => false, + 'placeholder' => ['year' => 'Change&Me'], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./select + [@id="name_month"] + [./option[@value="1"]] + /following-sibling::select + [@id="name_day"] + [./option[@value="1"]] + /following-sibling::select + [@id="name_year"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + ] + [count(./select)=3] +' + ); + } + + public function testDateText() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateType', '2011-02-03', [ + 'input' => 'string', + 'widget' => 'text', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input + [@id="name_month"] + [@type="text"] + [@value="2"] + /following-sibling::input + [@id="name_day"] + [@type="text"] + [@value="3"] + /following-sibling::input + [@id="name_year"] + [@type="text"] + [@value="2011"] + ] + [count(./input)=3] +' + ); + } + + public function testDateSingleText() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\DateType', '2011-02-03', [ + 'input' => 'string', + 'widget' => 'single_text', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="date"] + [@name="name"] + [@value="2011-02-03"] +' + ); + } + + public function testDateErrorBubbling() + { + $form = $this->factory->createNamedBuilder('form', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('date', 'Symfony\Component\Form\Extension\Core\Type\DateType', ['widget' => 'choice']) + ->getForm(); + $form->get('date')->addError(new FormError('[trans]Error![/trans]')); + $view = $form->createView(); + + $this->assertEmpty($this->renderErrors($view)); + $this->assertNotEmpty($this->renderErrors($view['date'])); + } + + public function testBirthDay() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\BirthdayType', '2000-02-03', [ + 'input' => 'string', + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./select + [@id="name_month"] + [./option[@value="2"][@selected="selected"]] + /following-sibling::select + [@id="name_day"] + [./option[@value="3"][@selected="selected"]] + /following-sibling::select + [@id="name_year"] + [./option[@value="2000"][@selected="selected"]] + ] + [count(./select)=3] +' + ); + } + + public function testBirthDayWithPlaceholder() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\BirthdayType', '1950-01-01', [ + 'input' => 'string', + 'placeholder' => '', + 'required' => false, + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./select + [@id="name_month"] + [./option[@value=""][not(@selected)][not(@disabled)][.=""]] + [./option[@value="1"][@selected="selected"]] + /following-sibling::select + [@id="name_day"] + [./option[@value=""][not(@selected)][not(@disabled)][.=""]] + [./option[@value="1"][@selected="selected"]] + /following-sibling::select + [@id="name_year"] + [./option[@value=""][not(@selected)][not(@disabled)][.=""]] + [./option[@value="1950"][@selected="selected"]] + ] + [count(./select)=3] +' + ); + } + + public function testEmail() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\EmailType', 'foo&bar'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="email"] + [@name="name"] + [@value="foo&bar"] + [not(@maxlength)] +' + ); + } + + public function testEmailWithMaxLength() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\EmailType', 'foo&bar', [ + 'attr' => ['maxlength' => 123], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="email"] + [@name="name"] + [@value="foo&bar"] + [@maxlength="123"] +' + ); + } + + public function testFile() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\FileType'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="file"] +' + ); + } + + public function testHidden() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\HiddenType', 'foo&bar'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="hidden"] + [@name="name"] + [@value="foo&bar"] +' + ); + } + + public function testDisabled() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'disabled' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="text"] + [@name="name"] + [@disabled="disabled"] +' + ); + } + + public function testInteger() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\IntegerType', 123); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="number"] + [@name="name"] + [@value="123"] +' + ); + } + + public function testIntegerTypeWithGroupingRendersAsTextInput() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\IntegerType', 123, [ + 'grouping' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="text"] + [@name="name"] + [@value="123"] +' + ); + } + + public function testLanguage() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\LanguageType', 'de'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [./option[@value="de"][@selected="selected"][.="German"]] + [count(./option)>200] +' + ); + } + + public function testLocale() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\LocaleType', 'de_AT'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [./option[@value="de_AT"][@selected="selected"][.="German (Austria)"]] + [count(./option)>200] +' + ); + } + + public function testMoney() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\MoneyType', 1234.56, [ + 'currency' => 'EUR', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="text"] + [@name="name"] + [@value="1234.56"] + [contains(.., "€")] +' + ); + } + + public function testNumber() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\NumberType', 1234.56); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="text"] + [@name="name"] + [@value="1234.56"] +' + ); + } + + public function testRenderNumberWithHtml5NumberType() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\NumberType', 1234.56, [ + 'html5' => true, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="number"] + [@step="any"] + [@name="name"] + [@value="1234.56"] +' + ); + } + + public function testRenderNumberWithHtml5NumberTypeAndStepAttribute() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\NumberType', 1234.56, [ + 'html5' => true, + 'attr' => ['step' => '0.1'], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="number"] + [@step="0.1"] + [@name="name"] + [@value="1234.56"] +' + ); + } + + public function testPassword() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\PasswordType', 'foo&bar'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="password"] + [@name="name"] +' + ); + } + + public function testPasswordSubmittedWithNotAlwaysEmpty() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\PasswordType', null, [ + 'always_empty' => false, + ]); + $form->submit('foo&bar'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="password"] + [@name="name"] + [@value="foo&bar"] +' + ); + } + + public function testPasswordWithMaxLength() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\PasswordType', 'foo&bar', [ + 'attr' => ['maxlength' => 123], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="password"] + [@name="name"] + [@maxlength="123"] +' + ); + } + + public function testPercent() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\PercentType', 0.1, ['rounding_mode' => \NumberFormatter::ROUND_CEILING]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="text"] + [@name="name"] + [@value="10"] + [contains(.., "%")] +' + ); + } + + public function testPercentNoSymbol() + { + $form = $this->factory->createNamed('name', PercentType::class, 0.1, ['symbol' => false, 'rounding_mode' => \NumberFormatter::ROUND_CEILING]); + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="text"] + [@name="name"] + [@value="10"] + [not(contains(.., "%"))] +' + ); + } + + public function testPercentCustomSymbol() + { + $form = $this->factory->createNamed('name', PercentType::class, 0.1, ['symbol' => '‱', 'rounding_mode' => \NumberFormatter::ROUND_CEILING]); + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="text"] + [@name="name"] + [@value="10"] + [contains(.., "‱")] +' + ); + } + + public function testCheckedRadio() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RadioType', true); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="radio"] + [@name="name"] + [@checked="checked"] + [@value="1"] +' + ); + } + + public function testUncheckedRadio() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RadioType', false); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="radio"] + [@name="name"] + [not(@checked)] +' + ); + } + + public function testRadioWithValue() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RadioType', false, [ + 'value' => 'foo&bar', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="radio"] + [@name="name"] + [@value="foo&bar"] +' + ); + } + + public function testRange() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RangeType', 42, ['attr' => ['min' => 5]]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="range"] + [@name="name"] + [@value="42"] + [@min="5"] +' + ); + } + + public function testRangeWithMinMaxValues() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RangeType', 42, ['attr' => ['min' => 5, 'max' => 57]]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="range"] + [@name="name"] + [@value="42"] + [@min="5"] + [@max="57"] +' + ); + } + + public function testTextarea() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextareaType', 'foo&bar', [ + 'attr' => ['pattern' => 'foo'], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/textarea + [@name="name"] + [not(@pattern)] + [.="foo&bar"] +' + ); + } + + public function testText() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', 'foo&bar'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="text"] + [@name="name"] + [@value="foo&bar"] + [not(@maxlength)] +' + ); + } + + public function testTextWithMaxLength() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', 'foo&bar', [ + 'attr' => ['maxlength' => 123], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="text"] + [@name="name"] + [@value="foo&bar"] + [@maxlength="123"] +' + ); + } + + public function testSearch() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\SearchType', 'foo&bar'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="search"] + [@name="name"] + [@value="foo&bar"] + [not(@maxlength)] +' + ); + } + + public function testTime() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TimeType', '04:05:06', [ + 'input' => 'string', + 'with_seconds' => false, + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./select + [@id="name_hour"] + [not(@size)] + [./option[@value="4"][@selected="selected"]] + /following-sibling::select + [@id="name_minute"] + [not(@size)] + [./option[@value="5"][@selected="selected"]] + ] + [count(./select)=2] +' + ); + } + + public function testTimeWithSeconds() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TimeType', '04:05:06', [ + 'input' => 'string', + 'with_seconds' => true, + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./select + [@id="name_hour"] + [not(@size)] + [./option[@value="4"][@selected="selected"]] + [count(./option)>23] + /following-sibling::select + [@id="name_minute"] + [not(@size)] + [./option[@value="5"][@selected="selected"]] + [count(./option)>59] + /following-sibling::select + [@id="name_second"] + [not(@size)] + [./option[@value="6"][@selected="selected"]] + [count(./option)>59] + ] + [count(./select)=3] +' + ); + } + + public function testTimeText() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TimeType', '04:05:06', [ + 'input' => 'string', + 'widget' => 'text', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./input + [@type="text"] + [@id="name_hour"] + [@name="name[hour]"] + [@value="04"] + [@size="1"] + [@required="required"] + /following-sibling::input + [@type="text"] + [@id="name_minute"] + [@name="name[minute]"] + [@value="05"] + [@size="1"] + [@required="required"] + ] + [count(./input)=2] +' + ); + } + + public function testTimeSingleText() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TimeType', '04:05:06', [ + 'input' => 'string', + 'widget' => 'single_text', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="time"] + [@name="name"] + [@value="04:05"] + [not(@size)] +' + ); + } + + public function testTimeWithPlaceholderGlobal() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TimeType', null, [ + 'input' => 'string', + 'placeholder' => 'Change&Me', + 'required' => false, + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./select + [@id="name_hour"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + [count(./option)>24] + /following-sibling::select + [@id="name_minute"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + [count(./option)>60] + ] + [count(./select)=2] +' + ); + } + + public function testTimeWithPlaceholderOnYear() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TimeType', null, [ + 'input' => 'string', + 'required' => false, + 'placeholder' => ['hour' => 'Change&Me'], + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/div + [ + ./select + [@id="name_hour"] + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Change&Me[/trans]"]] + [count(./option)>24] + /following-sibling::select + [@id="name_minute"] + [./option[@value="1"]] + [count(./option)>59] + ] + [count(./select)=2] +' + ); + } + + public function testTimeErrorBubbling() + { + $form = $this->factory->createNamedBuilder('form', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('time', 'Symfony\Component\Form\Extension\Core\Type\TimeType', ['widget' => 'choice']) + ->getForm(); + $form->get('time')->addError(new FormError('[trans]Error![/trans]')); + $view = $form->createView(); + + $this->assertEmpty($this->renderErrors($view)); + $this->assertNotEmpty($this->renderErrors($view['time'])); + } + + public function testTimezone() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TimezoneType', 'Europe/Vienna'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [@name="name"] + [not(@required)] + [./option[@value="Europe/Vienna"][@selected="selected"][.="Europe / Vienna"]] + [count(./option)>200] +' + ); + } + + public function testTimezoneWithPlaceholder() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TimezoneType', null, [ + 'placeholder' => 'Select&Timezone', + 'required' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/select + [./option[@value=""][not(@selected)][not(@disabled)][.="[trans]Select&Timezone[/trans]"]] + [count(./option)>201] +' + ); + } + + public function testUrlWithDefaultProtocol() + { + $url = 'http://www.example.com?foo1=bar1&foo2=bar2'; + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\UrlType', $url, ['default_protocol' => 'http']); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="text"] + [@name="name"] + [@value="http://www.example.com?foo1=bar1&foo2=bar2"] + [@inputmode="url"] +' + ); + } + + public function testUrlWithoutDefaultProtocol() + { + $url = 'http://www.example.com?foo1=bar1&foo2=bar2'; + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\UrlType', $url, ['default_protocol' => null]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="url"] + [@name="name"] + [@value="http://www.example.com?foo1=bar1&foo2=bar2"] +' + ); + } + + public function testCollectionPrototype() + { + $form = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType', ['items' => ['one', 'two', 'three']]) + ->add('items', 'Symfony\Component\Form\Extension\Core\Type\CollectionType', ['allow_add' => true]) + ->getForm() + ->createView(); + + $html = $this->renderWidget($form); + + $this->assertMatchesXpath($html, + '//div[@id="name_items"][@data-prototype] + | + //table[@id="name_items"][@data-prototype]' + ); + } + + public function testEmptyRootFormName() + { + $form = $this->factory->createNamedBuilder('', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('child', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm(); + + $this->assertMatchesXpath($this->renderWidget($form->createView()), + '//input[@type="hidden"][@id="_token"][@name="_token"] + | + //input[@type="text"][@id="child"][@name="child"]', 2); + } + + public function testButton() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ButtonType'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/button[@type="button"][@name="name"][.="[trans]Name[/trans]"]' + ); + } + + public function testButtonLabelIsEmpty() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ButtonType'); + + $this->assertSame('', $this->renderLabel($form->createView())); + } + + public function testButtonlabelWithoutTranslation() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ButtonType', null, [ + 'translation_domain' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/button[@type="button"][@name="name"][.="Name"]' + ); + } + + public function testSubmit() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\SubmitType'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/button[@type="submit"][@name="name"]' + ); + } + + public function testReset() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ResetType'); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/button[@type="reset"][@name="name"]' + ); + } + + public function testStartTag() + { + $form = $this->factory->create('Symfony\Component\Form\Extension\Core\Type\FormType', null, [ + 'method' => 'get', + 'action' => 'http://example.com/directory', + ]); + + $html = $this->renderStart($form->createView()); + + $this->assertSame('
', $html); + } + + public function testStartTagForPutRequest() + { + $form = $this->factory->create('Symfony\Component\Form\Extension\Core\Type\FormType', null, [ + 'method' => 'put', + 'action' => 'http://example.com/directory', + ]); + + $html = $this->renderStart($form->createView()); + + $this->assertMatchesXpath($html.'
', + '/form + [./input[@type="hidden"][@name="_method"][@value="PUT"]] + [@method="post"] + [@action="http://example.com/directory"]' + ); + } + + public function testStartTagWithOverriddenVars() + { + $form = $this->factory->create('Symfony\Component\Form\Extension\Core\Type\FormType', null, [ + 'method' => 'put', + 'action' => 'http://example.com/directory', + ]); + + $html = $this->renderStart($form->createView(), [ + 'method' => 'post', + 'action' => 'http://foo.com/directory', + ]); + + $this->assertSame('
', $html); + } + + public function testStartTagForMultipartForm() + { + $form = $this->factory->createBuilder('Symfony\Component\Form\Extension\Core\Type\FormType', null, [ + 'method' => 'get', + 'action' => 'http://example.com/directory', + ]) + ->add('file', 'Symfony\Component\Form\Extension\Core\Type\FileType') + ->getForm(); + + $html = $this->renderStart($form->createView()); + + $this->assertSame('', $html); + } + + public function testStartTagWithExtraAttributes() + { + $form = $this->factory->create('Symfony\Component\Form\Extension\Core\Type\FormType', null, [ + 'method' => 'get', + 'action' => 'http://example.com/directory', + ]); + + $html = $this->renderStart($form->createView(), [ + 'attr' => ['class' => 'foobar'], + ]); + + $this->assertSame('', $html); + } + + public function testWidgetAttributes() + { + $form = $this->factory->createNamed('text', 'Symfony\Component\Form\Extension\Core\Type\TextType', 'value', [ + 'required' => true, + 'disabled' => true, + 'attr' => ['readonly' => true, 'maxlength' => 10, 'pattern' => '\d+', 'class' => 'foobar', 'data-foo' => 'bar'], + ]); + + $html = $this->renderWidget($form->createView()); + + // compare plain HTML to check the whitespace + $this->assertSame('', $html); + } + + public function testWidgetAttributeNameRepeatedIfTrue() + { + $form = $this->factory->createNamed('text', 'Symfony\Component\Form\Extension\Core\Type\TextType', 'value', [ + 'attr' => ['foo' => true], + ]); + + $html = $this->renderWidget($form->createView()); + + // foo="foo" + $this->assertSame('', $html); + } + + public function testWidgetAttributeHiddenIfFalse() + { + $form = $this->factory->createNamed('text', 'Symfony\Component\Form\Extension\Core\Type\TextType', 'value', [ + 'attr' => ['foo' => false], + ]); + + $html = $this->renderWidget($form->createView()); + + $this->assertStringNotContainsString('foo="', $html); + } + + public function testButtonAttributes() + { + $form = $this->factory->createNamed('button', 'Symfony\Component\Form\Extension\Core\Type\ButtonType', null, [ + 'disabled' => true, + 'attr' => ['class' => 'foobar', 'data-foo' => 'bar'], + ]); + + $html = $this->renderWidget($form->createView()); + + // compare plain HTML to check the whitespace + $this->assertSame('', $html); + } + + public function testButtonAttributeNameRepeatedIfTrue() + { + $form = $this->factory->createNamed('button', 'Symfony\Component\Form\Extension\Core\Type\ButtonType', null, [ + 'attr' => ['foo' => true], + ]); + + $html = $this->renderWidget($form->createView()); + + // foo="foo" + $this->assertSame('', $html); + } + + public function testButtonAttributeHiddenIfFalse() + { + $form = $this->factory->createNamed('button', 'Symfony\Component\Form\Extension\Core\Type\ButtonType', null, [ + 'attr' => ['foo' => false], + ]); + + $html = $this->renderWidget($form->createView()); + + $this->assertStringNotContainsString('foo="', $html); + } + + public function testTextareaWithWhitespaceOnlyContentRetainsValue() + { + $form = $this->factory->createNamed('textarea', 'Symfony\Component\Form\Extension\Core\Type\TextareaType', ' '); + + $html = $this->renderWidget($form->createView()); + + $this->assertStringContainsString('> ', $html); + } + + public function testTextareaWithWhitespaceOnlyContentRetainsValueWhenRenderingForm() + { + $form = $this->factory->createBuilder('Symfony\Component\Form\Extension\Core\Type\FormType', ['textarea' => ' ']) + ->add('textarea', 'Symfony\Component\Form\Extension\Core\Type\TextareaType') + ->getForm(); + + $html = $this->renderForm($form->createView()); + + $this->assertStringContainsString('> ', $html); + } + + public function testWidgetContainerAttributeHiddenIfFalse() + { + $form = $this->factory->createNamed('form', 'Symfony\Component\Form\Extension\Core\Type\FormType', null, [ + 'attr' => ['foo' => false], + ]); + + $html = $this->renderWidget($form->createView()); + + // no foo + $this->assertStringNotContainsString('foo="', $html); + } + + public function testTranslatedAttributes() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('firstName', 'Symfony\Component\Form\Extension\Core\Type\TextType', ['attr' => ['title' => 'Foo']]) + ->add('lastName', 'Symfony\Component\Form\Extension\Core\Type\TextType', ['attr' => ['placeholder' => 'Bar']]) + ->getForm() + ->createView(); + + $html = $this->renderForm($view); + + $this->assertMatchesXpath($html, '/form//input[@title="[trans]Foo[/trans]"]'); + $this->assertMatchesXpath($html, '/form//input[@placeholder="[trans]Bar[/trans]"]'); + } + + public function testAttributesNotTranslatedWhenTranslationDomainIsFalse() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType', null, [ + 'translation_domain' => false, + ]) + ->add('firstName', 'Symfony\Component\Form\Extension\Core\Type\TextType', ['attr' => ['title' => 'Foo']]) + ->add('lastName', 'Symfony\Component\Form\Extension\Core\Type\TextType', ['attr' => ['placeholder' => 'Bar']]) + ->getForm() + ->createView(); + + $html = $this->renderForm($view); + + $this->assertMatchesXpath($html, '/form//input[@title="Foo"]'); + $this->assertMatchesXpath($html, '/form//input[@placeholder="Bar"]'); + } + + public function testTel() + { + $tel = '0102030405'; + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TelType', $tel); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="tel"] + [@name="name"] + [@value="0102030405"] +' + ); + } + + public function testColor() + { + $color = '#0000ff'; + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ColorType', $color); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/input + [@type="color"] + [@name="name"] + [@value="#0000ff"] +' + ); + } + + public function testLabelWithTranslationParameters() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $html = $this->renderLabel($form->createView(), 'Address is %address%', [ + 'label_translation_parameters' => [ + '%address%' => 'Paris, rue de la Paix', + ], + ]); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [.="[trans]Address is Paris, rue de la Paix[/trans]"] +' + ); + } + + public function testHelpWithTranslationParameters() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'help' => 'for company %company%', + 'help_translation_parameters' => [ + '%company%' => 'ACME Ltd.', + ], + ]); + $html = $this->renderHelp($form->createView()); + + $this->assertMatchesXpath($html, + '/* + [@id="name_help"] + [.="[trans]for company ACME Ltd.[/trans]"] +' + ); + } + + public function testLabelWithTranslatableMessage() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'label' => new TranslatableMessage('foo'), + ]); + $html = $this->renderLabel($form->createView()); + + $this->assertMatchesXpath($html, + '/label + [@for="name"] + [.="[trans]foo[/trans]"] +' + ); + } + + public function testHelpWithTranslatableMessage() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'help' => new TranslatableMessage('foo'), + ]); + $html = $this->renderHelp($form->createView()); + + $this->assertMatchesXpath($html, + '/* + [@id="name_help"] + [.="[trans]foo[/trans]"] +' + ); + } + + public function testHelpWithTranslatableInterface() + { + $message = new class implements TranslatableInterface { + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + return $translator->trans('foo'); + } + }; + + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'help' => $message, + ]); + $html = $this->renderHelp($form->createView()); + + $this->assertMatchesXpath($html, + '/* + [@id="name_help"] + [.="[trans]foo[/trans]"] +' + ); + } + + public function testAttributesWithTranslationParameters() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'attr' => [ + 'title' => 'Message to %company%', + 'placeholder' => 'Enter a message to %company%', + ], + 'attr_translation_parameters' => [ + '%company%' => 'ACME Ltd.', + ], + ]); + $html = $this->renderWidget($form->createView()); + + $this->assertMatchesXpath($html, + '/input + [@title="[trans]Message to ACME Ltd.[/trans]"] + [@placeholder="[trans]Enter a message to ACME Ltd.[/trans]"] +' + ); + } + + public function testButtonWithTranslationParameters() + { + $form = $this->factory->createNamedBuilder('myform') + ->add('mybutton', 'Symfony\Component\Form\Extension\Core\Type\ButtonType', [ + 'label' => 'Submit to %company%', + 'label_translation_parameters' => [ + '%company%' => 'ACME Ltd.', + ], + ]) + ->getForm(); + $view = $form->get('mybutton')->createView(); + $html = $this->renderWidget($view, ['label_format' => 'form.%name%']); + + $this->assertMatchesXpath($html, + '/button + [.="[trans]Submit to ACME Ltd.[/trans]"] +' + ); + } + + /** + * @dataProvider submitFormNoValidateProvider + */ + public function testSubmitFormNoValidate(bool $validate) + { + $form = $this->factory->create(SubmitType::class, null, [ + 'validate' => $validate, + ]); + + $html = $this->renderWidget($form->createView()); + + $xpath = '/button + [@type="submit"] + '; + + if (!$validate) { + $xpath .= '[@formnovalidate="formnovalidate"]'; + } else { + $xpath .= '[not(@formnovalidate="formnovalidate")]'; + } + + $this->assertMatchesXpath($html, $xpath); + } + + public static function submitFormNoValidateProvider() + { + return [ + [false], + [true], + ]; + } + + public function testWeekSingleText() + { + $form = $this->factory->createNamed('holidays', 'Symfony\Component\Form\Extension\Core\Type\WeekType', '1970-W01', [ + 'input' => 'string', + 'widget' => 'single_text', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/input + [@type="week"] + [@name="holidays"] + [@class="my&class"] + [@value="1970-W01"] +' + ); + } + + public function testWeekSingleTextNoHtml5() + { + $form = $this->factory->createNamed('holidays', 'Symfony\Component\Form\Extension\Core\Type\WeekType', '1970-W01', [ + 'input' => 'string', + 'widget' => 'single_text', + 'html5' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/input + [@type="text"] + [@name="holidays"] + [@class="my&class"] + [@value="1970-W01"] +' + ); + } + + public function testWeekChoices() + { + $data = ['year' => (int) date('Y'), 'week' => 1]; + + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\WeekType', $data, [ + 'input' => 'array', + 'widget' => 'choice', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./select + [@id="name_year"] + [./option[@value="'.$data['year'].'"][@selected="selected"]] + /following-sibling::select + [@id="name_week"] + [./option[@value="'.$data['week'].'"][@selected="selected"]] + ] + [count(.//select)=2]' + ); + } + + public function testWeekText() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\WeekType', '2000-W01', [ + 'input' => 'string', + 'widget' => 'text', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['attr' => ['class' => 'my&class']], + '/div + [@class="my&class"] + [ + ./input + [@id="name_year"] + [@type="number"] + [@value="2000"] + /following-sibling::input + [@id="name_week"] + [@type="number"] + [@value="1"] + ] + [count(./input)=2]' + ); + } +} diff --git a/Tests/Extension/AbstractTableLayoutTestCase.php b/Tests/Extension/AbstractTableLayoutTestCase.php new file mode 100644 index 00000000..c3cdb08e --- /dev/null +++ b/Tests/Extension/AbstractTableLayoutTestCase.php @@ -0,0 +1,536 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Extension; + +use Symfony\Component\Form\FormError; +use Symfony\Component\Security\Csrf\CsrfToken; + +abstract class AbstractTableLayoutTestCase extends AbstractLayoutTestCase +{ + public function testRow() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + $form->addError(new FormError('[trans]Error![/trans]')); + $view = $form->createView(); + $html = $this->renderRow($view); + + $this->assertMatchesXpath($html, + '/tr + [ + ./td + [./label[@for="name"]] + /following-sibling::td + [ + ./ul + [./li[.="[trans]Error![/trans]"]] + [count(./li)=1] + /following-sibling::input[@id="name"] + ] + ] +' + ); + } + + public function testLabelIsNotRenderedWhenSetToFalse() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\TextType', null, [ + 'label' => false, + ]); + $html = $this->renderRow($form->createView()); + + $this->assertMatchesXpath($html, + '/tr + [ + ./td + [count(//label)=0] + /following-sibling::td + [./input[@id="name"]] + ] +' + ); + } + + public function testRepeatedRow() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType'); + $html = $this->renderRow($form->createView()); + + $this->assertMatchesXpath($html, + '/tr + [ + ./td + [./label[@for="name_first"]] + /following-sibling::td + [./input[@id="name_first"]] + ] +/following-sibling::tr + [ + ./td + [./label[@for="name_second"]] + /following-sibling::td + [./input[@id="name_second"]] + ] +/following-sibling::tr[@style="display: none"] + [./td[@colspan="2"]/input + [@type="hidden"] + [@id="name__token"] + ] + [count(../tr)=3] +' + ); + } + + public function testRepeatedRowWithErrors() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType'); + $form->addError(new FormError('[trans]Error![/trans]')); + $view = $form->createView(); + $html = $this->renderRow($view); + + // The errors of the form are not rendered by intention! + // In practice, repeated fields cannot have errors as all errors + // on them are mapped to the first child. + // (see RepeatedTypeValidatorExtension) + + $this->assertMatchesXpath($html, + '/tr + [ + ./td + [./label[@for="name_first"]] + /following-sibling::td + [./input[@id="name_first"]] + ] +/following-sibling::tr + [ + ./td + [./label[@for="name_second"]] + /following-sibling::td + [./input[@id="name_second"]] + ] +/following-sibling::tr[@style="display: none"] + [./td[@colspan="2"]/input + [@type="hidden"] + [@id="name__token"] + ] + [count(../tr)=3] +' + ); + } + + public function testButtonRow() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ButtonType'); + $view = $form->createView(); + $html = $this->renderRow($view); + + $this->assertMatchesXpath($html, + '/tr + [ + ./td + [.=""] + /following-sibling::td + [./button[@type="button"][@name="name"]] + ] + [count(//label)=0] +' + ); + } + + public function testRest() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('field1', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('field2', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType') + ->add('field3', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('field4', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm() + ->createView(); + + // Render field2 row -> does not implicitly call renderWidget because + // it is a repeated field! + $this->renderRow($view['field2']); + + // Render field3 widget + $this->renderWidget($view['field3']); + + // Rest should only contain field1 and field4 + $html = $this->renderRest($view); + + $this->assertMatchesXpath($html, + '/tr + [ + ./td + [./label[@for="name_field1"]] + /following-sibling::td + [./input[@id="name_field1"]] + ] +/following-sibling::tr + [ + ./td + [./label[@for="name_field4"]] + /following-sibling::td + [./input[@id="name_field4"]] + ] + [count(../tr)=3] + [count(..//label)=2] + [count(..//input)=3] +/following-sibling::tr[@style="display: none"] + [./td[@colspan="2"]/input + [@type="hidden"] + [@id="name__token"] + ] +' + ); + } + + public function testCollection() + { + $form = $this->factory->createNamed('names', 'Symfony\Component\Form\Extension\Core\Type\CollectionType', ['a', 'b'], [ + 'entry_type' => 'Symfony\Component\Form\Extension\Core\Type\TextType', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/table + [ + ./tr[./td/input[@type="text"][@value="a"]] + /following-sibling::tr[./td/input[@type="text"][@value="b"]] + /following-sibling::tr[@style="display: none"][./td[@colspan="2"]/input[@type="hidden"][@id="names__token"]] + ] + [count(./tr[./td/input])=3] +' + ); + } + + public function testEmptyCollection() + { + $form = $this->factory->createNamed('names', 'Symfony\Component\Form\Extension\Core\Type\CollectionType', [], [ + 'entry_type' => 'Symfony\Component\Form\Extension\Core\Type\TextType', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/table + [./tr[@style="display: none"][./td[@colspan="2"]/input[@type="hidden"][@id="names__token"]]] + [count(./tr[./td/input])=1] +' + ); + } + + public function testForm() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->setMethod('PUT') + ->setAction('http://example.com') + ->add('firstName', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('lastName', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm() + ->createView(); + + $html = $this->renderForm($view, [ + 'id' => 'my&id', + 'attr' => ['class' => 'my&class'], + ]); + + $this->assertMatchesXpath($html, + '/form + [ + ./input[@type="hidden"][@name="_method"][@value="PUT"] + /following-sibling::table + [ + ./tr + [ + ./td + [./label[@for="name_firstName"]] + /following-sibling::td + [./input[@id="name_firstName"]] + ] + /following-sibling::tr + [ + ./td + [./label[@for="name_lastName"]] + /following-sibling::td + [./input[@id="name_lastName"]] + ] + /following-sibling::tr[@style="display: none"] + [./td[@colspan="2"]/input + [@type="hidden"] + [@id="name__token"] + ] + ] + [count(.//input)=3] + [@id="my&id"] + [@class="my&class"] + ] + [@method="post"] + [@action="http://example.com"] + [@class="my&class"] +' + ); + } + + public function testFormWidget() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('firstName', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('lastName', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm() + ->createView(); + + $this->assertWidgetMatchesXpath($view, [], + '/table + [ + ./tr + [ + ./td + [./label[@for="name_firstName"]] + /following-sibling::td + [./input[@id="name_firstName"]] + ] + /following-sibling::tr + [ + ./td + [./label[@for="name_lastName"]] + /following-sibling::td + [./input[@id="name_lastName"]] + ] + /following-sibling::tr[@style="display: none"] + [./td[@colspan="2"]/input + [@type="hidden"] + [@id="name__token"] + ] + ] + [count(.//input)=3] +' + ); + } + + // https://github.com/symfony/symfony/issues/2308 + public function testNestedFormError() + { + $form = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add($this->factory + ->createNamedBuilder('child', 'Symfony\Component\Form\Extension\Core\Type\FormType', null, ['error_bubbling' => false]) + ->add('grandChild', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ) + ->getForm(); + + $form->get('child')->addError(new FormError('[trans]Error![/trans]')); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/table + [ + ./tr/td/ul[./li[.="[trans]Error![/trans]"]] + /following-sibling::table[@id="name_child"] + ] + [count(.//li[.="[trans]Error![/trans]"])=1] +' + ); + } + + public function testCsrf() + { + $this->csrfTokenManager->expects($this->any()) + ->method('getToken') + ->willReturn(new CsrfToken('token_id', 'foo&bar')); + + $form = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add($this->factory + // No CSRF protection on nested forms + ->createNamedBuilder('child', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add($this->factory->createNamedBuilder('grandchild', 'Symfony\Component\Form\Extension\Core\Type\TextType')) + ) + ->getForm(); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/table + [ + ./tr[@style="display: none"] + [./td[@colspan="2"]/input + [@type="hidden"] + [@id="name__token"] + ] + ] + [count(.//input[@type="hidden"])=1] +' + ); + } + + public function testRepeated() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType', 'foobar', [ + 'type' => 'Symfony\Component\Form\Extension\Core\Type\TextType', + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/table + [ + ./tr + [ + ./td + [./label[@for="name_first"]] + /following-sibling::td + [./input[@type="text"][@id="name_first"]] + ] + /following-sibling::tr + [ + ./td + [./label[@for="name_second"]] + /following-sibling::td + [./input[@type="text"][@id="name_second"]] + ] + /following-sibling::tr[@style="display: none"] + [./td[@colspan="2"]/input + [@type="hidden"] + [@id="name__token"] + ] + ] + [count(.//input)=3] +' + ); + } + + public function testRepeatedWithCustomOptions() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType', 'foobar', [ + 'type' => 'Symfony\Component\Form\Extension\Core\Type\PasswordType', + 'first_options' => ['label' => 'Test', 'required' => false], + 'second_options' => ['label' => 'Test2'], + ]); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/table + [ + ./tr + [ + ./td + [./label[@for="name_first"][.="[trans]Test[/trans]"]] + /following-sibling::td + [./input[@type="password"][@id="name_first"][@required="required"]] + ] + /following-sibling::tr + [ + ./td + [./label[@for="name_second"][.="[trans]Test2[/trans]"]] + /following-sibling::td + [./input[@type="password"][@id="name_second"][@required="required"]] + ] + /following-sibling::tr[@style="display: none"] + [./td[@colspan="2"]/input + [@type="hidden"] + [@id="name__token"] + ] + ] + [count(.//input)=3] +' + ); + } + + /** + * The block "_name_child_label" should be overridden in the theme of the + * implemented driver. + */ + public function testCollectionRowWithCustomBlock() + { + $collection = ['one', 'two', 'three']; + $form = $this->factory->createNamedBuilder('names', 'Symfony\Component\Form\Extension\Core\Type\CollectionType', $collection) + ->getForm(); + + $this->assertWidgetMatchesXpath($form->createView(), [], + '/table + [ + ./tr[./td/label[.="Custom label: [trans]0[/trans]"]] + /following-sibling::tr[./td/label[.="Custom label: [trans]1[/trans]"]] + /following-sibling::tr[./td/label[.="Custom label: [trans]2[/trans]"]] + ] +' + ); + } + + public function testFormEndWithRest() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('field1', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('field2', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm() + ->createView(); + + $this->renderWidget($view['field1']); + + // Rest should only contain field2 + $html = $this->renderEnd($view); + + // Insert the start tag, the end tag should be rendered by the helper + // Unfortunately this is not valid HTML, because the surrounding table + // tag is missing. If someone renders a form with table layout + // manually, they should call form_rest() explicitly within the + // tag. + $this->assertMatchesXpath(''.$html, + '/form + [ + ./tr + [ + ./td + [./label[@for="name_field2"]] + /following-sibling::td + [./input[@id="name_field2"]] + ] + /following-sibling::tr[@style="display: none"] + [./td[@colspan="2"]/input + [@type="hidden"] + [@id="name__token"] + ] + ] +' + ); + } + + public function testFormEndWithoutRest() + { + $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') + ->add('field1', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->add('field2', 'Symfony\Component\Form\Extension\Core\Type\TextType') + ->getForm() + ->createView(); + + $this->renderWidget($view['field1']); + + // Rest should only contain field2, but isn't rendered + $html = $this->renderEnd($view, ['render_rest' => false]); + + $this->assertEquals('', $html); + } + + public function testWidgetContainerAttributes() + { + $form = $this->factory->createNamed('form', 'Symfony\Component\Form\Extension\Core\Type\FormType', null, [ + 'attr' => ['class' => 'foobar', 'data-foo' => 'bar'], + ]); + + $form->add('text', 'Symfony\Component\Form\Extension\Core\Type\TextType'); + + $html = $this->renderWidget($form->createView()); + + // compare plain HTML to check the whitespace + $this->assertStringContainsString('
', $html); + } + + public function testWidgetContainerAttributeNameRepeatedIfTrue() + { + $form = $this->factory->createNamed('form', 'Symfony\Component\Form\Extension\Core\Type\FormType', null, [ + 'attr' => ['foo' => true], + ]); + + $html = $this->renderWidget($form->createView()); + + // foo="foo" + $this->assertStringContainsString('
', $html); + } +} diff --git a/Tests/Extension/CodeExtensionTest.php b/Tests/Extension/CodeExtensionTest.php deleted file mode 100644 index c7c859f0..00000000 --- a/Tests/Extension/CodeExtensionTest.php +++ /dev/null @@ -1,165 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\Twig\Tests\Extension; - -use PHPUnit\Framework\TestCase; -use Symfony\Bridge\Twig\Extension\CodeExtension; -use Symfony\Component\HttpKernel\Debug\FileLinkFormatter; -use Twig\Environment; -use Twig\Loader\ArrayLoader; - -class CodeExtensionTest extends TestCase -{ - public function testFormatFile() - { - $expected = sprintf('%s at line 25', substr(__FILE__, 5), __FILE__); - $this->assertEquals($expected, $this->getExtension()->formatFile(__FILE__, 25)); - } - - public function testFileRelative() - { - $this->assertEquals('file.txt', $this->getExtension()->getFileRelative(\DIRECTORY_SEPARATOR.'project'.\DIRECTORY_SEPARATOR.'file.txt')); - } - - public function testClassAbbreviationIntegration() - { - $data = [ - 'fqcn' => 'F\Q\N\Foo', - 'xss' => ''; + $importMapRenderer->expects($this->once()) + ->method('render') + ->with('application') + ->willReturn($expected); + $runtime = new ImportMapRuntime($importMapRenderer); + + $mockRuntimeLoader = $this->createMock(RuntimeLoaderInterface::class); + $mockRuntimeLoader + ->method('load') + ->willReturnMap([ + [ImportMapRuntime::class, $runtime], + ]) + ; + $twig->addRuntimeLoader($mockRuntimeLoader); + + $this->assertSame($expected, $twig->render('template')); + } +} diff --git a/Tests/Extension/SerializerExtensionTest.php b/Tests/Extension/SerializerExtensionTest.php index 0c36c8c6..610030ce 100644 --- a/Tests/Extension/SerializerExtensionTest.php +++ b/Tests/Extension/SerializerExtensionTest.php @@ -11,7 +11,6 @@ namespace Symfony\Bridge\Twig\Tests\Extension; -use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; use Symfony\Bridge\Twig\Extension\SerializerExtension; use Symfony\Bridge\Twig\Extension\SerializerRuntime; @@ -19,7 +18,7 @@ use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\YamlEncoder; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; use Twig\Environment; @@ -50,7 +49,7 @@ public static function serializerDataProvider(): \Generator private function getTwig(string $template): Environment { - $meta = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $meta = new ClassMetadataFactory(new AttributeLoader()); $runtime = new SerializerRuntime(new Serializer([new ObjectNormalizer($meta)], [new JsonEncoder(), new YamlEncoder()])); $mockRuntimeLoader = $this->createMock(RuntimeLoaderInterface::class); diff --git a/Tests/Extension/TranslationExtensionTest.php b/Tests/Extension/TranslationExtensionTest.php index a0230aa2..f6dd5f62 100644 --- a/Tests/Extension/TranslationExtensionTest.php +++ b/Tests/Extension/TranslationExtensionTest.php @@ -206,11 +206,71 @@ public function testDefaultTranslationDomainWithNamedArguments() $this->assertEquals('foo (custom)foo (foo)foo (custom)foo (custom)foo (fr)foo (custom)foo (fr)', trim($template->render([]))); } + public function testDefaultTranslationDomainWithExpression() + { + $templates = [ + 'index' => ' + {%- extends "base" %} + + {%- trans_default_domain custom_domain %} + + {%- block content %} + {{- "foo"|trans }} + {%- endblock %} + ', + + 'base' => ' + {%- block content "" %} + ', + ]; + + $translator = new Translator('en'); + $translator->addLoader('array', new ArrayLoader()); + $translator->addResource('array', ['foo' => 'foo (messages)'], 'en'); + $translator->addResource('array', ['foo' => 'foo (custom)'], 'en', 'custom'); + $translator->addResource('array', ['foo' => 'foo (foo)'], 'en', 'foo'); + + $template = $this->getTemplate($templates, $translator); + + $this->assertEquals('foo (foo)', trim($template->render(['custom_domain' => 'foo']))); + } + + public function testDefaultTranslationDomainWithExpressionAndInheritance() + { + $templates = [ + 'index' => ' + {%- extends "base" %} + + {%- trans_default_domain foo_domain %} + + {%- block content %} + {{- "foo"|trans }} + {%- endblock %} + ', + + 'base' => ' + {%- trans_default_domain custom_domain %} + + {{- "foo"|trans }} + {%- block content "" %} + {{- "foo"|trans }} + ', + ]; + + $translator = new Translator('en'); + $translator->addLoader('array', new ArrayLoader()); + $translator->addResource('array', ['foo' => 'foo (messages)'], 'en'); + $translator->addResource('array', ['foo' => 'foo (custom)'], 'en', 'custom'); + $translator->addResource('array', ['foo' => 'foo (foo)'], 'en', 'foo'); + + $template = $this->getTemplate($templates, $translator); + + $this->assertEquals('foo (custom)foo (foo)foo (custom)', trim($template->render(['foo_domain' => 'foo', 'custom_domain' => 'custom']))); + } + private function getTemplate($template, ?TranslatorInterface $translator = null): TemplateWrapper { - if (null === $translator) { - $translator = new Translator('en'); - } + $translator ??= new Translator('en'); if (\is_array($template)) { $loader = new TwigArrayLoader($template); diff --git a/Tests/Extension/WebLinkExtensionTest.php b/Tests/Extension/WebLinkExtensionTest.php index 1739c1ee..c8167077 100644 --- a/Tests/Extension/WebLinkExtensionTest.php +++ b/Tests/Extension/WebLinkExtensionTest.php @@ -22,15 +22,8 @@ */ class WebLinkExtensionTest extends TestCase { - /** - * @var Request - */ - private $request; - - /** - * @var WebLinkExtension - */ - private $extension; + private Request $request; + private WebLinkExtension $extension; protected function setUp(): void { diff --git a/Tests/Extension/WorkflowExtensionTest.php b/Tests/Extension/WorkflowExtensionTest.php index 23dcc64b..21f9e663 100644 --- a/Tests/Extension/WorkflowExtensionTest.php +++ b/Tests/Extension/WorkflowExtensionTest.php @@ -17,7 +17,6 @@ use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore; use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore; use Symfony\Component\Workflow\Registry; -use Symfony\Component\Workflow\SupportStrategy\ClassInstanceSupportStrategy; use Symfony\Component\Workflow\SupportStrategy\InstanceOfSupportStrategy; use Symfony\Component\Workflow\Transition; use Symfony\Component\Workflow\TransitionBlockerList; @@ -25,40 +24,30 @@ class WorkflowExtensionTest extends TestCase { - private $extension; - private $t1; + private WorkflowExtension $extension; + private Transition $t1; protected function setUp(): void { - if (!class_exists(Workflow::class)) { - $this->markTestSkipped('The Workflow component is needed to run tests for this extension.'); - } - $places = ['ordered', 'waiting_for_payment', 'processed']; $transitions = [ $this->t1 = new Transition('t1', 'ordered', 'waiting_for_payment'), new Transition('t2', 'waiting_for_payment', 'processed'), ]; - $metadataStore = null; - if (class_exists(InMemoryMetadataStore::class)) { - $transitionsMetadata = new \SplObjectStorage(); - $transitionsMetadata->attach($this->t1, ['title' => 't1 title']); - $metadataStore = new InMemoryMetadataStore( - ['title' => 'workflow title'], - ['orderer' => ['title' => 'ordered title']], - $transitionsMetadata - ); - } + $transitionsMetadata = new \SplObjectStorage(); + $transitionsMetadata->attach($this->t1, ['title' => 't1 title']); + $metadataStore = new InMemoryMetadataStore( + ['title' => 'workflow title'], + ['orderer' => ['title' => 'ordered title']], + $transitionsMetadata + ); $definition = new Definition($places, $transitions, null, $metadataStore); $workflow = new Workflow($definition, new MethodMarkingStore()); $registry = new Registry(); - $addWorkflow = method_exists($registry, 'addWorkflow') ? 'addWorkflow' : 'add'; - $supportStrategy = class_exists(InstanceOfSupportStrategy::class) - ? new InstanceOfSupportStrategy(Subject::class) - : new ClassInstanceSupportStrategy(Subject::class); - $registry->$addWorkflow($workflow, $supportStrategy); + $supportStrategy = new InstanceOfSupportStrategy(Subject::class); + $registry->addWorkflow($workflow, $supportStrategy); $this->extension = new WorkflowExtension($registry); } @@ -110,9 +99,6 @@ public function testGetMarkedPlaces() public function testGetMetadata() { - if (!class_exists(InMemoryMetadataStore::class)) { - $this->markTestSkipped('This test requires symfony/workflow:4.1.'); - } $subject = new Subject(); $this->assertSame('workflow title', $this->extension->getMetadata($subject, 'title')); @@ -124,9 +110,6 @@ public function testGetMetadata() public function testbuildTransitionBlockerList() { - if (!class_exists(TransitionBlockerList::class)) { - $this->markTestSkipped('This test requires symfony/workflow:4.1.'); - } $subject = new Subject(); $list = $this->extension->buildTransitionBlockerList($subject, 't1'); @@ -137,19 +120,19 @@ public function testbuildTransitionBlockerList() final class Subject { - private $marking; + private array $marking; - public function __construct($marking = null) + public function __construct(array $marking = []) { $this->marking = $marking; } - public function getMarking() + public function getMarking(): array { return $this->marking; } - public function setMarking($marking) + public function setMarking($marking): void { $this->marking = $marking; } diff --git a/Tests/Fixtures/TemplateAttributeController.php b/Tests/Fixtures/TemplateAttributeController.php new file mode 100644 index 00000000..3e69e0d2 --- /dev/null +++ b/Tests/Fixtures/TemplateAttributeController.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Fixtures; + +use Symfony\Bridge\Twig\Attribute\Template; + +class TemplateAttributeController +{ + #[Template('templates/foo.html.twig', vars: ['bar', 'buz'])] + public function foo($bar, $baz = 'abc', $buz = 'def') + { + } +} diff --git a/Tests/Mime/BodyRendererTest.php b/Tests/Mime/BodyRendererTest.php index 8ff343b6..cce8ee9a 100644 --- a/Tests/Mime/BodyRendererTest.php +++ b/Tests/Mime/BodyRendererTest.php @@ -15,9 +15,13 @@ use Symfony\Bridge\Twig\Mime\BodyRenderer; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Component\Mime\Exception\InvalidArgumentException; +use Symfony\Component\Mime\HtmlToTextConverter\DefaultHtmlToTextConverter; +use Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface; use Symfony\Component\Mime\Part\Multipart\AlternativePart; +use Symfony\Component\Translation\LocaleSwitcher; use Twig\Environment; use Twig\Loader\ArrayLoader; +use Twig\TwigFunction; class BodyRendererTest extends TestCase { @@ -27,14 +31,24 @@ public function testRenderTextOnly() $this->assertEquals('Text', $email->getBody()->bodyToString()); } - public function testRenderHtmlOnly() + public function testRenderHtmlOnlyWithDefaultConverter() { - $html = 'headHTML'; - $email = $this->prepareEmail(null, $html); + $html = 'HTML'; + $email = $this->prepareEmail(null, $html, [], new DefaultHtmlToTextConverter()); $body = $email->getBody(); $this->assertInstanceOf(AlternativePart::class, $body); $this->assertEquals('HTML', $body->getParts()[0]->bodyToString()); - $this->assertEquals(str_replace('=', '=3D', $html), $body->getParts()[1]->bodyToString()); + $this->assertEquals(str_replace(['=', "\n"], ['=3D', "\r\n"], $html), $body->getParts()[1]->bodyToString()); + } + + public function testRenderHtmlOnlyWithLeagueConverter() + { + $html = 'HTML'; + $email = $this->prepareEmail(null, $html); + $body = $email->getBody(); + $this->assertInstanceOf(AlternativePart::class, $body); + $this->assertEquals('**HTML**', $body->getParts()[0]->bodyToString()); + $this->assertEquals(str_replace(['=', "\n"], ['=3D', "\r\n"], $html), $body->getParts()[1]->bodyToString()); } public function testRenderMultiLineHtmlOnly() @@ -50,7 +64,7 @@ public function testRenderMultiLineHtmlOnly() $email = $this->prepareEmail(null, $html); $body = $email->getBody(); $this->assertInstanceOf(AlternativePart::class, $body); - $this->assertEquals('HTML', str_replace(["\r", "\n"], '', $body->getParts()[0]->bodyToString())); + $this->assertEquals('**HTML**', str_replace(["\r", "\n"], '', $body->getParts()[0]->bodyToString())); $this->assertEquals(str_replace(['=', "\n"], ['=3D', "\r\n"], $html), $body->getParts()[1]->bodyToString()); } @@ -91,10 +105,14 @@ public function testRenderedOnce() ; $email->textTemplate('text'); + $this->assertFalse($email->isRendered()); $renderer->render($email); + $this->assertTrue($email->isRendered()); + $this->assertEquals('Text', $email->getTextBody()); $email->text('reset'); + $this->assertTrue($email->isRendered()); $renderer->render($email); $this->assertEquals('reset', $email->getTextBody()); @@ -112,16 +130,26 @@ public function testRenderedOnceUnserializableContext() ; $email->textTemplate('text'); $email->context([ - 'foo' => static function () { - return 'bar'; - }, + 'foo' => static fn () => 'bar', ]); $renderer->render($email); $this->assertEquals('Text', $email->getTextBody()); } - private function prepareEmail(?string $text, ?string $html, array $context = []): TemplatedEmail + /** + * @requires extension intl + */ + public function testRenderWithLocale() + { + $localeSwitcher = new LocaleSwitcher('en', []); + $email = $this->prepareEmail(null, 'Locale: {{ locale_switcher_locale() }}', [], new DefaultHtmlToTextConverter(), $localeSwitcher, 'fr'); + + $this->assertEquals('Locale: fr', $email->getTextBody()); + $this->assertEquals('Locale: fr', $email->getHtmlBody()); + } + + private function prepareEmail(?string $text, ?string $html, array $context = [], ?HtmlToTextConverterInterface $converter = null, ?LocaleSwitcher $localeSwitcher = null, ?string $locale = null): TemplatedEmail { $twig = new Environment(new ArrayLoader([ 'text' => $text, @@ -129,12 +157,19 @@ private function prepareEmail(?string $text, ?string $html, array $context = []) 'document.txt' => 'Some text document...', 'image.jpg' => 'Some image data', ])); - $renderer = new BodyRenderer($twig); + + if ($localeSwitcher instanceof LocaleSwitcher) { + $twig->addFunction(new TwigFunction('locale_switcher_locale', [$localeSwitcher, 'getLocale'])); + } + + $renderer = new BodyRenderer($twig, [], $converter, $localeSwitcher); $email = (new TemplatedEmail()) ->to('fabien@symfony.com') ->from('helene@symfony.com') + ->locale($locale) ->context($context) ; + if (null !== $text) { $email->textTemplate('text'); } diff --git a/Tests/Mime/NotificationEmailTest.php b/Tests/Mime/NotificationEmailTest.php index 71ced84f..979f2791 100644 --- a/Tests/Mime/NotificationEmailTest.php +++ b/Tests/Mime/NotificationEmailTest.php @@ -35,7 +35,7 @@ public function test() 'markdown' => true, 'raw' => false, 'a' => 'b', - 'footer_text' => 'Notification e-mail sent by Symfony', + 'footer_text' => 'Notification email sent by Symfony', ], $email->getContext()); } @@ -58,7 +58,7 @@ public function testSerialize() 'markdown' => false, 'raw' => true, 'a' => 'b', - 'footer_text' => 'Notification e-mail sent by Symfony', + 'footer_text' => 'Notification email sent by Symfony', ], $email->getContext()); $this->assertSame('@email/example/notification/body.html.twig', $email->getHtmlTemplate()); @@ -142,7 +142,7 @@ public function testContext() 'action_url' => null, 'markdown' => false, 'raw' => false, - 'footer_text' => 'Notification e-mail sent by Symfony', + 'footer_text' => 'Notification email sent by Symfony', 'some' => 'context', ], $email->getContext()); @@ -158,7 +158,7 @@ public function testContext() 'action_url' => null, 'markdown' => false, 'raw' => false, - 'footer_text' => 'Notification e-mail sent by Symfony', + 'footer_text' => 'Notification email sent by Symfony', 'some' => 'context', 'foo' => 'bar', ], $email->getContext()); @@ -173,7 +173,7 @@ public function testContext() 'action_url' => 'Action URL', 'markdown' => false, 'raw' => false, - 'footer_text' => 'Notification e-mail sent by Symfony', + 'footer_text' => 'Notification email sent by Symfony', 'some' => 'context', 'foo' => 'bar', ], $email->getContext()); diff --git a/Tests/Mime/TemplatedEmailTest.php b/Tests/Mime/TemplatedEmailTest.php index dd23cd37..f77b3ad4 100644 --- a/Tests/Mime/TemplatedEmailTest.php +++ b/Tests/Mime/TemplatedEmailTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\Twig\Mime\TemplatedEmail; +use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; @@ -42,12 +43,14 @@ public function testSerialize() ->textTemplate('text.txt.twig') ->htmlTemplate('text.html.twig') ->context($context = ['a' => 'b']) + ->locale($locale = 'fr_FR') ; $email = unserialize(serialize($email)); $this->assertEquals('text.txt.twig', $email->getTextTemplate()); $this->assertEquals('text.html.twig', $email->getHtmlTemplate()); $this->assertEquals($context, $email->getContext()); + $this->assertEquals($locale, $email->getLocale()); } public function testSymfonySerialize() @@ -57,14 +60,16 @@ public function testSymfonySerialize() $e->to('you@example.com'); $e->textTemplate('email.txt.twig'); $e->htmlTemplate('email.html.twig'); + $e->locale('en'); $e->context(['foo' => 'bar']); - $e->attach('Some Text file', 'test.txt'); + $e->addPart(new DataPart('Some Text file', 'test.txt')); $expected = clone $e; $expectedJson = <<assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime("Symfony\\\\Component\\\\Form\\\\FormRenderer")->setTheme(%s, [1 => "tpl1", 0 => "tpl2"], true);', $this->getVariableGetter('form') ), trim($compiler->compile($node)->getSource()) ); - $node = new FormThemeNode($form, $resources, 0, null, true); + $node = new FormThemeNode($form, $resources, 0, true); $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime("Symfony\\\\Component\\\\Form\\\\FormRenderer")->setTheme(%s, [1 => "tpl1", 0 => "tpl2"], false);', $this->getVariableGetter('form') ), @@ -92,17 +92,17 @@ public function testCompile() $node = new FormThemeNode($form, $resources, 0); $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime("Symfony\\\\Component\\\\Form\\\\FormRenderer")->setTheme(%s, "tpl1", true);', $this->getVariableGetter('form') ), trim($compiler->compile($node)->getSource()) ); - $node = new FormThemeNode($form, $resources, 0, null, true); + $node = new FormThemeNode($form, $resources, 0, true); $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime("Symfony\\\\Component\\\\Form\\\\FormRenderer")->setTheme(%s, "tpl1", false);', $this->getVariableGetter('form') ), @@ -112,6 +112,6 @@ public function testCompile() protected function getVariableGetter($name) { - return sprintf('($context["%s"] ?? null)', $name); + return \sprintf('($context["%s"] ?? null)', $name); } } diff --git a/Tests/Node/SearchAndRenderBlockNodeTest.php b/Tests/Node/SearchAndRenderBlockNodeTest.php index 5c2bacf1..ab9113ac 100644 --- a/Tests/Node/SearchAndRenderBlockNodeTest.php +++ b/Tests/Node/SearchAndRenderBlockNodeTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\Twig\Node\SearchAndRenderBlockNode; -use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Compiler; use Twig\Environment; use Twig\Extension\CoreExtension; @@ -22,6 +21,7 @@ use Twig\Node\Expression\ConditionalExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\NameExpression; +use Twig\Node\Expression\Ternary\ConditionalTernary; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; use Twig\Node\Nodes; @@ -41,16 +41,12 @@ public function testCompileWidget() ]); } - if (class_exists(FirstClassTwigCallableReady::class)) { - $node = new SearchAndRenderBlockNode(new TwigFunction('form_widget'), $arguments, 0); - } else { - $node = new SearchAndRenderBlockNode('form_widget', $arguments, 0); - } + $node = new SearchAndRenderBlockNode(new TwigFunction('form_widget'), $arguments, 0); $compiler = new Compiler(new Environment($this->createMock(LoaderInterface::class))); $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime(\'Symfony\Component\Form\FormRenderer\')->searchAndRenderBlock(%s, \'widget\')', $this->getVariableGetter('form') ), @@ -78,16 +74,12 @@ public function testCompileWidgetWithVariables() ]); } - if (class_exists(FirstClassTwigCallableReady::class)) { - $node = new SearchAndRenderBlockNode(new TwigFunction('form_widget'), $arguments, 0); - } else { - $node = new SearchAndRenderBlockNode('form_widget', $arguments, 0); - } + $node = new SearchAndRenderBlockNode(new TwigFunction('form_widget'), $arguments, 0); $compiler = new Compiler(new Environment($this->createMock(LoaderInterface::class))); $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime(\'Symfony\Component\Form\FormRenderer\')->searchAndRenderBlock(%s, \'widget\', ["foo" => "bar"])', $this->getVariableGetter('form') ), @@ -109,16 +101,12 @@ public function testCompileLabelWithLabel() ]); } - if (class_exists(FirstClassTwigCallableReady::class)) { - $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); - } else { - $node = new SearchAndRenderBlockNode('form_label', $arguments, 0); - } + $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); $compiler = new Compiler(new Environment($this->createMock(LoaderInterface::class))); $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime(\'Symfony\Component\Form\FormRenderer\')->searchAndRenderBlock(%s, \'label\', ["label" => "my label"])', $this->getVariableGetter('form') ), @@ -140,18 +128,14 @@ public function testCompileLabelWithNullLabel() ]); } - if (class_exists(FirstClassTwigCallableReady::class)) { - $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); - } else { - $node = new SearchAndRenderBlockNode('form_label', $arguments, 0); - } + $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); $compiler = new Compiler(new Environment($this->createMock(LoaderInterface::class))); // "label" => null must not be included in the output! // Otherwise the default label is overwritten with null. $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime(\'Symfony\Component\Form\FormRenderer\')->searchAndRenderBlock(%s, \'label\')', $this->getVariableGetter('form') ), @@ -173,18 +157,14 @@ public function testCompileLabelWithEmptyStringLabel() ]); } - if (class_exists(FirstClassTwigCallableReady::class)) { - $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); - } else { - $node = new SearchAndRenderBlockNode('form_label', $arguments, 0); - } + $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); $compiler = new Compiler(new Environment($this->createMock(LoaderInterface::class))); // "label" => null must not be included in the output! // Otherwise the default label is overwritten with null. $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime(\'Symfony\Component\Form\FormRenderer\')->searchAndRenderBlock(%s, \'label\')', $this->getVariableGetter('form') ), @@ -204,16 +184,12 @@ public function testCompileLabelWithDefaultLabel() ]); } - if (class_exists(FirstClassTwigCallableReady::class)) { - $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); - } else { - $node = new SearchAndRenderBlockNode('form_label', $arguments, 0); - } + $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); $compiler = new Compiler(new Environment($this->createMock(LoaderInterface::class))); $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime(\'Symfony\Component\Form\FormRenderer\')->searchAndRenderBlock(%s, \'label\')', $this->getVariableGetter('form') ), @@ -243,11 +219,7 @@ public function testCompileLabelWithAttributes() ]); } - if (class_exists(FirstClassTwigCallableReady::class)) { - $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); - } else { - $node = new SearchAndRenderBlockNode('form_label', $arguments, 0); - } + $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); $compiler = new Compiler(new Environment($this->createMock(LoaderInterface::class))); @@ -255,7 +227,7 @@ public function testCompileLabelWithAttributes() // Otherwise the default label is overwritten with null. // https://github.com/symfony/symfony/issues/5029 $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime(\'Symfony\Component\Form\FormRenderer\')->searchAndRenderBlock(%s, \'label\', ["foo" => "bar"])', $this->getVariableGetter('form') ), @@ -289,16 +261,12 @@ public function testCompileLabelWithLabelAndAttributes() ]); } - if (class_exists(FirstClassTwigCallableReady::class)) { - $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); - } else { - $node = new SearchAndRenderBlockNode('form_label', $arguments, 0); - } + $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); $compiler = new Compiler(new Environment($this->createMock(LoaderInterface::class))); $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime(\'Symfony\Component\Form\FormRenderer\')->searchAndRenderBlock(%s, \'label\', ["foo" => "bar", "label" => "value in argument"])', $this->getVariableGetter('form') ), @@ -308,47 +276,43 @@ public function testCompileLabelWithLabelAndAttributes() public function testCompileLabelWithLabelThatEvaluatesToNull() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ConditionalExpression( - // if - new ConstantExpression(true, 0), - // then - new ConstantExpression(null, 0), - // else - new ConstantExpression(null, 0), - 0 - ), - ]); + if (class_exists(ConditionalTernary::class)) { + $conditional = new ConditionalTernary( + // if + new ConstantExpression(true, 0), + // then + new ConstantExpression(null, 0), + // else + new ConstantExpression(null, 0), + 0 + ); } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ConditionalExpression( - // if - new ConstantExpression(true, 0), - // then - new ConstantExpression(null, 0), - // else - new ConstantExpression(null, 0), - 0 - ), - ]); + $conditional = new ConditionalExpression( + // if + new ConstantExpression(true, 0), + // then + new ConstantExpression(null, 0), + // else + new ConstantExpression(null, 0), + 0 + ); } - if (class_exists(FirstClassTwigCallableReady::class)) { - $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); + if (class_exists(Nodes::class)) { + $arguments = new Nodes([new ContextVariable('form', 0), $conditional]); } else { - $node = new SearchAndRenderBlockNode('form_label', $arguments, 0); + $arguments = new Node([new NameExpression('form', 0), $conditional]); } + $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); + $compiler = new Compiler(new Environment($this->createMock(LoaderInterface::class))); // "label" => null must not be included in the output! // Otherwise the default label is overwritten with null. // https://github.com/symfony/symfony/issues/5029 $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime(\'Symfony\Component\Form\FormRenderer\')->searchAndRenderBlock(%s, \'label\', (%s($_label_ = ((true) ? (null) : (null))) ? [] : ["label" => $_label_]))', $this->getVariableGetter('form'), method_exists(CoreExtension::class, 'testEmpty') ? 'CoreExtension::testEmpty' : 'twig_test_empty' @@ -359,18 +323,32 @@ public function testCompileLabelWithLabelThatEvaluatesToNull() public function testCompileLabelWithLabelThatEvaluatesToNullAndAttributes() { + if (class_exists(ConditionalTernary::class)) { + $conditional = new ConditionalTernary( + // if + new ConstantExpression(true, 0), + // then + new ConstantExpression(null, 0), + // else + new ConstantExpression(null, 0), + 0 + ); + } else { + $conditional = new ConditionalExpression( + // if + new ConstantExpression(true, 0), + // then + new ConstantExpression(null, 0), + // else + new ConstantExpression(null, 0), + 0 + ); + } + if (class_exists(Nodes::class)) { $arguments = new Nodes([ new ContextVariable('form', 0), - new ConditionalExpression( - // if - new ConstantExpression(true, 0), - // then - new ConstantExpression(null, 0), - // else - new ConstantExpression(null, 0), - 0 - ), + $conditional, new ArrayExpression([ new ConstantExpression('foo', 0), new ConstantExpression('bar', 0), @@ -381,12 +359,7 @@ public function testCompileLabelWithLabelThatEvaluatesToNullAndAttributes() } else { $arguments = new Node([ new NameExpression('form', 0), - new ConditionalExpression( - new ConstantExpression(true, 0), - new ConstantExpression(null, 0), - new ConstantExpression(null, 0), - 0 - ), + $conditional, new ArrayExpression([ new ConstantExpression('foo', 0), new ConstantExpression('bar', 0), @@ -396,11 +369,7 @@ public function testCompileLabelWithLabelThatEvaluatesToNullAndAttributes() ]); } - if (class_exists(FirstClassTwigCallableReady::class)) { - $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); - } else { - $node = new SearchAndRenderBlockNode('form_label', $arguments, 0); - } + $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); $compiler = new Compiler(new Environment($this->createMock(LoaderInterface::class))); @@ -408,7 +377,7 @@ public function testCompileLabelWithLabelThatEvaluatesToNullAndAttributes() // Otherwise the default label is overwritten with null. // https://github.com/symfony/symfony/issues/5029 $this->assertEquals( - sprintf( + \sprintf( '$this->env->getRuntime(\'Symfony\Component\Form\FormRenderer\')->searchAndRenderBlock(%s, \'label\', ["foo" => "bar", "label" => "value in attributes"] + (%s($_label_ = ((true) ? (null) : (null))) ? [] : ["label" => $_label_]))', $this->getVariableGetter('form'), method_exists(CoreExtension::class, 'testEmpty') ? 'CoreExtension::testEmpty' : 'twig_test_empty' @@ -419,6 +388,6 @@ public function testCompileLabelWithLabelThatEvaluatesToNullAndAttributes() protected function getVariableGetter($name) { - return sprintf('($context["%s"] ?? null)', $name); + return \sprintf('($context["%s"] ?? null)', $name); } } diff --git a/Tests/Node/TransNodeTest.php b/Tests/Node/TransNodeTest.php index a6b54f53..24fa4d25 100644 --- a/Tests/Node/TransNodeTest.php +++ b/Tests/Node/TransNodeTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\Twig\Node\TransNode; -use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Environment; use Twig\Loader\LoaderInterface; @@ -36,9 +35,8 @@ public function testCompileStrict() $compiler = new Compiler($env); $this->assertEquals( - sprintf( - '%s $this->env->getExtension(\'Symfony\Bridge\Twig\Extension\TranslationExtension\')->trans("trans %%var%%", array_merge(["%%var%%" => %s], %s), "messages");', - class_exists(YieldReady::class) ? 'yield' : 'echo', + \sprintf( + 'yield $this->env->getExtension(\'Symfony\Bridge\Twig\Extension\TranslationExtension\')->trans("trans %%var%%", array_merge(["%%var%%" => %s], %s), "messages");', $this->getVariableGetterWithoutStrictCheck('var'), $this->getVariableGetterWithStrictCheck('foo') ), @@ -48,15 +46,11 @@ class_exists(YieldReady::class) ? 'yield' : 'echo', protected function getVariableGetterWithoutStrictCheck($name) { - return sprintf('($context["%s"] ?? null)', $name); + return \sprintf('($context["%s"] ?? null)', $name); } protected function getVariableGetterWithStrictCheck($name) { - if (Environment::MAJOR_VERSION >= 2) { - return sprintf('(isset($context["%1$s"]) || array_key_exists("%1$s", $context) ? $context["%1$s"] : (function () { throw new %2$s(\'Variable "%1$s" does not exist.\', 0, $this->source); })())', $name, Environment::VERSION_ID >= 20700 ? 'RuntimeError' : 'Twig_Error_Runtime'); - } - - return sprintf('($context["%s"] ?? $this->getContext($context, "%1$s"))', $name); + return \sprintf('(isset($context["%1$s"]) || array_key_exists("%1$s", $context) ? $context["%1$s"] : (function () { throw new RuntimeError(\'Variable "%1$s" does not exist.\', 0, $this->source); })())', $name); } } diff --git a/Tests/NodeVisitor/TranslationDefaultDomainNodeVisitorTest.php b/Tests/NodeVisitor/TranslationDefaultDomainNodeVisitorTest.php index 25b8166e..40063c6b 100644 --- a/Tests/NodeVisitor/TranslationDefaultDomainNodeVisitorTest.php +++ b/Tests/NodeVisitor/TranslationDefaultDomainNodeVisitorTest.php @@ -21,8 +21,8 @@ class TranslationDefaultDomainNodeVisitorTest extends TestCase { - private static $message = 'message'; - private static $domain = 'domain'; + private static string $message = 'message'; + private static string $domain = 'domain'; /** @dataProvider getDefaultDomainAssignmentTestData */ public function testDefaultDomainAssignment(Node $node) diff --git a/Tests/NodeVisitor/TranslationNodeVisitorTest.php b/Tests/NodeVisitor/TranslationNodeVisitorTest.php index 6dbd0d27..2d52c4ea 100644 --- a/Tests/NodeVisitor/TranslationNodeVisitorTest.php +++ b/Tests/NodeVisitor/TranslationNodeVisitorTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\Twig\NodeVisitor\TranslationNodeVisitor; -use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Environment; use Twig\Loader\LoaderInterface; use Twig\Node\Expression\ArrayExpression; @@ -54,21 +53,12 @@ public function testMessageExtractionWithInvalidDomainNode() ]); } - if (class_exists(FirstClassTwigCallableReady::class)) { - $node = new FilterExpression( - new ConstantExpression($message, 0), - new TwigFilter('trans'), - $n, - 0 - ); - } else { - $node = new FilterExpression( - new ConstantExpression($message, 0), - new ConstantExpression('trans', 0), - $n, - 0 - ); - } + $node = new FilterExpression( + new ConstantExpression($message, 0), + new TwigFilter('trans'), + $n, + 0 + ); $this->testMessagesExtraction($node, [[$message, TranslationNodeVisitor::UNDEFINED_DOMAIN]]); } diff --git a/Tests/NodeVisitor/TwigNodeProvider.php b/Tests/NodeVisitor/TwigNodeProvider.php index 69cf6bec..64ce92bc 100644 --- a/Tests/NodeVisitor/TwigNodeProvider.php +++ b/Tests/NodeVisitor/TwigNodeProvider.php @@ -13,8 +13,8 @@ use Symfony\Bridge\Twig\Node\TransDefaultDomainNode; use Symfony\Bridge\Twig\Node\TransNode; -use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Node\BodyNode; +use Twig\Node\EmptyNode; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\FilterExpression; @@ -28,13 +28,15 @@ class TwigNodeProvider { public static function getModule($content) { + $emptyNodeExists = class_exists(EmptyNode::class); + return new ModuleNode( new BodyNode([new ConstantExpression($content, 0)]), null, - new ArrayExpression([], 0), - new ArrayExpression([], 0), - new ArrayExpression([], 0), - null, + $emptyNodeExists ? new EmptyNode() : new ArrayExpression([], 0), + $emptyNodeExists ? new EmptyNode() : new ArrayExpression([], 0), + $emptyNodeExists ? new EmptyNode() : new ArrayExpression([], 0), + $emptyNodeExists ? new EmptyNode() : null, new Source('', '') ); } @@ -54,15 +56,6 @@ public static function getTransFilter($message, $domain = null, $arguments = nul $args = new Node($arguments); } - if (!class_exists(FirstClassTwigCallableReady::class)) { - return new FilterExpression( - new ConstantExpression($message, 0), - new ConstantExpression('trans', 0), - $args, - 0 - ); - } - return new FilterExpression( new ConstantExpression($message, 0), new TwigFilter('trans'), diff --git a/Tests/TokenParser/FormThemeTokenParserTest.php b/Tests/TokenParser/FormThemeTokenParserTest.php index 02b6597c..4e8209ef 100644 --- a/Tests/TokenParser/FormThemeTokenParserTest.php +++ b/Tests/TokenParser/FormThemeTokenParserTest.php @@ -14,7 +14,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\Twig\Node\FormThemeNode; use Symfony\Bridge\Twig\TokenParser\FormThemeTokenParser; -use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Environment; use Twig\Loader\LoaderInterface; use Twig\Node\Expression\ArrayExpression; @@ -37,10 +36,7 @@ public function testCompile($source, $expected) $stream = $env->tokenize($source); $parser = new Parser($env); - if (class_exists(FirstClassTwigCallableReady::class)) { - $expected->setNodeTag('form_theme'); - } - + $expected->setNodeTag('form_theme'); $expected->setSourceContext($source); $this->assertEquals($expected, $parser->parse($stream)->getNode('body')->getNode(0)); @@ -57,8 +53,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name new ConstantExpression(0, 1), new ConstantExpression('tpl1', 1), ], 1), - 1, - 'form_theme' + 1 ), ], [ @@ -71,8 +66,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name new ConstantExpression(1, 1), new ConstantExpression('tpl2', 1), ], 1), - 1, - 'form_theme' + 1 ), ], [ @@ -80,8 +74,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name new FormThemeNode( class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), new ConstantExpression('tpl1', 1), - 1, - 'form_theme' + 1 ), ], [ @@ -92,8 +85,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name new ConstantExpression(0, 1), new ConstantExpression('tpl1', 1), ], 1), - 1, - 'form_theme' + 1 ), ], [ @@ -106,8 +98,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name new ConstantExpression(1, 1), new ConstantExpression('tpl2', 1), ], 1), - 1, - 'form_theme' + 1 ), ], [ @@ -121,7 +112,6 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name new ConstantExpression('tpl2', 1), ], 1), 1, - 'form_theme', true ), ], diff --git a/Tests/Translation/TwigExtractorTest.php b/Tests/Translation/TwigExtractorTest.php index 4e7b501d..f9ae8c34 100644 --- a/Tests/Translation/TwigExtractorTest.php +++ b/Tests/Translation/TwigExtractorTest.php @@ -43,7 +43,6 @@ public function testExtract($template, $messages) $catalogue = new MessageCatalogue('en'); $m = new \ReflectionMethod($extractor, 'extractTemplate'); - $m->setAccessible(true); $m->invoke($extractor, $template, $catalogue); if (0 === \count($messages)) { diff --git a/TokenParser/DumpTokenParser.php b/TokenParser/DumpTokenParser.php index 2d80f05c..9c12dc23 100644 --- a/TokenParser/DumpTokenParser.php +++ b/TokenParser/DumpTokenParser.php @@ -14,6 +14,7 @@ use Symfony\Bridge\Twig\Node\DumpNode; use Twig\Node\Expression\Variable\LocalVariable; use Twig\Node\Node; +use Twig\Node\Nodes; use Twig\Token; use Twig\TokenParser\AbstractTokenParser; @@ -30,23 +31,32 @@ */ final class DumpTokenParser extends AbstractTokenParser { - /** - * {@inheritdoc} - */ public function parse(Token $token): Node { $values = null; if (!$this->parser->getStream()->test(Token::BLOCK_END_TYPE)) { - $values = $this->parser->getExpressionParser()->parseMultitargetExpression(); + $values = method_exists($this->parser, 'parseExpression') ? + $this->parseMultitargetExpression() : + $this->parser->getExpressionParser()->parseMultitargetExpression(); } $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); return new DumpNode(class_exists(LocalVariable::class) ? new LocalVariable(null, $token->getLine()) : $this->parser->getVarName(), $values, $token->getLine(), $this->getTag()); } - /** - * {@inheritdoc} - */ + private function parseMultitargetExpression(): Node + { + $targets = []; + while (true) { + $targets[] = $this->parser->parseExpression(); + if (!$this->parser->getStream()->nextIf(Token::PUNCTUATION_TYPE, ',')) { + break; + } + } + + return new Nodes($targets); + } + public function getTag(): string { return 'dump'; diff --git a/TokenParser/FormThemeTokenParser.php b/TokenParser/FormThemeTokenParser.php index ef5dacb5..0988eae5 100644 --- a/TokenParser/FormThemeTokenParser.php +++ b/TokenParser/FormThemeTokenParser.php @@ -24,20 +24,21 @@ */ final class FormThemeTokenParser extends AbstractTokenParser { - /** - * {@inheritdoc} - */ public function parse(Token $token): Node { $lineno = $token->getLine(); $stream = $this->parser->getStream(); - $form = $this->parser->getExpressionParser()->parseExpression(); + $parseExpression = method_exists($this->parser, 'parseExpression') + ? $this->parser->parseExpression(...) + : $this->parser->getExpressionParser()->parseExpression(...); + + $form = $parseExpression(); $only = false; if ($this->parser->getStream()->test(Token::NAME_TYPE, 'with')) { $this->parser->getStream()->next(); - $resources = $this->parser->getExpressionParser()->parseExpression(); + $resources = $parseExpression(); if ($this->parser->getStream()->nextIf(Token::NAME_TYPE, 'only')) { $only = true; @@ -45,18 +46,15 @@ public function parse(Token $token): Node } else { $resources = new ArrayExpression([], $stream->getCurrent()->getLine()); do { - $resources->addElement($this->parser->getExpressionParser()->parseExpression()); + $resources->addElement($parseExpression()); } while (!$stream->test(Token::BLOCK_END_TYPE)); } $stream->expect(Token::BLOCK_END_TYPE); - return new FormThemeNode($form, $resources, $lineno, $this->getTag(), $only); + return new FormThemeNode($form, $resources, $lineno, $only); } - /** - * {@inheritdoc} - */ public function getTag(): string { return 'form_theme'; diff --git a/TokenParser/StopwatchTokenParser.php b/TokenParser/StopwatchTokenParser.php index 84faee22..d77cbbf4 100644 --- a/TokenParser/StopwatchTokenParser.php +++ b/TokenParser/StopwatchTokenParser.php @@ -25,11 +25,9 @@ */ final class StopwatchTokenParser extends AbstractTokenParser { - protected $stopwatchIsAvailable; - - public function __construct(bool $stopwatchIsAvailable) - { - $this->stopwatchIsAvailable = $stopwatchIsAvailable; + public function __construct( + private bool $stopwatchIsAvailable, + ) { } public function parse(Token $token): Node @@ -38,12 +36,14 @@ public function parse(Token $token): Node $stream = $this->parser->getStream(); // {% stopwatch 'bar' %} - $name = $this->parser->getExpressionParser()->parseExpression(); + $name = method_exists($this->parser, 'parseExpression') ? + $this->parser->parseExpression() : + $this->parser->getExpressionParser()->parseExpression(); $stream->expect(Token::BLOCK_END_TYPE); // {% endstopwatch %} - $body = $this->parser->subparse([$this, 'decideStopwatchEnd'], true); + $body = $this->parser->subparse($this->decideStopwatchEnd(...), true); $stream->expect(Token::BLOCK_END_TYPE); if ($this->stopwatchIsAvailable) { diff --git a/TokenParser/TransDefaultDomainTokenParser.php b/TokenParser/TransDefaultDomainTokenParser.php index 19b82049..a64a2332 100644 --- a/TokenParser/TransDefaultDomainTokenParser.php +++ b/TokenParser/TransDefaultDomainTokenParser.php @@ -23,21 +23,17 @@ */ final class TransDefaultDomainTokenParser extends AbstractTokenParser { - /** - * {@inheritdoc} - */ public function parse(Token $token): Node { - $expr = $this->parser->getExpressionParser()->parseExpression(); + $expr = method_exists($this->parser, 'parseExpression') ? + $this->parser->parseExpression() : + $this->parser->getExpressionParser()->parseExpression(); $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); return new TransDefaultDomainNode($expr, $token->getLine(), $this->getTag()); } - /** - * {@inheritdoc} - */ public function getTag(): string { return 'trans_default_domain'; diff --git a/TokenParser/TransTokenParser.php b/TokenParser/TransTokenParser.php index ffe88285..f522356b 100644 --- a/TokenParser/TransTokenParser.php +++ b/TokenParser/TransTokenParser.php @@ -27,9 +27,6 @@ */ final class TransTokenParser extends AbstractTokenParser { - /** - * {@inheritdoc} - */ public function parse(Token $token): Node { $lineno = $token->getLine(); @@ -39,29 +36,33 @@ public function parse(Token $token): Node $vars = new ArrayExpression([], $lineno); $domain = null; $locale = null; + $parseExpression = method_exists($this->parser, 'parseExpression') + ? $this->parser->parseExpression(...) + : $this->parser->getExpressionParser()->parseExpression(...); + if (!$stream->test(Token::BLOCK_END_TYPE)) { if ($stream->test('count')) { // {% trans count 5 %} $stream->next(); - $count = $this->parser->getExpressionParser()->parseExpression(); + $count = $parseExpression(); } if ($stream->test('with')) { // {% trans with vars %} $stream->next(); - $vars = $this->parser->getExpressionParser()->parseExpression(); + $vars = $parseExpression(); } if ($stream->test('from')) { // {% trans from "messages" %} $stream->next(); - $domain = $this->parser->getExpressionParser()->parseExpression(); + $domain = $parseExpression(); } if ($stream->test('into')) { // {% trans into "fr" %} $stream->next(); - $locale = $this->parser->getExpressionParser()->parseExpression(); + $locale = $parseExpression(); } elseif (!$stream->test(Token::BLOCK_END_TYPE)) { throw new SyntaxError('Unexpected token. Twig was looking for the "with", "from", or "into" keyword.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); } @@ -69,7 +70,7 @@ public function parse(Token $token): Node // {% trans %}message{% endtrans %} $stream->expect(Token::BLOCK_END_TYPE); - $body = $this->parser->subparse([$this, 'decideTransFork'], true); + $body = $this->parser->subparse($this->decideTransFork(...), true); if (!$body instanceof TextNode && !$body instanceof AbstractExpression) { throw new SyntaxError('A message inside a trans tag must be a simple text.', $body->getTemplateLine(), $stream->getSourceContext()); @@ -77,7 +78,7 @@ public function parse(Token $token): Node $stream->expect(Token::BLOCK_END_TYPE); - return new TransNode($body, $domain, $count, $vars, $locale, $lineno, $this->getTag()); + return new TransNode($body, $domain, $count, $vars, $locale, $lineno); } public function decideTransFork(Token $token): bool @@ -85,9 +86,6 @@ public function decideTransFork(Token $token): bool return $token->test(['endtrans']); } - /** - * {@inheritdoc} - */ public function getTag(): string { return 'trans'; diff --git a/Translation/TwigExtractor.php b/Translation/TwigExtractor.php index e79ec697..a4b4bbe5 100644 --- a/Translation/TwigExtractor.php +++ b/Translation/TwigExtractor.php @@ -11,6 +11,7 @@ namespace Symfony\Bridge\Twig\Translation; +use Symfony\Bridge\Twig\Extension\TranslationExtension; use Symfony\Component\Finder\Finder; use Symfony\Component\Translation\Extractor\AbstractFileExtractor; use Symfony\Component\Translation\Extractor\ExtractorInterface; @@ -29,50 +30,38 @@ class TwigExtractor extends AbstractFileExtractor implements ExtractorInterface { /** * Default domain for found messages. - * - * @var string */ - private $defaultDomain = 'messages'; + private string $defaultDomain = 'messages'; /** * Prefix for found message. - * - * @var string */ - private $prefix = ''; + private string $prefix = ''; - private $twig; - - public function __construct(Environment $twig) - { - $this->twig = $twig; + public function __construct( + private Environment $twig, + ) { } - /** - * {@inheritdoc} - */ - public function extract($resource, MessageCatalogue $catalogue) + public function extract($resource, MessageCatalogue $catalogue): void { foreach ($this->extractFiles($resource) as $file) { try { $this->extractTemplate(file_get_contents($file->getPathname()), $catalogue); - } catch (Error $e) { + } catch (Error) { // ignore errors, these should be fixed by using the linter } } } - /** - * {@inheritdoc} - */ - public function setPrefix(string $prefix) + public function setPrefix(string $prefix): void { $this->prefix = $prefix; } - protected function extractTemplate(string $template, MessageCatalogue $catalogue) + protected function extractTemplate(string $template, MessageCatalogue $catalogue): void { - $visitor = $this->twig->getExtension('Symfony\Bridge\Twig\Extension\TranslationExtension')->getTranslationNodeVisitor(); + $visitor = $this->twig->getExtension(TranslationExtension::class)->getTranslationNodeVisitor(); $visitor->enable(); $this->twig->parse($this->twig->tokenize(new Source($template, ''))); @@ -84,18 +73,12 @@ protected function extractTemplate(string $template, MessageCatalogue $catalogue $visitor->disable(); } - /** - * @return bool - */ - protected function canBeExtracted(string $file) + protected function canBeExtracted(string $file): bool { return $this->isFile($file) && 'twig' === pathinfo($file, \PATHINFO_EXTENSION); } - /** - * {@inheritdoc} - */ - protected function extractFromDirectory($directory) + protected function extractFromDirectory($directory): iterable { $finder = new Finder(); diff --git a/UndefinedCallableHandler.php b/UndefinedCallableHandler.php index 75bd9dc4..5da9a148 100644 --- a/UndefinedCallableHandler.php +++ b/UndefinedCallableHandler.php @@ -23,8 +23,12 @@ class UndefinedCallableHandler { private const FILTER_COMPONENTS = [ + 'emojify' => 'emoji', 'humanize' => 'form', + 'form_encode_currency' => 'form', + 'serialize' => 'serializer', 'trans' => 'translation', + 'sanitize_html' => 'html-sanitizer', 'yaml_encode' => 'yaml', 'yaml_dump' => 'yaml', ]; @@ -32,6 +36,7 @@ class UndefinedCallableHandler private const FUNCTION_COMPONENTS = [ 'asset' => 'asset', 'asset_version' => 'asset', + 'importmap' => 'asset-mapper', 'dump' => 'debug-bundle', 'encore_entry_link_tags' => 'webpack-encore-bundle', 'encore_entry_script_tags' => 'webpack-encore-bundle', @@ -46,9 +51,21 @@ class UndefinedCallableHandler 'form_start' => 'form', 'form_end' => 'form', 'csrf_token' => 'form', + 'form_parent' => 'form', + 'field_name' => 'form', + 'field_value' => 'form', + 'field_label' => 'form', + 'field_help' => 'form', + 'field_errors' => 'form', + 'field_choices' => 'form', 'logout_url' => 'security-http', 'logout_path' => 'security-http', 'is_granted' => 'security-core', + 'impersonation_path' => 'security-http', + 'impersonation_url' => 'security-http', + 'impersonation_exit_path' => 'security-http', + 'impersonation_exit_url' => 'security-http', + 't' => 'translation', 'link' => 'web-link', 'preload' => 'web-link', 'dns_prefetch' => 'web-link', @@ -57,11 +74,15 @@ class UndefinedCallableHandler 'prerender' => 'web-link', 'workflow_can' => 'workflow', 'workflow_transitions' => 'workflow', + 'workflow_transition' => 'workflow', 'workflow_has_marked_place' => 'workflow', 'workflow_marked_places' => 'workflow', + 'workflow_metadata' => 'workflow', + 'workflow_transition_blockers' => 'workflow', ]; private const FULL_STACK_ENABLE = [ + 'html-sanitizer' => 'enable "framework.html_sanitizer"', 'form' => 'enable "framework.form"', 'security-core' => 'add the "SecurityBundle"', 'security-http' => 'add the "SecurityBundle"', @@ -69,10 +90,7 @@ class UndefinedCallableHandler 'workflow' => 'enable "framework.workflows"', ]; - /** - * @return TwigFilter|false - */ - public static function onUndefinedFilter(string $name) + public static function onUndefinedFilter(string $name): TwigFilter|false { if (!isset(self::FILTER_COMPONENTS[$name])) { return false; @@ -81,17 +99,14 @@ public static function onUndefinedFilter(string $name) throw new SyntaxError(self::onUndefined($name, 'filter', self::FILTER_COMPONENTS[$name])); } - /** - * @return TwigFunction|false - */ - public static function onUndefinedFunction(string $name) + public static function onUndefinedFunction(string $name): TwigFunction|false { if (!isset(self::FUNCTION_COMPONENTS[$name])) { return false; } if ('webpack-encore-bundle' === self::FUNCTION_COMPONENTS[$name]) { - return new TwigFunction($name, static function () { return ''; }); + return new TwigFunction($name, static fn () => ''); } throw new SyntaxError(self::onUndefined($name, 'function', self::FUNCTION_COMPONENTS[$name])); @@ -100,7 +115,7 @@ public static function onUndefinedFunction(string $name) private static function onUndefined(string $name, string $type, string $component): string { if (class_exists(FullStack::class) && isset(self::FULL_STACK_ENABLE[$component])) { - return sprintf('Did you forget to %s? Unknown %s "%s".', self::FULL_STACK_ENABLE[$component], $type, $name); + return \sprintf('Did you forget to %s? Unknown %s "%s".', self::FULL_STACK_ENABLE[$component], $type, $name); } $missingPackage = 'symfony/'.$component; @@ -109,6 +124,6 @@ private static function onUndefined(string $name, string $type, string $componen $missingPackage = 'symfony/twig-bundle'; } - return sprintf('Did you forget to run "composer require %s"? Unknown %s "%s".', $missingPackage, $type, $name); + return \sprintf('Did you forget to run "composer require %s"? Unknown %s "%s".', $missingPackage, $type, $name); } } diff --git a/composer.json b/composer.json index 1abf3ad1..f0ae491d 100644 --- a/composer.json +++ b/composer.json @@ -16,38 +16,41 @@ } ], "require": { - "php": ">=7.2.5", - "symfony/polyfill-php80": "^1.16", - "symfony/translation-contracts": "^1.1|^2|^3", - "twig/twig": "^2.13|^3.0.4" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/translation-contracts": "^2.5|^3", + "twig/twig": "^3.12" }, "require-dev": { - "doctrine/annotations": "^1.12|^2", "egulias/email-validator": "^2.1.10|^3|^4", + "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/asset": "^4.4|^5.0|^6.0", - "symfony/dependency-injection": "^4.4|^5.0|^6.0", - "symfony/finder": "^4.4|^5.0|^6.0", - "symfony/form": "^5.4.21|^6.2.7", - "symfony/http-foundation": "^5.3|^6.0", - "symfony/http-kernel": "^4.4|^5.0|^6.0", - "symfony/intl": "^4.4|^5.0|^6.0", - "symfony/mime": "^5.2|^6.0", + "symfony/asset": "^6.4|^7.0", + "symfony/asset-mapper": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/emoji": "^7.1", + "symfony/finder": "^6.4|^7.0", + "symfony/form": "^6.4.20|^7.2.5", + "symfony/html-sanitizer": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", "symfony/polyfill-intl-icu": "~1.0", - "symfony/property-info": "^4.4|^5.1|^6.0", - "symfony/routing": "^4.4|^5.0|^6.0", - "symfony/translation": "^5.2|^6.0", - "symfony/yaml": "^4.4|^5.0|^6.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0", "symfony/security-acl": "^2.8|^3.0", - "symfony/security-core": "^4.4|^5.0|^6.0", - "symfony/security-csrf": "^4.4|^5.0|^6.0", - "symfony/security-http": "^4.4|^5.0|^6.0", - "symfony/serializer": "^5.4.35|~6.3.12|^6.4.3", - "symfony/stopwatch": "^4.4|^5.0|^6.0", - "symfony/console": "^5.3|^6.0", - "symfony/expression-language": "^4.4|^5.0|^6.0", - "symfony/web-link": "^4.4|^5.0|^6.0", - "symfony/workflow": "^5.2|^6.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/security-csrf": "^6.4|^7.0", + "symfony/security-http": "^6.4|^7.0", + "symfony/serializer": "^6.4.3|^7.0.3", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/web-link": "^6.4|^7.0", + "symfony/workflow": "^6.4|^7.0", "twig/cssinliner-extra": "^2.12|^3", "twig/inky-extra": "^2.12|^3", "twig/markdown-extra": "^2.12|^3" @@ -55,28 +58,14 @@ "conflict": { "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", - "symfony/console": "<5.3", - "symfony/form": "<5.4.21|>=6,<6.2.7", - "symfony/http-foundation": "<5.3", - "symfony/http-kernel": "<4.4", - "symfony/translation": "<5.2", - "symfony/workflow": "<5.2" - }, - "suggest": { - "symfony/finder": "", - "symfony/asset": "For using the AssetExtension", - "symfony/form": "For using the FormExtension", - "symfony/http-kernel": "For using the HttpKernelExtension", - "symfony/routing": "For using the RoutingExtension", - "symfony/translation": "For using the TranslationExtension", - "symfony/yaml": "For using the YamlExtension", - "symfony/security-core": "For using the SecurityExtension", - "symfony/security-csrf": "For using the CsrfExtension", - "symfony/security-http": "For using the LogoutUrlExtension", - "symfony/stopwatch": "For using the StopwatchExtension", - "symfony/var-dumper": "For using the DumpExtension", - "symfony/expression-language": "For using the ExpressionExtension", - "symfony/web-link": "For using the WebLinkExtension" + "symfony/console": "<6.4", + "symfony/form": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/mime": "<6.4", + "symfony/serializer": "<6.4", + "symfony/translation": "<6.4", + "symfony/workflow": "<6.4" }, "autoload": { "psr-4": { "Symfony\\Bridge\\Twig\\": "" }, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6e1ada1b..e5a59c8c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - - + + ./ - - ./Resources - ./Tests - ./vendor - - - + + + ./Resources + ./Tests + ./vendor + +