Split user language (UI translation) from locale (formatted values) (#4595)

This commit is contained in:
Kevin Papst
2024-01-30 00:09:53 +01:00
committed by GitHub
parent 12ef19df28
commit df3ca9d5a9
39 changed files with 685 additions and 881 deletions

View File

@@ -83,7 +83,7 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
$user = $this->security->getUser();
if ($user !== null) {
$locale = $user->getLocale();
$locale = $user->getLanguage();
}
if (null !== $error->getPlural()) {

View File

@@ -29,15 +29,22 @@ use Symfony\Component\Intl\Locales;
#[AsCommand(name: 'kimai:reset:locales')]
final class RegenerateLocalesCommand extends Command
{
private string $defaultDate = 'dd.MM.y';
private string $defaultTime = 'HH:mm';
private array $rtlLocales = [
'ar' => true,
'fa' => true,
'he' => true,
];
/**
* @var string[]
*/
private array $rtlLocales = ['ar', 'fa', 'he'];
/**
* new locales were added here, to shrink the list a little bit
* this can be removed in the future, if there will ever be the need for it
*
* @var string[]
*/
private array $noRegionCode = ['ar', 'id', 'pa', 'sl'];
public function __construct(private LocaleService $localeService, private string $projectDirectory, private string $kernelEnvironment)
public function __construct(
private readonly string $projectDirectory,
private readonly string $kernelEnvironment
)
{
parent::__construct();
}
@@ -55,32 +62,44 @@ final class RegenerateLocalesCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$locales = $this->localeService->getAllLocales();
// detect all registered locales and allow to choose them as well, so people get to
// choose the language for translation with the correct format of their location
/*
// find all available locales from the translation filenames
$translationFilenames = glob($this->projectDirectory . DIRECTORY_SEPARATOR . 'translations/*.xlf');
if ($translationFilenames === false) {
$io->error('Failed reading translation files');
return Command::FAILURE;
}
$firstLevelLocales = [];
foreach ($translationFilenames as $file) {
$firstLevelLocales[] = explode('.', basename($file))[1];
}
$firstLevelLocales = array_unique($firstLevelLocales);
$io->title('Locales found from translation files');
$io->writeln(implode('|', $firstLevelLocales));
$secondLevel = [];
foreach (Locales::getLocales() as $locale) {
if (substr_count($locale, '_') === 1) {
$baseLocale = substr($locale, 0, strpos($locale, '_'));
if (in_array($baseLocale, $locales)) {
$subLocale = substr($locale, strpos($locale, '_') + 1);
if (!is_numeric($subLocale)) {
$secondLevel[] = $locale;
foreach (Locales::getLocales() as $localeCode) {
$locale = explode('_', $localeCode);
if (\count($locale) === 2 && !\in_array($locale[0], $this->noRegionCode, true)) {
$baseLocale = $locale[0];
if (\in_array($baseLocale, $firstLevelLocales)) {
$regionCode = $locale[1];
if (!is_numeric($regionCode)) {
$secondLevel[] = $localeCode;
}
}
}
}
$locales = array_merge($locales, $secondLevel);
*/
sort($firstLevelLocales);
sort($secondLevel);
// keep the locales that have translation filesat the begin
// the config is than easier to read and the locales will be sorted in the UI anyway
$locales = array_merge($firstLevelLocales, $secondLevel);
$appLocales = [];
$defaults = [
'date' => $this->defaultDate,
'time' => $this->defaultTime,
'rtl' => false,
];
// make sure all allowed locales are registered
foreach ($locales as $locale) {
@@ -88,11 +107,13 @@ final class RegenerateLocalesCommand extends Command
continue;
}
$appLocales[$locale] = $defaults;
$appLocales[$locale] = LocaleService::DEFAULT_SETTINGS;
}
// make sure all keys are registered for every locale
foreach ($appLocales as $locale => $settings) {
$settings['translation'] = \in_array($locale, $firstLevelLocales, true);
// these are completely new since v2
// calculate everything with IntlFormatter
$shortDate = new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE);
@@ -115,14 +136,50 @@ final class RegenerateLocalesCommand extends Command
$rtlLocale = substr($rtlLocale, 0, strpos($rtlLocale, '_'));
}
if (\array_key_exists($rtlLocale, $this->rtlLocales)) {
$settings['rtl'] = $this->rtlLocales[$rtlLocale];
}
$settings['rtl'] = \in_array($rtlLocale, $this->rtlLocales, true);
// pre-fill all formats with the default locale settings
$appLocales[$locale] = $settings;
}
$removableDuplicates = [];
foreach ($appLocales as $locale => $setting) {
$localeParts = explode('_', $locale);
if (\count($localeParts) === 1) {
continue;
}
// e.g. norwegian just exists with region code
if (!\array_key_exists($localeParts[0], $appLocales)) {
continue;
}
$baseLocaleSettings = $appLocales[$localeParts[0]];
if ($baseLocaleSettings['time'] !== $setting['time']) {
continue;
}
if ($baseLocaleSettings['date'] !== $setting['date']) {
continue;
}
if ($setting['translation'] === true) {
continue;
}
if ($baseLocaleSettings['rtl'] !== $setting['rtl']) {
continue;
}
$removableDuplicates[] = $locale;
}
$io->title('Redundant locales that will be skipped');
$io->writeln(implode('|', $removableDuplicates));
foreach ($removableDuplicates as $duplicate) {
unset($appLocales[$duplicate]);
}
// in the future this list should be reduced to the list of available translations, but for a long time users
// could choose from the entire list of all locales, so we likely have to keep that forever ...
$io->title('List of app_locales for services.yaml');
$io->writeln(implode('|', $locales));
ksort($appLocales);
$filename = 'config/locales.php';

