Skip to content

Add available-locale discovery and a language selector to i18n#15940

Merged
jdaugherty merged 6 commits into
apache:8.0.xfrom
codeconsole:feat/i18n-available-locale-selector
Jul 10, 2026
Merged

Add available-locale discovery and a language selector to i18n#15940
jdaugherty merged 6 commits into
apache:8.0.xfrom
codeconsole:feat/i18n-available-locale-selector

Conversation

@codeconsole

@codeconsole codeconsole commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Grails already ships the switch-language half of i18n — I18nAutoConfiguration wires a SessionLocaleResolver and a ?lang=-driven LocaleChangeInterceptor, and FormTagLib has a g:localeSelect. What was missing was a way to discover which locales an application is actually translated into (g:localeSelect lists all ~150 Locale.getAvailableLocales(), which is not useful as a language switcher) and a ready-made selector in the generated app.

This PR adds that discovery to grails-i18n and surfaces a language dropdown in the default create-app layout. Because a generated web app already ships the messages_*.properties bundles (via the forge default plugins / the base profile skeleton), a freshly created app shows a fully-populated language switcher out of the box with no configuration.

Changes

grails-i18n

  • New AvailableLocaleResolver — scans the classpath for messages_*.properties and returns the translated locales (plus the configurable default locale, grails.i18n.default.locale, default en), sorted by display name and cached.
  • I18nAutoConfiguration registers it as a bean (@ConditionalOnMissingBean).
  • I18nGrailsPlugin publishes the list to the servlet context (availableLocales) on startup and re-scans on dev-mode bundle changes. Reading a servlet-context attribute keeps consumers decoupled from this module (no new compile dependency in grails-gsp / grails-forge).

grails-gsp

  • g:localeSelect gains an available="true" attribute that lists only the translated locales instead of every JVM locale. Default behaviour is unchanged.

Generated app (grails-forge template + classic web profile skeleton)

  • The default main.gsp navbar gets a Bootstrap language dropdown that iterates the published locales and switches via the existing ?lang= interceptor. It renders only when more than one locale is available.

Docs

  • localeSelect tag reference + the "Changing Locales" guide.

Tests

  • AvailableLocaleResolverSpec (resolver discovery, sorting, default locale, caching, immutability)
  • LocaleSelectAvailableSpec (available="true" behaviour + a render test of the generated navbar snippet producing correct ?lang= links)
  • GrailsGspSpec gains a check that the generated layout contains the dropdown

All green.

Update: plugin-contributed locales (default on)

AvailableLocaleResolver now discovers locales contributed by plugins too — their bundles are namespaced (e.g. spring-security-core_fr.properties) and were previously missed. When enabled it scans every root-level *.properties bundle on the classpath and validates each candidate suffix against Locale.getISOLanguages(), so non-i18n files (application.properties, etc.) are ignored.

Controlled by grails.i18n.availableLocales.includePlugins, default true — a selector lists every locale the app or its plugins provide out of the box. Set it to false to restrict discovery to the application's own messages_*.properties.

Introduce AvailableLocaleResolver in grails-i18n, which discovers the
locales an application is actually translated into by scanning the
classpath for messages_*.properties bundles (plus the configurable
default locale, grails.i18n.default.locale). Unlike
Locale.getAvailableLocales(), this returns only real translations, so it
can drive a language selector. The resolver is registered as a bean by
I18nAutoConfiguration and published to the servlet context by
I18nGrailsPlugin, keeping consumers decoupled from the i18n module.

Add an available="true" mode to g:localeSelect that lists only those
locales rather than every JVM locale, and add a Bootstrap language
dropdown to the default create-app layout -- both the grails-forge
template and the classic web profile skeleton -- which switches locale
via the existing ?lang= interceptor.

Includes unit tests for the resolver, the tag, and the generated layout,
plus reference and guide documentation.
Extend AvailableLocaleResolver so it can discover locales contributed by
plugins, whose message bundles are namespaced (e.g.
spring-security-core_fr.properties) and were previously missed by the
messages-only scan. When plugin bundles are included the resolver scans
every *.properties bundle on the classpath and validates each candidate
suffix against Locale.getISOLanguages(), so non-i18n properties files
(application.properties, etc.) are ignored.

