diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 81ceddf9ca4..f820c9e6770 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -66,6 +66,7 @@ - Entry types are now managed independently of sections. - Entry types are no longer required to have a Title Format, if the Title field isn’t shown. - Entry types now have a “Show the Slug field” setting. ([#13799](https://github.com/craftcms/cms/discussions/13799)) +- Sites’ Language settings can now be set to environment variables. ([#14235](https://github.com/craftcms/cms/pull/14235), [#14135](https://github.com/craftcms/cms/discussions/14135)) - Added the “Addresses” field type. ([#11438](https://github.com/craftcms/cms/discussions/11438)) - Matrix fields now manage nested entries, rather than Matrix blocks. During the upgrade, existing Matrix block types will be converted to entry types; their nested fields will be made global; and Matrix blocks will be converted to entries. - Matrix fields now have “Entry URI Format” and “Template” settings for each site. @@ -110,7 +111,7 @@ - Migrations that modify the project config no longer need to worry about whether the same changes were already applied to the incoming project config YAML files. - Selectize menus no longer apply special styling to options with the value `new`. The `_includes/forms/selectize.twig` control panel template should be used instead (or `craft\helpers\Cp::selectizeHtml()`/`selectizeFieldHtml()`), which will append an styled “Add” option when `addOptionFn` and `addOptionLabel` settings are passed. ([#11946](https://github.com/craftcms/cms/issues/11946)) - Added the `chip()`, `customSelect()`, `disclosureMenu()`, `elementCard()`, `elementChip()`, `elementIndex()`, `iconSvg()`, and `siteMenuItems()` global functions for control panel templates. -- Added the `colorSelect` and `colorSelectField` form macros. +- Added the `colorSelect`, `colorSelectField`, `languageMenu`, and `languageMenuField` form macros. - The `assets/move-asset` and `assets/move-folder` actions no longer include `success` keys in responses. ([#12159](https://github.com/craftcms/cms/pull/12159)) - The `assets/upload` controller action now includes `errors` object in failure responses. ([#12159](https://github.com/craftcms/cms/pull/12159)) - Element action triggers’ `validateSelection()` and `activate()` methods are now passed an `elementIndex` argument, with a reference to the trigger’s corresponding element index. @@ -293,6 +294,8 @@ - Added `craft\models\FieldLayout::getThumbField()`. - Added `craft\models\FsListing::getAdjustedUri()`. - Added `craft\models\Section::getCpEditUrl()`. +- Added `craft\models\Site::getLanguage()`. +- Added `craft\models\Site::setLanguage()`. - Added `craft\models\Volume::$altTranslationKeyFormat`. - Added `craft\models\Volume::$altTranslationMethod`. - Added `craft\models\Volume::getSubpath()`. diff --git a/CHANGELOG.md b/CHANGELOG.md index ef06b43263c..f1cf56a02df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Release Notes for Craft CMS 5 +## Unreleased + +- Sites’ Language settings can now be set to environment variables. ([#14235](https://github.com/craftcms/cms/pull/14235), [#14135](https://github.com/craftcms/cms/discussions/14135)) +- Added the `languageMenu` and `languageMenuField` form macros. +- Added `craft\models\Site::getLanguage()`. +- Added `craft\models\Site::setLanguage()`. + ## 5.0.0-alpha.9 - 2024-01-29 - Added live conditional field support to inline-editable Matrix blocks. ([#14223](https://github.com/craftcms/cms/pull/14223)) diff --git a/src/config/app.php b/src/config/app.php index 97088d55928..d95f3725db8 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -4,7 +4,7 @@ 'id' => 'CraftCMS', 'name' => 'Craft CMS', 'version' => '5.0.0-alpha.9', - 'schemaVersion' => '5.0.0.16', + 'schemaVersion' => '5.0.0.17', 'minVersionRequired' => '4.4.0', 'basePath' => dirname(__DIR__), // Defines the @app alias 'runtimePath' => '@storage/runtime', // Defines the @runtime alias diff --git a/src/controllers/SitesController.php b/src/controllers/SitesController.php index 057ed5f5300..10bfa63c5a2 100644 --- a/src/controllers/SitesController.php +++ b/src/controllers/SitesController.php @@ -266,22 +266,6 @@ public function actionEditSite(?int $siteId = null, ?Site $siteModel = null, ?in ], ]; - $languageOptions = []; - $languageId = Craft::$app->getLocale()->getLanguageID(); - - foreach (Craft::$app->getI18n()->getAllLocales() as $locale) { - $languageOptions[] = [ - 'label' => $locale->getDisplayName(Craft::$app->language), - 'value' => $locale->id, - 'data' => [ - 'data' => [ - 'hint' => $locale->id, - 'keywords' => $locale->getLanguageID() !== $languageId ? $locale->getDisplayName() : false, - ], - ], - ]; - } - return $this->renderTemplate('settings/sites/_edit.twig', [ 'brandNewSite' => $brandNewSite, 'title' => $title, @@ -289,7 +273,6 @@ public function actionEditSite(?int $siteId = null, ?Site $siteModel = null, ?in 'site' => $siteModel, 'groupId' => $groupId, 'groupOptions' => $groupOptions, - 'languageOptions' => $languageOptions, ]); } diff --git a/src/controllers/UsersController.php b/src/controllers/UsersController.php index fba19005551..9d78e4bd2ce 100644 --- a/src/controllers/UsersController.php +++ b/src/controllers/UsersController.php @@ -23,6 +23,7 @@ use craft\events\InvalidUserTokenEvent; use craft\events\LoginFailureEvent; use craft\events\UserEvent; +use craft\helpers\App; use craft\helpers\ArrayHelper; use craft\helpers\Assets; use craft\helpers\Cp; @@ -1059,61 +1060,29 @@ public function actionPreferences(): Response $response = $this->asEditScreen($user, self::SCREEN_PREFERENCES); $i18n = Craft::$app->getI18n(); - $appLocales = $i18n->getAppLocales(); - ArrayHelper::multisort($appLocales, fn(Locale $locale) => $locale->getDisplayName()); - $languageId = Craft::$app->getLocale()->getLanguageID(); - - $languageOptions = array_map(fn(Locale $locale) => [ - 'label' => $locale->getDisplayName(Craft::$app->language), - 'value' => $locale->id, - 'data' => [ - 'data' => [ - 'hint' => $locale->getLanguageID() !== $languageId ? $locale->getDisplayName() : '', - 'hintLang' => $locale->id, - ], - ], - ], $appLocales); + // user language $userLanguage = $user->getPreferredLanguage(); if ( !$userLanguage || - !ArrayHelper::contains($appLocales, fn(Locale $locale) => $locale->id === $userLanguage) + !ArrayHelper::contains($i18n->getAppLocales(), fn(Locale $locale) => $locale->id === App::parseEnv($userLanguage)) ) { $userLanguage = Craft::$app->language; } - // Formatting Locale - $allLocales = $i18n->getAllLocales(); - ArrayHelper::multisort($allLocales, fn(Locale $locale) => $locale->getDisplayName()); - - $localeOptions = [ - ['label' => Craft::t('app', 'Same as language'), 'value' => ''], - ]; - array_push($localeOptions, ...array_map(fn(Locale $locale) => [ - 'label' => $locale->getDisplayName(Craft::$app->language), - 'value' => $locale->id, - 'data' => [ - 'data' => [ - 'hint' => $locale->getLanguageID() !== $languageId ? $locale->getDisplayName() : false, - 'hintLang' => $locale->id, - ], - ], - ], $allLocales)); - + // user locale $userLocale = $user->getPreferredLocale(); if ( !$userLocale || - !ArrayHelper::contains($allLocales, fn(Locale $locale) => $locale->id === $userLocale) + !ArrayHelper::contains($i18n->getAllLocales(), fn(Locale $locale) => $locale->id === App::parseEnv($userLocale)) ) { $userLocale = Craft::$app->getConfig()->getGeneral()->defaultCpLocale; } $response->action('users/save-preferences'); $response->contentTemplate('users/_preferences', compact( - 'languageOptions', - 'localeOptions', 'userLanguage', 'userLocale', )); diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 9784428d217..84f383a1c27 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -606,7 +606,7 @@ public function createTables(): void 'enabled' => $this->string()->notNull()->defaultValue('true'), 'name' => $this->string()->notNull(), 'handle' => $this->string()->notNull(), - 'language' => $this->string(12)->notNull(), + 'language' => $this->string()->notNull(), 'hasUrls' => $this->boolean()->defaultValue(false)->notNull(), 'baseUrl' => $this->string(), 'sortOrder' => $this->smallInteger()->unsigned(), diff --git a/src/migrations/m240129_150719_sites_language_amend_length.php b/src/migrations/m240129_150719_sites_language_amend_length.php new file mode 100644 index 00000000000..88b489e0973 --- /dev/null +++ b/src/migrations/m240129_150719_sites_language_amend_length.php @@ -0,0 +1,34 @@ +getDb()->tableExists(Table::SITES)) { + $this->alterColumn(Table::SITES, 'language', $this->string()->notNull()); + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m240129_150719_sites_language_amend_length cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/Site.php b/src/models/Site.php index 62190df36d5..d3310165229 100644 --- a/src/models/Site.php +++ b/src/models/Site.php @@ -26,6 +26,7 @@ * @property bool|string $enabled Enabled * @property string|null $baseUrl The site’s base URL * @property string $name The site’s name + * @property string $language The site’s language * @author Pixel & Tonic, Inc. * @since 3.0.0 */ @@ -46,11 +47,6 @@ class Site extends Model */ public ?string $handle = null; - /** - * @var string|null Name - */ - public ?string $language = null; - /** * @var bool Primary site? */ @@ -102,6 +98,13 @@ class Site extends Model */ private bool|string $_enabled = true; + /** + * @var string|null Language + * @see getLanguage() + * @see setLanguage() + */ + private ?string $_language = null; + /** * Returns the site’s name. * @@ -182,6 +185,29 @@ public function setEnabled(bool|string $name): void $this->_enabled = $name; } + /** + * Returns the site’s language. + * + * @param bool $parse Whether to parse the language for an environment variable + * @return string + * @since 5.0.0 + */ + public function getLanguage(bool $parse = true): string + { + return ($parse ? App::parseEnv($this->_language) : $this->_language) ?? ''; + } + + /** + * Sets the site’s language. + * + * @param string $language + * @since 5.0.0 + */ + public function setLanguage(string $language): void + { + $this->_language = $language; + } + /** * @inheritdoc */ @@ -299,7 +325,7 @@ public function getConfig(): array 'siteGroup' => $this->getGroup()->uid, 'name' => $this->_name, 'handle' => $this->handle, - 'language' => $this->language, + 'language' => $this->getLanguage(false), 'hasUrls' => $this->hasUrls, 'baseUrl' => $this->_baseUrl ?: null, 'sortOrder' => $this->sortOrder, diff --git a/src/templates/_includes/forms.twig b/src/templates/_includes/forms.twig index 89a40319264..670d1739562 100644 --- a/src/templates/_includes/forms.twig +++ b/src/templates/_includes/forms.twig @@ -168,6 +168,11 @@ {% endmacro %} +{% macro languageMenu(config) %} + {% include "_includes/forms/languageMenu" with config only %} +{% endmacro %} + + {% macro fieldLayoutDesigner(config) %} {% include "_includes/forms/fieldLayoutDesigner" with config only %} {% endmacro %} @@ -434,6 +439,19 @@ {% endmacro %} +{% macro languageMenuField(config) %} + {% set config = config|merge({id: config.id ?? "languagemenu#{random()}"}) %} + {% if (config.includeEnvVars ?? false) and config.tip is not defined and (config.value ?? '')[0:1] != '$' %} + {% set config = config|merge({ + tip: 'This can be set to an environment variable with a valid language ID ({examples}).'|t('app', { + examples: '`en`/`en-GB`', + }), + }) %} + {% endif %} + {{ _self.field(config, 'template:_includes/forms/languageMenu') }} +{% endmacro %} + + {% macro fieldLayoutDesignerField(config) %} {{ _self.field({ label: 'Field Layout'|t('app'), diff --git a/src/templates/_includes/forms/languageMenu.twig b/src/templates/_includes/forms/languageMenu.twig new file mode 100644 index 00000000000..d33209a9ae5 --- /dev/null +++ b/src/templates/_includes/forms/languageMenu.twig @@ -0,0 +1,13 @@ +{% set id = id ?? "languagemenu#{random()}" %} +{% set value = value ?? null -%} +{% set options = options ?? [] %} +{% set appOnly = appOnly ?? false %} + + +{% if includeEnvVars ?? false %} + {% set options = options|merge(craft.cp.getLanguageEnvOptions(appOnly)) %} +{% endif %} + +{% include '_includes/forms/selectize' with { + includeEnvVars: false, +} %} diff --git a/src/templates/settings/sites/_edit.twig b/src/templates/settings/sites/_edit.twig index 1f12b240c53..d29a4c40913 100644 --- a/src/templates/settings/sites/_edit.twig +++ b/src/templates/settings/sites/_edit.twig @@ -57,14 +57,15 @@ required: true }) }} - {{ forms.selectizeField({ + {{ forms.languageMenuField({ label: "Language"|t('app'), instructions: "The language content in this site will use."|t('app'), id: 'language', name: 'language', - value: site.language, - options: languageOptions, + value: site.getLanguage(false), + options: craft.cp.getLanguageOptions(true), errors: site.getErrors('language'), + includeEnvVars: true, }) }} {% if (craft.app.isMultiSite or not site.id) %} diff --git a/src/templates/users/_preferences.twig b/src/templates/users/_preferences.twig index cc1c132b5af..7baf72f6517 100644 --- a/src/templates/users/_preferences.twig +++ b/src/templates/users/_preferences.twig @@ -3,21 +3,22 @@

{{ 'General'|t('app') }}

- {{ forms.selectizeField({ + {{ forms.languageMenuField({ id: 'preferredLanguage', name: 'preferredLanguage', label: 'Language'|t('app'), instructions: 'The language that the control panel should use.'|t('app'), - options: languageOptions, + options: craft.cp.getLanguageOptions(false, true, true), value: userLanguage, + appOnly: true, }) }} - {{ forms.selectizeField({ + {{ forms.languageMenuField({ id: 'preferredLocale', name: 'preferredLocale', label: 'Formatting Locale'|t('app'), instructions: 'The locale that should be used for date and number formatting.'|t('app'), - options: localeOptions, + options: [{'label' : 'Same as language'|t('app'), 'value' : ''}]|merge(craft.cp.getLanguageOptions(false, true)), value: userLocale, }) }} diff --git a/src/translations/en/app.php b/src/translations/en/app.php index 88dd6232ff7..25d4066689e 100644 --- a/src/translations/en/app.php +++ b/src/translations/en/app.php @@ -1606,6 +1606,7 @@ 'This can be left blank if you just want an unlabeled separator.' => 'This can be left blank if you just want an unlabeled separator.', 'This can be set to an environment variable matching one of the option values.' => 'This can be set to an environment variable matching one of the option values.', 'This can be set to an environment variable with a boolean value ({examples}).' => 'This can be set to an environment variable with a boolean value ({examples}).', + 'This can be set to an environment variable with a valid language ID ({examples}).' => 'This can be set to an environment variable with a valid language ID ({examples}).', 'This can be set to an environment variable with a value of a [supported time zone]({url}).' => 'This can be set to an environment variable with a value of a [supported time zone]({url}).', 'This can be set to an environment variable, or a Twig template that outputs an ID.' => 'This can be set to an environment variable, or a Twig template that outputs an ID.', 'This can be set to an environment variable, or begin with an alias.' => 'This can be set to an environment variable, or begin with an alias.', diff --git a/src/web/twig/variables/Cp.php b/src/web/twig/variables/Cp.php index e0c67b7a10e..ceb5a199605 100644 --- a/src/web/twig/variables/Cp.php +++ b/src/web/twig/variables/Cp.php @@ -21,6 +21,7 @@ use craft\helpers\Cp as CpHelper; use craft\helpers\StringHelper; use craft\helpers\UrlHelper; +use craft\i18n\Locale; use craft\models\FieldLayout; use craft\models\Site; use craft\models\Volume; @@ -752,6 +753,50 @@ public function getBooleanEnvOptions(): array return $this->_envOptions($options); } + /** + * Returns environment variable options for a language menu. + * + * @param bool $appOnly Whether to limit the env options to those that match available app locales + * @return array + * @since 5.0.0 + */ + public function getLanguageEnvOptions(bool $appOnly = false): array + { + $options = []; + if ($appOnly) { + $allLanguages = array_map(fn(Locale $locale) => $locale->id, Craft::$app->getI18n()->getAppLocales()); + } else { + $allLanguages = array_map(fn(Locale $locale) => $locale->id, Craft::$app->getI18n()->getAllLocales()); + } + + foreach (array_keys($_SERVER) as $var) { + if (!is_string($var)) { + continue; + } + $value = App::env($var); + if ($value === null || $value === '') { + continue; + } + + $languageValue = null; + if (in_array($value, $allLanguages, true)) { + $languageValue = $value; + } + + if ($languageValue !== null) { + $options[] = [ + 'label' => "$$var", + 'value' => "$$var", + 'data' => [ + 'hint' => $languageValue, + ], + ]; + } + } + + return $this->_envOptions($options); + } + /** * @param array $options * @return array @@ -825,6 +870,62 @@ public function getTimeZoneOptions(): array return $options; } + /** + * Returns all known language options for a language input. + * + * @param bool $showLocaleIds Whether to show the hint as locale id; e.g. en, en-GB + * @param bool $showLocalizedNames Whether to show the hint as localizes names; e.g. English, English (United Kingdom) + * @param bool $appLocales Whether to limit the returned locales to just app locales (cp translation options) or show them all + * @return array + * @since 5.0.0 + */ + public function getLanguageOptions( + bool $showLocaleIds = false, + bool $showLocalizedNames = false, + bool $appLocales = false, + ): array { + $options = []; + + $languageId = Craft::$app->getLocale()->getLanguageID(); + + if ($appLocales) { + $allLocales = Craft::$app->getI18n()->getAppLocales(); + } else { + $allLocales = Craft::$app->getI18n()->getAllLocales(); + } + + ArrayHelper::multisort($allLocales, fn(Locale $locale) => $locale->getDisplayName()); + + foreach ($allLocales as $locale) { + $name = $locale->getLanguageID() !== $languageId ? $locale->getDisplayName() : ''; + $option = [ + 'label' => $locale->getDisplayName(Craft::$app->language), + 'value' => $locale->id, + 'data' => [ + 'data' => [ + 'keywords' => $name, + ], + ], + ]; + + $hints = []; + if ($showLocaleIds) { + $hints[] = $locale->id; + } + if ($showLocalizedNames) { + $hints[] = $name; + $option['data']['data']['hintLang'] = $locale->id; + } + if (!empty($hints)) { + $option['data']['data']['hint'] = implode(', ', $hints); + } + + $options[] = $option; + } + + return $options; + } + /** * Returns all options for a filesystem input. *