View File

@@ -70,7 +70,7 @@ final class UserLoginLinkCommand extends Command
}
$request = new Request();
$request->setLocale($user->getLocale());
$request->setLocale($user->getLanguage());
$this->requestStack->push($request);
$loginLinkDetails = $this->loginLink->createLoginLink($user, $request);

View File

@@ -9,9 +9,21 @@
namespace App\Configuration;
use App\Entity\User;
final class LocaleService
{
public function __construct(private array $languageSettings)
public const DEFAULT_SETTINGS = [
'date' => 'dd.MM.y',
'time' => 'HH:mm',
'rtl' => false,
'translation' => false,
];
/**
* @param array<string, array{'date': string, 'time': string, 'translation': bool}> $languageSettings
*/
public function __construct(private readonly array $languageSettings)
{
}
@@ -25,6 +37,18 @@ final class LocaleService
return array_keys($this->languageSettings);
}
/**
* Returns an array with all language codes that have translations.
*
* @return string[]
*/
public function getTranslatedLocales(): array
{
return array_keys(array_filter($this->languageSettings, function (array $setting) {
return $setting['translation'];
}));
}
public function isKnownLocale(string $language): bool
{
return \in_array($language, $this->getAllLocales());
@@ -38,7 +62,7 @@ final class LocaleService
*/
public function getDateFormat(string $locale): string
{
return $this->getConfig('date', $locale);
return (string) $this->getConfig('date', $locale);
}
/**
@@ -49,7 +73,7 @@ final class LocaleService
*/
public function getTimeFormat(string $locale): string
{
return $this->getConfig('time', $locale);
return (string) $this->getConfig('time', $locale);
}
/**
@@ -76,7 +100,34 @@ final class LocaleService
public function isRightToLeft(string $locale): bool
{
return $this->getConfig('rtl', $locale);
return (bool) $this->getConfig('rtl', $locale);
}
public function isTranslated(string $locale): bool
{
return (bool) $this->getConfig('translation', $locale);
}
public function getNearestTranslationLocale(string $locale): string
{
if (!$this->isKnownLocale($locale)) {
$parts = explode('_', $locale);
if (\count($parts) !== 2 || \strlen($parts[0]) !== 2 || !$this->isKnownLocale($parts[0])) {
return User::DEFAULT_LANGUAGE;
}
$locale = $parts[0];
}
if (!$this->isTranslated($locale)) {
$base = explode('_', $locale)[0];
if (!$this->isTranslated($base)) {
return User::DEFAULT_LANGUAGE;
}
return $base;
}
return $locale;
}
public function is24Hour(string $locale): bool

View File

@@ -22,13 +22,13 @@ use App\Form\Type\CustomerTypePatternType;
use App\Form\Type\DatePickerType;
use App\Form\Type\DateTimeTextType;
use App\Form\Type\DayTimeType;
use App\Form\Type\LanguageType;
use App\Form\Type\MinuteIncrementType;
use App\Form\Type\ProjectTypePatternType;
use App\Form\Type\RoundingModeType;
use App\Form\Type\SkinType;
use App\Form\Type\TimezoneType;
use App\Form\Type\TrackingModeType;
use App\Form\Type\UserLanguageType;
use App\Form\Type\WeekDaysType;
use App\Form\Type\YesNoType;
use App\Timesheet\LockdownService;
@@ -504,7 +504,7 @@ final class SystemConfigurationController extends AbstractController
->setOptions(['help' => 'default_value_new']),
(new Configuration('defaults.user.language'))
->setLabel('language')
->setType(LanguageType::class)
->setType(UserLanguageType::class)
->setOptions(['help' => 'default_value_new']),
(new Configuration('defaults.user.theme'))
->setLabel('skin')

View File

@@ -11,9 +11,10 @@ namespace App\Controller;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Form\Type\LanguageType;
use App\Form\Type\SkinType;
use App\Form\Type\TimezoneType;
use App\Form\Type\UserLanguageType;
use App\Form\Type\UserLocaleType;
use App\Form\UserPasswordType;
use App\User\UserService;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
@@ -44,14 +45,16 @@ final class WizardController extends AbstractController
if ($wizard === 'profile') {
$data = [
UserPreference::LOCALE => $request->getLocale(),
UserPreference::LANGUAGE => $user->getPreferenceValue(UserPreference::LANGUAGE, $request->getLocale(), false),
UserPreference::LOCALE => $user->getPreferenceValue(UserPreference::LOCALE, $request->getLocale(), false),
UserPreference::TIMEZONE => $user->getTimezone(),
UserPreference::SKIN => $user->getSkin(),
'reload' => '0',
];
$form = $this->createFormBuilder($data)
->add(UserPreference::LOCALE, LanguageType::class)
->add(UserPreference::LANGUAGE, UserLanguageType::class)
->add(UserPreference::LOCALE, UserLocaleType::class, ['help' => null])
->add(UserPreference::TIMEZONE, TimezoneType::class)
->add(UserPreference::SKIN, SkinType::class)
->add('reload', HiddenType::class)
@@ -69,7 +72,8 @@ final class WizardController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
/** @var array<string, string> $data */
$data = $form->getData();
$user->setLanguage($data[UserPreference::LOCALE]);
$user->setLanguage($data[UserPreference::LANGUAGE]);
$user->setLocale($data[UserPreference::LOCALE]);
$user->setTimezone($data[UserPreference::TIMEZONE]);
$user->setPreferenceValue(UserPreference::SKIN, $data[UserPreference::SKIN]);
$user->setWizardAsSeen('profile');

View File

@@ -9,6 +9,7 @@
namespace App\DependencyInjection;
use App\Configuration\LocaleService;
use App\Kernel;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -112,12 +113,6 @@ final class AppExtension extends Extension
$settings = include $directory . DIRECTORY_SEPARATOR . 'config/locales.php';
$appLocales = [];
$defaults = [
'date' => 'dd.MM.y',
'time' => 'HH:mm',
'rtl' => false,
];
// make sure all allowed locales are registered
foreach ($locales as $locale) {
// unlikely that a locale disappears, but in case that a new symfony update comes with changed locales
@@ -125,7 +120,7 @@ final class AppExtension extends Extension
continue;
}
$appLocales[$locale] = $defaults;
$appLocales[$locale] = LocaleService::DEFAULT_SETTINGS;
if (\array_key_exists($locale, $settings)) {
$appLocales[$locale] = array_merge($appLocales[$locale], $settings[$locale]);

View File

@@ -318,7 +318,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
}
/**
* Read-only list of of all visible user preferences.
* Read-only list of all visible user preferences.
*
* @internal only for API usage
* @return UserPreference[]
@@ -334,6 +334,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
$skip = [
UserPreference::TIMEZONE,
UserPreference::LOCALE,
UserPreference::LANGUAGE,
UserPreference::SKIN,
'calendar_initial_view',
'login_initial_view',
@@ -406,13 +407,22 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return null;
}
/**
* The locale used for formatting number, money, dates and times
*/
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('language')]
#[Serializer\SerializedName('locale')]
#[Serializer\Groups(['User_Entity'])]
#[OA\Property(type: 'string')]
public function getLocale(): string
{
return $this->getPreferenceValue(UserPreference::LOCALE, User::DEFAULT_LANGUAGE, false);
// uses language as fallback, because the language was here before
return (string) $this->getPreferenceValue(UserPreference::LOCALE, $this->getLanguage(), false);
}
public function setLocale(?string $locale): void
{
$this->setPreferenceValue(UserPreference::LOCALE, $locale ?? User::DEFAULT_LANGUAGE);
}
#[Serializer\VirtualProperty]
@@ -424,17 +434,21 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return $this->getPreferenceValue(UserPreference::TIMEZONE, date_default_timezone_get(), false);
}
/**
* The locale used for translations
*/
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('language')]
#[Serializer\Groups(['User_Entity'])]
#[OA\Property(type: 'string')]
public function getLanguage(): string
{
return $this->getLocale();
return (string) $this->getPreferenceValue(UserPreference::LANGUAGE, User::DEFAULT_LANGUAGE, false);
}
public function setLanguage(?string $language): void
{
if ($language === null) {
$language = User::DEFAULT_LANGUAGE;
}
$this->setPreferenceValue(UserPreference::LOCALE, $language);
$this->setPreferenceValue(UserPreference::LANGUAGE, $language ?? User::DEFAULT_LANGUAGE);
}
public function isFirstDayOfWeekSunday(): bool