This is controlled by grails.i18n.availableLocales.includePlugins, which
defaults to true, so a language selector lists every locale the
application or its plugins provide out of the box. Set it to false to
restrict discovery to the application's own messages_*.properties.
@codeconsole codeconsole force-pushed the feat/i18n-available-locale-selector branch from 5d8ec5d to b756b53 Compare July 8, 2026 18:10
@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.01299% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.5322%. Comparing base (b5e583a) to head (9d79f67).
⚠️ Report is 46 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...g/grails/plugins/i18n/AvailableLocaleResolver.java 92.4528% 1 Missing and 3 partials ⚠️
...vy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy 76.4706% 1 Missing and 3 partials ⚠️
...vy/org/grails/plugins/web/taglib/FormTagLib.groovy 66.6667% 0 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #15940        +/-   ##
==================================================
+ Coverage     49.3909%   49.5322%   +0.1414%     
- Complexity      16808      16919       +111     
==================================================
  Files            1993       1999         +6     
  Lines           93495      93747       +252     
  Branches        16360      16418        +58     
==================================================
+ Hits            46178      46435       +257     
+ Misses          40180      40153        -27     
- Partials         7137       7159        +22     
Files with missing lines Coverage Δ
...org/grails/plugins/i18n/I18nAutoConfiguration.java 82.7586% <100.0000%> (+7.7586%) ⬆️
...vy/org/grails/plugins/web/taglib/FormTagLib.groovy 76.4808% <66.6667%> (-0.1448%) ⬇️
...g/grails/plugins/i18n/AvailableLocaleResolver.java 92.4528% <92.4528%> (ø)
...vy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy 38.4615% <76.4706%> (+38.4615%) ⬆️

... and 34 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codeconsole codeconsole requested review from jamesfredley, jdaugherty, matrei and sbglasius and removed request for jdaugherty July 8, 2026 19:45
…e-locale-selector

# Conflicts:
#	grails-forge/grails-forge-core/src/main/resources/gsp/main.gsp
#	grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GrailsGspSpec.groovy
#	grails-profiles/web/skeleton/grails-app/views/layouts/main.gsp

@borinquenkid borinquenkid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage audit for this PR (grounded in Codecov's patch report + a line-by-line read of the head commit 8f9d7aa)

Codecov: 75% patch coverage, 17 lines uncovered. Per-file: AvailableLocaleResolver.java 80.85% (4 missing/5 partial), I18nGrailsPlugin.groovy 53.85% (4 missing/2 partial), FormTagLib.groovy 66.67% (0 missing/2 partial branches). Below is what those numbers correspond to in the code, confirmed by grepping the actual test files (no guessing from the summary alone).

1. I18nGrailsPlugin.doWithApplicationContext() / publishAvailableLocales() — no plugin-lifecycle test at all

@Override
void doWithApplicationContext() {
publishAvailableLocales()
}
private void publishAvailableLocales() {
def ctx = applicationContext
if (!(ctx instanceof WebApplicationContext)) {
return
}
def servletContext = ((WebApplicationContext) ctx).servletContext
if (servletContext != null && ctx.containsBean('availableLocaleResolver')) {
AvailableLocaleResolver resolver = ctx.getBean('availableLocaleResolver', AvailableLocaleResolver)
servletContext.setAttribute(AVAILABLE_LOCALES_ATTRIBUTE, resolver.availableLocales)
}
}

Nothing in this PR (or pre-existing) boots the plugin against a WebApplicationContext + ServletContext and asserts servletContext.getAttribute('availableLocales') (L66) is actually populated. The two early-return branches are also unexercised:

  • L60-62: ctx is not a WebApplicationContext
  • L64: servletContext == null or the availableLocaleResolver bean is absent

The onChange() addition at L123-126 (clear the resolver's cache and republish when a bundle changes) has no test either — I grepped both new specs (AvailableLocaleResolverSpec, LocaleSelectAvailableSpec) and neither calls onChange or references plugin reload behavior.

2. I18nAutoConfiguration.availableLocaleResolver bean — wiring itself is unasserted

@Bean
@ConditionalOnMissingBean(AvailableLocaleResolver.class)
public AvailableLocaleResolver availableLocaleResolver(GrailsApplication grailsApplication,
@Value("${grails.i18n.default.locale:en}") String defaultLocale,
@Value("${grails.i18n.availableLocales.includePlugins:true}") boolean includePlugins) {
return new AvailableLocaleResolver(grailsApplication.getClassLoader(),
Locale.forLanguageTag(defaultLocale.replace('_', '-')), includePlugins);
}

Every sibling bean in this class (localeResolver, localeChangeInterceptor, messageSource) has a WebApplicationContextRunner-based test in I18nAutoConfigurationSpec.groovy — property-driven variants and @ConditionalOnMissingBean back-off against a user-supplied bean. grep -n "availableLocaleResolver\|AvailableLocaleResolver" I18nAutoConfigurationSpec.groovy returns nothing: the new bean isn't checked for presence, isn't checked for property wiring (grails.i18n.default.locale, grails.i18n.availableLocales.includePlugins), and the @ConditionalOnMissingBean(AvailableLocaleResolver.class) back-off path is untested. AvailableLocaleResolverSpec only exercises new AvailableLocaleResolver(...) directly, not the Spring wiring.

3. grails-profiles/web/skeleton/.../main.gsp — zero coverage (the forge twin got a test, this didn't)

<g:set var="availableLocales" value="${application.getAttribute('availableLocales')}"/>
<g:if test="${availableLocales && availableLocales.size() > 1}">
<g:set var="currentLocale" value="${org.springframework.web.servlet.support.RequestContextUtils.getLocale(request)}"/>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="localeDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-globe me-1"></i>${currentLocale.getDisplayName(currentLocale)}
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="localeDropdown">
<g:each in="${availableLocales}" var="availableLocale">
<li>
<a class="dropdown-item${availableLocale == currentLocale ? ' active' : ''}" href="?lang=${availableLocale.toLanguageTag()}">${availableLocale.getDisplayName(availableLocale)}</a>
</li>
</g:each>
</ul>
</li>
</g:if>

Identical dropdown markup was added to grails-forge/grails-forge-core/src/main/resources/gsp/main.gsp, and that copy got a new GrailsGspSpec test ("test default layout includes the language selector dropdown"). This classic-profile copy has no equivalent — grails-profiles has no src/test source set in the repo at all, so nothing verifies this template even renders, let alone that the g:if gate on size() > 1 behaves.

4. AvailableLocaleResolver — well-tested class, but specific branches are still open (matches Codecov's 4 missing/5 partial)

https://github.com/apache/grails-core/blob/8f9d7aa2bc65769b371bc392fa185df5f3edb261/grails-i18n/src/main/groovy/org/grails/plugins/i18n/AvailableLocaleResolver.java

  • L128: defaultLocale == null or empty-language (Locale.ROOT) — the "don't add a default" branch is never hit by any spec.
  • L143-144: the IOException catch in computeAvailableLocales() — unreachable without mocking ResourcePatternResolver.
  • L157: filename == null / non-.properties filename branch.
  • L162-163: a *.properties file with no underscore, reached only when includePluginBundles=true — currently only covered incidentally by whatever happens to be on the test classpath (e.g. application.properties), not by a deliberate fixture.
  • L166-167: an empty suffix after the underscore (messages_.properties).
  • L170: a non-ISO-language suffix on a deliberate fixture — same incidental-vs-intentional gap as above.

5. FormTagLib.localeSelect — one untested branch

def localeSelect(Map attrs) {
def availableAttr = attrs.remove('available')
boolean availableOnly = availableAttr != null && Boolean.valueOf(availableAttr.toString())
if (availableOnly) {
def availableLocales = request.servletContext?.getAttribute('availableLocales')
attrs.from = availableLocales ?: [RCU.getLocale(request)]
}
else {
attrs.from = Locale.getAvailableLocales()
}
attrs.value = (attrs.value ?: RCU.getLocale(request))?.toString()

LocaleSelectAvailableSpec covers available="true" and the attribute being absent, but never an explicit available="false". L981 (Boolean.valueOf(availableAttr.toString())) has an untested false-but-present path. Confirmed: grep -n "available=\"false\"" in the spec returns nothing.


Findings generated by automated analysis of the Codecov patch report and the PR head commit; reviewed by a human before posting.

Addresses the test-coverage review on apache#15940:

- I18nAutoConfigurationSpec: assert the availableLocaleResolver bean
  registers by default, honours grails.i18n.default.locale (underscore
  form) and grails.i18n.availableLocales.includePlugins, and backs off
  to a user-defined AvailableLocaleResolver bean
- New I18nGrailsPluginSpec: doWithApplicationContext publishes the
  resolved locales to the servlet context, is a no-op outside a
  WebApplicationContext or without the resolver bean, and onChange
  clears the resolver cache and republishes a freshly computed list
- AvailableLocaleResolverSpec: null and language-less default locales
  are not added, classpath scan failure falls back to the default
  locale only, and deliberate malformed-bundle fixtures (no suffix,
  empty suffix, non-ISO suffix) never contribute a locale
- LocaleSelectAvailableSpec: an explicit available="false" keeps the
  full JVM locale list
- I18nGrailsPlugin.onChange: null-guard BuildSettings.RESOURCES_DIR,
  which is null outside a Grails build
@codeconsole

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough audit — all five findings were accurate. Addressed in 170ae02:

1. Plugin lifecycle — Added I18nGrailsPluginSpec covering doWithApplicationContext() publishing the resolved locales to the servlet context, the non-WebApplicationContext and missing-bean early returns, and onChange clearing the resolver cache and republishing a freshly computed list. This also surfaced an NPE in onChange when BuildSettings.RESOURCES_DIR is unset (as in unit tests), now null-guarded.

2. availableLocaleResolver bean wiringI18nAutoConfigurationSpec now asserts the bean registers by default, honours grails.i18n.default.locale (including the underscore form pt_BR) and grails.i18n.availableLocales.includePlugins=false, and backs off to a user-defined AvailableLocaleResolver bean.

3. Web-profile skeleton main.gsp — Acknowledged, but leaving as-is: grails-profiles modules are resource-only packaging projects with no test source sets anywhere in the repo, so there is no existing place to test profile templates. The markup is byte-identical to the forge template, which GrailsGspSpec covers.

4. AvailableLocaleResolver branches — Covered everything reachable through the public API: null and Locale.ROOT default locales, the IOException catch (via a ClassLoader whose getResources throws, through the public constructor), and deliberate malformed-bundle fixtures (standalone.properties, messages_.properties, bogus-plugin_zz.properties) replacing the incidental coverage. The remaining two branches (filename == null, non-.properties filename) are defensive dead code on the scan path — the classpath pattern guarantees .properties filenames — and testing them would require invoking the private method directly, which this repo's testing rules disallow.

5. available="false" — Added an explicit test: with locales published to the servlet context, available="false" still renders the full JVM locale list.

@codeconsole codeconsole requested a review from borinquenkid July 10, 2026 00:45
Comment thread grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy Outdated
Comment thread grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java Outdated
Comment thread grails-i18n/src/main/groovy/org/grails/plugins/i18n/AvailableLocaleResolver.java Outdated
Comment thread grails-forge/grails-forge-core/src/main/resources/gsp/main.gsp Outdated
Comment thread grails-doc/src/en/guide/i18n/changingLocales.adoc
…, stricter suffix validation

- I18nGrailsPlugin now resolves AvailableLocaleResolver by type via
  getBeanProvider instead of the 'availableLocaleResolver' bean name,
  so a user-defined resolver registered under any name (the back-off
  contract of @ConditionalOnMissingBean) is still published; covered
  by a new custom-bean-name test
- I18nAutoConfiguration reuses the existing grails.i18n.default.locale
  field and fixedLocale() (StringUtils.parseLocale, JVM-default
  fallback) for the availableLocaleResolver bean instead of a second
  'en' default parsed with forLanguageTag
- AvailableLocaleResolver validates bundle suffixes by resource-bundle
  convention language(_COUNTRY(_variant)): the country must be an ISO
  country, so foo_en_prod.properties no longer misparses as an en-Prod
  locale; new report_en_prod.properties fixture and country-parsing
  test
- create-app/web-profile layouts highlight the active language by
  language subtag so the highlight also fires when the request locale
  carries a country (e.g. en_US vs en)
- Reorder the changingLocales docs so the custom-switcher example
  directly follows the sentence introducing it
@codeconsole codeconsole requested a review from jdaugherty July 10, 2026 02:19
@testlens-app

testlens-app Bot commented Jul 10, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 9d79f67
▶️ Tests: 31970 executed
⚪️ Checks: 61/61 completed


Learn more about TestLens at testlens.app.

@jdaugherty jdaugherty merged commit 49ca3b9 into apache:8.0.x Jul 10, 2026
64 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants