Skip to content

Add i18n Support#126

Draft
eric-pSAP wants to merge 6 commits into
mainfrom
i18n
Draft

Add i18n Support#126
eric-pSAP wants to merge 6 commits into
mainfrom
i18n

Conversation

@eric-pSAP
Copy link
Copy Markdown
Contributor

@eric-pSAP eric-pSAP commented May 12, 2026

Adds i18n support beyond hard-coded English. Prereq: #125

@hyperspace-insights
Copy link
Copy Markdown
Contributor

Summary

The following content is AI-generated and provides a summary of the pull request:


Add i18n Support for Notification Types

New Features

✨ This PR introduces internationalization (i18n) support for CDS-defined notification types, moving beyond hardcoded English strings. Notification type templates are now compiled from CDS model annotations and resolved against the full list of 44 ANS-supported languages, with automatic fallback to English when a locale-specific translation is missing.

Key capabilities added:

  • CDS model-based notification type compilation via a new lib/compile.js module, which reads @notification.* annotations from CDS event definitions and generates per-language templates for all ANS-supported locales.
  • {i18n>KEY} resolution in notification template fields — keys are resolved using cds.i18n labels per language, with English fallback.
  • Locale-aware notification propertiesbuildDefaultNotification and buildCustomNotification now use cds.context?.locale instead of a hardcoded "en" for property language fields.
  • Merged notification types from both CDS model and JSON files — types from notifications.cds and notification-types.json are combined at startup and during build.

Changes

  • lib/compile.js (new): Core i18n compilation logic. Reads CDS event definitions annotated with @notification, generates templates for all 44 ANS languages, resolves {i18n>KEY} references via cds.i18n, and exports notificationTypesFromModel.
  • cds-plugin.js: Updated to load notification types from both the CDS model and an optional JSON file at startup; removes the old inline JSON-only approach; added cds.on("loaded") hook for recipient injection.
  • lib/build.js: Refactored build plugin to derive notification types from the CDS model (via notificationTypesFromModel) and merge with optional JSON types; output written as notification-types.json.
  • lib/utils.js: buildDefaultNotification and buildCustomNotification now use cds.context?.locale ?? 'en' for the Language field on notification properties.
  • tests/bookshop/srv/notifications.cds (new): Sample CDS event BookOrdered with {i18n>...} annotation references for testing model-based compilation.
  • tests/bookshop/srv/_i18n/i18n.properties & i18n_de.properties (new): English and German translation files for bookshop test fixtures.
  • tests/bookshop/srv/notification-types.json: Updated to only contain the BookReturned type (no longer duplicating model-defined types).
  • tests/unit/lib/compile.test.js (new): Comprehensive unit tests for notificationTypesFromModel covering i18n resolution, language generation, delivery channel handling, and enum unwrapping.
  • tests/unit/lib/plugin.test.js (new): Unit tests for the cds.on("loaded") recipients injection hook.
  • tests/integration/bookshop.test.js: Extended with integration tests verifying merged types, template language coverage (44 languages), and German i18n resolution.
  • tests/unit/lib/utils.test.js, tests/unit/lib/notificationTypes.test.js, tests/unit/lib/notifications.test.js, tests/unit/srv/notifyToRest.test.js, tests/unit/lib/content-deployment.test.js: Migrated all it(...) calls to test(...) and removed trailing semicolons (style normalization); added locale-aware language tests for default and custom notifications.

  • 🔄 Regenerate and Update Summary
  • ✏️ Insert as PR Description (deletes this comment)
  • 🗑️ Delete comment
PR Bot Information

Version: 1.20.47

  • File Content Strategy: Full file content
  • Summary Prompt: Default Prompt
  • Event Trigger: pull_request.opened
  • Output Template: Default Template
  • LLM: anthropic--claude-4.6-sonnet
  • Correlation ID: 685f3a58-da82-4729-88de-7f2eec06c33d

Copy link
Copy Markdown
Contributor

@hyperspace-insights hyperspace-insights Bot left a comment

Choose a reason for hiding this comment

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

The PR introduces i18n support across the notification pipeline with good test coverage, but has several correctness issues: the most critical is that cds.load() is called redundantly inside the "served" handler instead of using cds.model, meaning the "loaded" hook's recipients injection is bypassed for that model instance. Additionally, resolveEnum().toUpperCase() can throw on non-string channel values, and cds.reflect() in compile.js is inconsistent with how the rest of the codebase iterates definitions directly.

PR Bot Information

Version: 1.20.47

  • File Content Strategy: Full file content
  • Event Trigger: pull_request.opened
  • Correlation ID: 685f3a58-da82-4729-88de-7f2eec06c33d
  • LLM: anthropic--claude-4.6-sonnet

Comment thread lib/compile.js
if (!model) return []
const types = []

