Skip to content

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

Open
codeconsole wants to merge 4 commits into
apache:8.0.xfrom
codeconsole:feat/i18n-available-locale-selector
Open

Add available-locale discovery and a language selector to i18n#15940
codeconsole wants to merge 4 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 75.00000% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.4143%. Comparing base (b5e583a) to head (8f9d7aa).
⚠️ Report is 4 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...g/grails/plugins/i18n/AvailableLocaleResolver.java 80.8511% 4 Missing and 5 partials ⚠️
...vy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy 53.8462% 4 Missing and 2 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.4143%   +0.0234%     
- Complexity      16808      16828        +20     
==================================================
  Files            1993       1994         +1     
  Lines           93495      93562        +67     
  Branches        16360      16375        +15     
==================================================
+ Hits            46178      46233        +55     
- Misses          40180      40184         +4     
- Partials         7137       7145         +8     
Files with missing lines Coverage Δ
...org/grails/plugins/i18n/I18nAutoConfiguration.java 76.6667% <100.0000%> (+1.6667%) ⬆️
...vy/org/grails/plugins/web/taglib/FormTagLib.groovy 76.4808% <66.6667%> (-0.1448%) ⬇️
...vy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy 14.2857% <53.8462%> (+14.2857%) ⬆️
...g/grails/plugins/i18n/AvailableLocaleResolver.java 80.8511% <80.8511%> (ø)

... and 4 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
@testlens-app

testlens-app Bot commented Jul 10, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 8f9d7aa
▶️ Tests: 17712 executed
⚪️ Checks: 61/61 completed


Learn more about TestLens at testlens.app.

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants