Add available-locale discovery and a language selector to i18n#15940
Add available-locale discovery and a language selector to i18n#15940codeconsole wants to merge 4 commits into
Conversation
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.
5d8ec5d to
b756b53
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ 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
🚀 New features to boost your workflow:
|
…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
✅ All tests passed ✅🏷️ Commit: 8f9d7aa Learn more about TestLens at testlens.app. |
borinquenkid
left a comment
There was a problem hiding this comment.
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
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:
ctxis not aWebApplicationContext - L64:
servletContext == nullor theavailableLocaleResolverbean 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
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)
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)
- L128:
defaultLocale == nullor empty-language (Locale.ROOT) — the "don't add a default" branch is never hit by any spec. - L143-144: the
IOExceptioncatch incomputeAvailableLocales()— unreachable without mockingResourcePatternResolver. - L157:
filename == null/ non-.propertiesfilename branch. - L162-163: a
*.propertiesfile with no underscore, reached only whenincludePluginBundles=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
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.
Summary
Grails already ships the switch-language half of i18n —
I18nAutoConfigurationwires aSessionLocaleResolverand a?lang=-drivenLocaleChangeInterceptor, andFormTagLibhas ag:localeSelect. What was missing was a way to discover which locales an application is actually translated into (g:localeSelectlists all ~150Locale.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-i18nand surfaces a language dropdown in the defaultcreate-applayout. Because a generated web app already ships themessages_*.propertiesbundles (via the forge default plugins / thebaseprofile skeleton), a freshly created app shows a fully-populated language switcher out of the box with no configuration.Changes
grails-i18nAvailableLocaleResolver— scans the classpath formessages_*.propertiesand returns the translated locales (plus the configurable default locale,grails.i18n.default.locale, defaulten), sorted by display name and cached.I18nAutoConfigurationregisters it as a bean (@ConditionalOnMissingBean).I18nGrailsPluginpublishes 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 ingrails-gsp/grails-forge).grails-gspg:localeSelectgains anavailable="true"attribute that lists only the translated locales instead of every JVM locale. Default behaviour is unchanged.Generated app (
grails-forgetemplate + classicwebprofile skeleton)main.gspnavbar 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
localeSelecttag 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)GrailsGspSpecgains a check that the generated layout contains the dropdownAll green.
Update: plugin-contributed locales (default on)
AvailableLocaleResolvernow 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*.propertiesbundle on the classpath and validates each candidate suffix againstLocale.getISOLanguages(), so non-i18n files (application.properties, etc.) are ignored.Controlled by
grails.i18n.availableLocales.includePlugins, defaulttrue— a selector lists every locale the app or its plugins provide out of the box. Set it tofalseto restrict discovery to the application's ownmessages_*.properties.