From 662a04fd814368287c907dba895017bcffff8557 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Wed, 30 Sep 2026 10:51:49 -0500 Subject: [PATCH] feat(navigation): localize navigation nodes through LocalizedMessagesProvider NavigationNode now resolves name and description via the registered LocalizedMessagesProvider chain (null classifier, key = virtual path), so the REST navigation tree honors provider-based translations without ZK. The metadata controller no longer caches locale-dependent application metadata and global actions; entities metadata stays cached. Refs #115 Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01KmGjWQL17XEG8y8UTMwFLy --- .../ApplicationMetadataController.java | 21 ++----- .../dynamia/navigation/NavigationLabels.java | 55 +++++++++++++++++++ .../dynamia/navigation/NavigationNode.java | 9 ++- .../navigation/NavigationLabelsTest.java | 45 +++++++++++++++ 4 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationLabels.java create mode 100644 platform/core/navigation/src/test/java/tools/dynamia/navigation/NavigationLabelsTest.java diff --git a/platform/app/src/main/java/tools/dynamia/app/controllers/ApplicationMetadataController.java b/platform/app/src/main/java/tools/dynamia/app/controllers/ApplicationMetadataController.java index d287cacc..25765661 100644 --- a/platform/app/src/main/java/tools/dynamia/app/controllers/ApplicationMetadataController.java +++ b/platform/app/src/main/java/tools/dynamia/app/controllers/ApplicationMetadataController.java @@ -68,17 +68,10 @@ public class ApplicationMetadataController { private final ApplicationMetadataLoader metadataLoader; private final EntityMetadata unknowEntity; /** - * Cached application metadata. - */ - private ApplicationMetadata cache; - /** - * Cached entities metadata. + * Cached entities metadata. Application metadata and global actions are deliberately not cached because + * their texts depend on the request locale (e.g. per-tenant), unlike the much larger entities metadata. */ private ApplicationMetadataEntities entities; - /** - * Cached global actions metadata. - */ - private ApplicationMetadataActions globalActions; /** * Cache for individual entity metadata. */ @@ -114,10 +107,7 @@ private void initMetadata() { */ @GetMapping(value = "", produces = "application/json") public ApplicationMetadata getMetadata() { - if (cache == null) { - cache = metadataLoader.load(); - } - return cache; + return metadataLoader.load(); } /** @@ -137,10 +127,7 @@ public NavigationTree getNavigation() { */ @GetMapping(value = "/actions", produces = "application/json") public ApplicationMetadataActions getGlobalActions() { - if (globalActions == null) { - globalActions = metadataLoader.loadGlobalActions(); - } - return globalActions; + return metadataLoader.loadGlobalActions(); } /** diff --git a/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationLabels.java b/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationLabels.java new file mode 100644 index 00000000..f31aa39a --- /dev/null +++ b/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationLabels.java @@ -0,0 +1,55 @@ +package tools.dynamia.navigation; + +import tools.dynamia.commons.LocalizedMessagesProvider; +import tools.dynamia.commons.logger.LoggingService; +import tools.dynamia.integration.Containers; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +/** + * Resolves navigation labels through the registered {@link LocalizedMessagesProvider}s, independent of any UI + * technology. The classifier is always {@code null}: it is the provider's job to derive any grouping it needs + * from the key (the element's virtual path). + */ +final class NavigationLabels { + + private static final LoggingService LOGGER = LoggingService.get(NavigationLabels.class); + + private NavigationLabels() { + } + + /** + * Returns the registered providers sorted by priority (lower number first), or an empty list when no + * container is available. + */ + static List providers() { + try { + return Containers.get().findObjects(LocalizedMessagesProvider.class).stream() + .sorted(Comparator.comparingInt(LocalizedMessagesProvider::getPriority)) + .toList(); + } catch (RuntimeException e) { + return Collections.emptyList(); + } + } + + /** + * Returns the first non-null provider message for the key, or {@code defaultValue} when none provides one. + * A failing provider is skipped so it can never break the navigation tree. + */ + static String resolve(List providers, String key, Locale locale, String defaultValue) { + for (var provider : providers) { + try { + var message = provider.getMessage(key, null, locale, defaultValue); + if (message != null) { + return message; + } + } catch (RuntimeException e) { + LOGGER.warn("Localized messages provider " + provider.getClass().getName() + " failed for key " + key + ": " + e.getMessage()); + } + } + return defaultValue; + } +} diff --git a/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationNode.java b/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationNode.java index 638661db..40e23982 100644 --- a/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationNode.java +++ b/platform/core/navigation/src/main/java/tools/dynamia/navigation/NavigationNode.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import tools.dynamia.commons.Messages; import java.io.Serializable; import java.util.ArrayList; @@ -50,9 +51,13 @@ public NavigationNode(String id, String name, String internalPath) { public NavigationNode(NavigationElement element) { this.element = element; this.id = element.getId(); - this.name = element.getLocalizedName(); + var locale = Messages.getDefaultLocale(); + var providers = NavigationLabels.providers(); + this.name = NavigationLabels.resolve(providers, element.getVirtualPath(), locale, element.getLocalizedName(locale)); this.longName = element.getLongNameSupplier() != null ? (String) element.getLongNameSupplier().get() : element.getLongName(); - this.description = element.getLocalizedDescription(); + var defaultDescription = element.getLocalizedDescription(locale); + this.description = defaultDescription == null || defaultDescription.isBlank() ? defaultDescription + : NavigationLabels.resolve(providers, element.getVirtualPath() + ".description", locale, defaultDescription); this.icon = element.getIcon(); this.internalPath = element.getVirtualPath(); this.path = element.getPrettyVirtualPath(); diff --git a/platform/core/navigation/src/test/java/tools/dynamia/navigation/NavigationLabelsTest.java b/platform/core/navigation/src/test/java/tools/dynamia/navigation/NavigationLabelsTest.java new file mode 100644 index 00000000..7d3dba3f --- /dev/null +++ b/platform/core/navigation/src/test/java/tools/dynamia/navigation/NavigationLabelsTest.java @@ -0,0 +1,45 @@ +package tools.dynamia.navigation; + +import org.junit.jupiter.api.Test; +import tools.dynamia.commons.LocalizedMessagesProvider; + +import java.util.List; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class NavigationLabelsTest { + + private static LocalizedMessagesProvider provider(String value) { + return (key, classifier, locale, defaultValue) -> { + assertNull(classifier); + return value; + }; + } + + @Test + void firstNonNullProviderWins() { + var providers = List.of(provider(null), provider("Ventas"), provider("Sales")); + assertEquals("Ventas", NavigationLabels.resolve(providers, "store/sales", Locale.of("es"), "Sales")); + } + + @Test + void fallsBackToDefaultWithoutProviders() { + assertEquals("Sales", NavigationLabels.resolve(List.of(), "store/sales", Locale.ENGLISH, "Sales")); + } + + @Test + void failingProviderIsSkipped() { + LocalizedMessagesProvider failing = (key, classifier, locale, defaultValue) -> { + throw new IllegalStateException("boom"); + }; + assertEquals("Ventas", NavigationLabels.resolve(List.of(failing, provider("Ventas")), "store/sales", Locale.ENGLISH, "Sales")); + } + + @Test + void nodeUsesLocalizedElementNameWhenNoProviders() { + var page = new Page("sales", "Sales", "store/sales"); + assertEquals("Sales", new NavigationNode(page).getName()); + } +}