View File

@@ -28,7 +28,8 @@ class UserPreference
public const HOURLY_RATE = 'hourly_rate';
public const INTERNAL_RATE = 'internal_rate';
public const SKIN = 'skin';
public const LOCALE = 'language';
public const LANGUAGE = 'language';
public const LOCALE = 'locale';
public const TIMEZONE = 'timezone';
public const FIRST_WEEKDAY = 'first_weekday';
public const WORK_HOURS_MONDAY = 'work_monday';

View File

@@ -24,7 +24,11 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInt
*/
final class ThemeOptionsSubscriber implements EventSubscriberInterface
{
public function __construct(private TokenStorageInterface $storage, private ContextHelper $helper, private LocaleService $localeService)
public function __construct(
private readonly TokenStorageInterface $storage,
private readonly ContextHelper $helper,
private readonly LocaleService $localeService
)
{
}
@@ -44,7 +48,7 @@ final class ThemeOptionsSubscriber implements EventSubscriberInterface
$this->helper->setAssetVersion((string) Constants::VERSION_ID);
if ($this->localeService->isRightToLeft(\Locale::getDefault())) {
if ($this->localeService->isRightToLeft($event->getRequest()->getLocale())) {
$this->helper->setIsRightToLeft(true);
}

View File

@@ -10,6 +10,7 @@
namespace App\EventSubscriber;
use App\Entity\User;
use App\Twig\LocaleFormatExtensions;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
@@ -18,7 +19,11 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class UserEnvironmentSubscriber implements EventSubscriberInterface
{
public function __construct(private TokenStorageInterface $tokenStorage, private AuthorizationCheckerInterface $auth)
public function __construct(
private readonly TokenStorageInterface $tokenStorage,
private readonly AuthorizationCheckerInterface $auth,
private readonly LocaleFormatExtensions $localeFormatExtensions
)
{
}
@@ -36,19 +41,21 @@ final class UserEnvironmentSubscriber implements EventSubscriberInterface
return;
}
// the locale depends on the request, not on the user configuration
\Locale::setDefault($event->getRequest()->getLocale());
$locale = $event->getRequest()->getLocale();
// ignore events like the toolbar where we do not have a token
if (null === ($token = $this->tokenStorage->getToken())) {
return;
// events like the toolbar might not have a token
if (null !== ($token = $this->tokenStorage->getToken())) {
$user = $token->getUser();
if ($user instanceof User) {
$locale = $user->getLocale();
date_default_timezone_set($user->getTimezone());
$user->initCanSeeAllData($this->auth->isGranted('view_all_data'));
}
}
$user = $token->getUser();
if ($user instanceof User) {
date_default_timezone_set($user->getTimezone());
$user->initCanSeeAllData($this->auth->isGranted('view_all_data'));
}
// the locale is primarily used for formatting values, so we depend on the user locale if available
\Locale::setDefault($locale);
$this->localeFormatExtensions->setLocale($locale);
}
}

View File

@@ -22,6 +22,7 @@ use App\Form\Type\InitialViewType;
use App\Form\Type\SkinType;
use App\Form\Type\TimezoneType;
use App\Form\Type\UserLanguageType;
use App\Form\Type\UserLocaleType;
use App\Form\Type\YesNoType;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@@ -84,11 +85,16 @@ final class UserPreferenceSubscriber implements EventSubscriberInterface
->setSection('locale')
->setType(TimezoneType::class),
(new UserPreference(UserPreference::LOCALE, $this->systemConfiguration->getUserDefaultLanguage()))
(new UserPreference(UserPreference::LANGUAGE, $this->systemConfiguration->getUserDefaultLanguage()))
->setOrder(250)
->setSection('locale')
->setType(UserLanguageType::class),
(new UserPreference(UserPreference::LOCALE, $this->systemConfiguration->getUserDefaultLanguage()))
->setOrder(250)
->setSection('locale')
->setType(UserLocaleType::class),
(new UserPreference(UserPreference::FIRST_WEEKDAY, User::DEFAULT_FIRST_WEEKDAY))
->setOrder(300)
->setSection('locale')

View File

@@ -12,10 +12,9 @@ namespace App\Form\Helper;
use App\Configuration\LocaleService;
use App\Configuration\SystemConfiguration;
use App\Entity\Project;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final class ProjectHelper implements LocaleAwareInterface
final class ProjectHelper
{
public const PATTERN_NAME = '{name}';
public const PATTERN_COMMENT = '{comment}';
@@ -33,7 +32,11 @@ final class ProjectHelper implements LocaleAwareInterface
private bool $showEnd = false;
private ?string $locale = null;
public function __construct(private SystemConfiguration $configuration, private LocaleService $localeService, private TranslatorInterface $translator)
public function __construct(
private readonly SystemConfiguration $configuration,
private readonly LocaleService $localeService,
private readonly TranslatorInterface $translator
)
{
}
@@ -42,7 +45,7 @@ final class ProjectHelper implements LocaleAwareInterface
return $this->locale ?? \Locale::getDefault();
}
public function setLocale(string $locale): void
public function setLocale(?string $locale): void
{
$this->locale = $locale;
}

View File

@@ -13,6 +13,7 @@ use App\Configuration\LocaleService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Intl\Locales;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -20,21 +21,32 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
final class LanguageType extends AbstractType
{
public function __construct(private LocaleService $localeService)
public function __construct(private readonly LocaleService $localeService)
{
}
public function configureOptions(OptionsResolver $resolver): void
{
$choices = [];
foreach ($this->localeService->getAllLocales() as $key) {
$name = ucfirst(Locales::getName($key, $key));
$choices[$name] = $key;
}
$resolver->setDefault('choices', function (Options $options) {
$choices = [];
if ($options['translated_only'] === true) {
$locales = $this->localeService->getTranslatedLocales();
} else {
$locales = $this->localeService->getAllLocales();
}
foreach ($locales as $key) {
$name = ucfirst(Locales::getName($key, $key));
$choices[$name] = $key;
}
return $choices;
});
$resolver->setDefaults([
'choices' => $choices,
'label' => 'language',
'translated_only' => false,
'choice_translation_domain' => false,
]);
}

View File

@@ -16,6 +16,8 @@ use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class TimePickerType extends AbstractType
@@ -40,6 +42,11 @@ final class TimePickerType extends AbstractType
]);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['format'] = $options['format'];
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(

View File

@@ -9,30 +9,43 @@
namespace App\Form\Type;
use App\Configuration\LocaleService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Custom form field type to select the user language.
* Custom form field type to select the user language, which is used to translate the UI.
* @extends AbstractType<string>
*/
final class UserLanguageType extends AbstractType
{
public function __construct(private UrlGeneratorInterface $router, private TranslatorInterface $translator)
public function __construct(private readonly LocaleService $localeService)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new CallbackTransformer(
function ($value) {
if ($value === null) {
return null;
}
return $this->localeService->getNearestTranslationLocale($value);
},
function ($value) {
return $value;
}
));
}
public function configureOptions(OptionsResolver $resolver): void
{
$route = $this->router->generate('help_locales');
$message = $this->translator->trans('user.language.help');
$moreLink = $this->translator->trans('help_locales');
$resolver->setDefaults([
'help_html' => true,
'help' => sprintf('%2$s <a href="%1$s" target="help_locales">%3$s</a>', $route, $message, $moreLink)
'label' => 'language',
'translated_only' => true,
]);
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Custom form field type to select the user locale, which is used to format date/time/money/number values.
*
* @extends AbstractType<string>
*/
final class UserLocaleType extends AbstractType
{
public function __construct(
private readonly UrlGeneratorInterface $router,
private readonly TranslatorInterface $translator
)
{
}
public function configureOptions(OptionsResolver $resolver): void
{
$route = $this->router->generate('help_locales');
$moreLink = $this->translator->trans('help_locales');
$resolver->setDefaults([
'label' => 'locale',
'help_html' => true,
'help' => sprintf('<a href="%1$s" target="help_locales">%2$s</a>', $route, $moreLink)
]);
}
public function getParent(): string
{
return LanguageType::class;
}
}

View File

@@ -15,6 +15,7 @@ use App\Form\Type\AvatarType;
use App\Form\Type\MailType;
use App\Form\Type\TimezoneType;
use App\Form\Type\UserLanguageType;
use App\Form\Type\UserLocaleType;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use Symfony\Component\Form\AbstractType;
@@ -80,6 +81,10 @@ class UserEditType extends AbstractType
'required' => true,
]);
$builder->add('locale', UserLocaleType::class, [
'required' => true,
]);
$builder->add('timezone', TimezoneType::class, [
'required' => true,
]);

View File

@@ -24,11 +24,11 @@ final class FormFormatConverter
* This defines the mapping between ICU date format and PHP Date format.
*
* @see https://www.php.net/manual/en/datetime.format.php
* @var array
* @var array<string, string>
*/
private static array $formatConvertRules = [
// Litepicker interprets a year like 22 as 1922 instead of 2022
// so we have to make sure that it is always a4-digit year
// so we have to make sure that it is always a 4-digit year
"'h'" => "\h", // special format for fr_CA which includes 'h' as character
'yy' => 'yyyy',
'y' => 'yyyy',
@@ -47,10 +47,6 @@ final class FormFormatConverter
/**
* This works with ICU and DateTime format.
*
* @param string $format
* @param bool $html
* @return string
*/
public function convertToPattern(string $format, bool $html = true): string
{
@@ -83,8 +79,8 @@ final class FormFormatConverter
$pattern = str_replace('g', self::PATTERN_HOUR_SINGLE, $pattern);
$pattern = str_replace('i', self::PATTERN_MINUTES, $pattern);
$pattern = str_replace('mm', self::PATTERN_MINUTES, $pattern);
$pattern = str_replace('A', '(AM|PM){1}', $pattern);
$pattern = str_replace('a', '(AM|PM){1}', $pattern);
$pattern = str_replace('A', '(AM|PM|am|pm){1}', $pattern);
$pattern = str_replace('a', '(AM|PM|am|pm){1}', $pattern);
$pattern = str_replace('*****', 'h', $pattern);
if (!$html) {