for (const def of Object.values(cds.reflect(model).definitions)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Logic Error: notificationTypesFromModel uses cds.reflect(model) on a plain object that may not be a real CDS linked model

In cds-plugin.js, the model is loaded with await cds.load(srvPath) which returns a linked/reflected model, so cds.reflect() there is fine. However, in unit tests (compile.test.js), the function is called with bare plain objects like { definitions: { ... } }. cds.reflect() on a plain object may throw or silently produce an empty/wrong definitions set, making real-world parity with the tests uncertain.

More importantly, the notificationTypesFromModel function called in cds-plugin.js at line 30 and in lib/build.js at line 21 receives model returned by cds.load() (already linked), but in compile.js line 18 it calls cds.reflect(model) which double-reflects it. cds.reflect() is idempotent for already-linked models, but when test helpers pass raw objects the .definitions lookup could return unexpected results.

Consider aligning the implementation to directly iterate model.definitions (as the "loaded" hook in cds-plugin.js already does at line 4), removing the cds.reflect() wrapper, since both call sites already provide a loaded/linked model:

for (const def of Object.values(model.definitions)) {
Suggested change
for (const def of Object.values(cds.reflect(model).definitions)) {
for (const def of Object.values(model.definitions)) {

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment thread lib/compile.js
if (def['@notification.deliveryChannels']?.length) {
type.DeliveryChannels = def['@notification.deliveryChannels'].map(ch => {
if (!ch.channel) return null
const entry = { Type: resolveEnum(ch.channel).toUpperCase() }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: resolveEnum(ch.channel).toUpperCase() will throw a TypeError if resolveEnum returns a non-string value

resolveEnum returns its input unchanged when it is not an object with "=". If ch.channel is null, a number, or an object without "=", calling .toUpperCase() on it will throw a TypeError at runtime.

The guard if (!ch.channel) return null on line 45 only catches falsy values. A truthy non-string (e.g. { channel: 42 } or an object missing "=") will still reach toUpperCase() and crash.

Consider adding a string check after resolving the enum value:

Suggested change
const entry = { Type: resolveEnum(ch.channel).toUpperCase() }
const resolved = resolveEnum(ch.channel)
if (typeof resolved !== 'string') return null
const entry = { Type: resolved.toUpperCase() }

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment thread cds-plugin.js

const typesPath = cds.env.requires?.notifications?.types
const srvPath = path.join(cds.root, cds.env.folders.srv)
const model = await cds.load(srvPath)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: cds.load(srvPath) in the "served" handler loads the service model a second time, which is redundant and potentially inconsistent

By the time "served" fires, CDS has already fully loaded and linked the application model (accessible via cds.model). Calling cds.load(srvPath) again performs a fresh, separate load that bypasses any model extensions or modifications applied during the normal boot sequence (including the "loaded" hook registered earlier in this same file). This means the freshly-loaded model may not reflect the recipients elements that the "loaded" hook injects.

Instead, use the already-loaded and fully-resolved model:

Suggested change
const model = await cds.load(srvPath)
const model = cds.model

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment thread tests/unit/lib/compile.test.js Outdated

test("Fall back to raw value when i18n key not found in any locale", () => {
const cds = require('@sap/cds')
cds.i18n = { labels: { at: () => undefined } }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: cds.i18n is overwritten directly with assignment instead of Object.defineProperty, breaking the afterEach restore

At line 239, the test assigns cds.i18n = { ... } directly. If cds.i18n is a non-writable or non-configurable property, this silently fails (in non-strict mode) and the mock is never applied. More critically, the afterEach restores via Object.defineProperty(require('@sap/cds'), 'i18n', originalI18nDescriptor), but since the descriptor was captured before the direct assignment, the restore path may be inconsistent across tests.

Use Object.defineProperty consistently as the other tests in this file do:

Suggested change
cds.i18n = { labels: { at: () => undefined } }
Object.defineProperty(cds, 'i18n', {
value: { labels: { at: () => undefined } },
configurable: true,
writable: true
})

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment thread tests/unit/lib/compile.test.js Outdated
})

test("Resolve {i18n>KEY} in subtitle field", () => {
Object.defineProperty(cds, 'i18n', {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The cds variable used in the last test ("Resolve {i18n>KEY} in subtitle field") is not locally required — it refers to whatever cds is in scope, which is the module-level require('@sap/cds') from line 1. However, the afterEach restores the descriptor captured in beforeEach. If this test runs after one that mutated cds.i18n via direct assignment (line 239), the descriptor captured in beforeEach may not match expectations.

More directly: the test at line 257-267 uses Object.defineProperty(cds, ...) where cds is the outer module scope binding. This is consistent, but a local const cds = require('@sap/cds') (like the other i18n tests) would make the intent explicit and avoid any confusion about which binding is being modified.

Suggested change
Object.defineProperty(cds, 'i18n', {
const cds = require('@sap/cds')
Object.defineProperty(cds, 'i18n', {

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant