ZuloOne
English
Sign in
Menu

Metadata captions: `caption` is authored, `displayCaption` is read

Updated

Every metadata object — a dictionary, a document type, a field, a menu item, a report — carries one authored caption and one translated one. The designer edits the first; the user reads the second. Mixing them up produces one of two bugs that both look like "the translation is missing": a screen stuck in the base language, or a translation silently saved over the original text.

← Developer wiki

The two fields#

Column Who writes it Language
Caption the designer, the workspace *.json, the model import always the base language (en in the caption language object)
DisplayCaption the server, per request the request's language, or null when there is no translation

DisplayCaption is not stored. It lives on BaseMetadata (src/ZuloOne.Shared/BaseMetadata.cs) as a plain property and is filled on the way out, per response, from MetaTranslations (PropertyName = 'Caption', or 'SubtypeCaption' for a document state).

So the client rule is always the same three-step fallback:

displayCaption || caption || name

Helpers exist — do not re-write the chain by hand: metaLabel(o) and metaNameLabel(o) (frontend/src/api/metaLabel.ts), menuItemLabel(m) (frontend/src/layout/menuTargets.ts).

Who fills it#

CaptionLocalizationFilter (src/ZuloOne.Core/Services/CaptionLocalizationFilter.cs), a global IAsyncResultFilter. For every ObjectResult it:

  1. reads the language — X-ZuloOne-Lang first (what the SPA sends), then Accept-Language, reduced to the primary subtag (ru-RU,ru;q=0.9 → ru);
  2. loads the two translation maps for that language from CaptionMapCache (5-minute TTL, invalidated when translations are saved);
  3. walks the response graph and, on each node, sets DisplayCaption if the node is a BaseMetadata, then applies whatever ILocalizedCaptions (src/ZuloOne.Shared/ILocalizedCaptions.cs) the node declares.

No endpoint opts in. Which is exactly why the two ways to fall out of it are easy to miss.

Trap 1 — an anonymous projection has nowhere to write#

Warning

.Select(m => new { m.MetaId, m.Caption, … }) ships the base language to every client, forever, with no error anywhere. The filter only descends into types whose FullName starts with ZuloOne., and an anonymous type has no namespace — so the walker turns around at the first node and never even looks at the captions inside.

The menu builder (/menu) is the easy place to walk into it: the ribbon localizes (/api/metadata/menu/my translates by hand) while the builder's tree, served by /api/metadata/menu, stays in English on a Russian stand if the projection is anonymous.

GET /api/metadata/menu, X-ZuloOne-Lang: ru items with displayCaption
anonymous projection 252 0
projected into MenuItemDto 252 252

Same endpoint, uk: 228 of 252 (the other 24 have no Ukrainian translation yet — the client falls back to caption). Same endpoint, en: 0 of 252, and that is correct — English is the caption, there is nothing to translate.

Always declare the projection type.

.Select(m => new MenuItemDto { MetaId = m.MetaId, Caption = m.Caption, … })

The same trap applies to GET /api/metadata/custom-reports (CustomReportListDto) — the menu builder's object catalogue reads it.

The anonymous root counts too. return Ok(new { templateId, items = rows }) is skipped as a whole: the walker never reaches items, however well-typed the rows are. /api/metadata/menu/my has that shape and therefore still localizes by hand. Either return the list directly, or translate it yourself.

Trap 2 — a DTO that copied a caption out of a row#

A flattened DTO (a report column naming its dictionary, a menu item projected out of MetaMenuItem) is not a BaseMetadata and has no MetaId the filter can trust. ILocalizedCaptions is the seam: say which row each caption came from and where to put the translated text.

public sealed class CustomReportColumnDto : ILocalizedCaptions
{
    public string? Caption { get; set; }           // from the SCRIPT — untranslatable
    public Guid? DictionaryMetaId { get; set; }
    public string? DictionaryCaption { get; set; } // from a real metadata row

    public IEnumerable<(Guid MetaId, Action<string> Apply)> LocalizableCaptions()
    {
        if (DictionaryMetaId is Guid dict) yield return (dict, t => DictionaryCaption = t);
    }
}

One DTO may expose several captions. Entries without a translation in the request language are left alone, so the base caption stays as the fallback.

Note what is not localizable in that example: Caption comes from authored C# in the report's data-source script, not from a metadata row. Nothing can translate it, and pretending otherwise would only hide that.

Trap 3 — never bind edit state to the display text#

Warning

defaultValue={metaLabel(o)} in a designer form saves the Russian text into the base Caption on the next Save. The base language then is Russian, for every tenant, and the original wording is gone.

A form that edits a caption binds to caption and leaves translations to the Translate modal. A form that shows one binds to the fallback chain. The menu builder does both on one screen: its tree rows and its target column read menuItemLabel, its caption input reads caption.

Trap 4 — the header picker is not the language registry#

Warning

Switching the flag to Ukrainian used to leave every menu, field and column in English. The SPA chrome pack (frontend/src/i18n/locales/uk.ts) loads on the client. Metadata captions live in MetaTranslations, and an import only writes the languages the tenant has already registered. Ukrainian was never seeded (English is), so picking it in the header sent X-ZuloOne-Lang: uk against an empty map. A handful of leftover caption_uk rows from before that filter made boot treat the language as filled and skip the backfill.

The language code is uk, not ua. The picker shows the country flag code UA; the workspace files and MetaLanguages.Code use the ISO 639-1 language.

Picking a bundled language (ru / uk / ar) now calls POST /api/metadata/languages/ensure, which registers the code and reads the captions out of the model tree in the image (WorkspaceLanguageCaptions). Adding a language that is not bundled (Polish, …) is still Developer → Languages. ua is accepted as an alias of uk.

The same rule applies when one object's caption is copied onto another: adding a menu item from the object catalogue seeds the new item's caption with the target's base caption, never with its display text — a menu item is its own metadata row with its own translations, and seeding it from the display text would freeze today's UI language into the base caption of a new row.

Checking an endpoint#

curl -s -H 'X-ZuloOne-Lang: ru' localhost:5257/api/metadata/menu \
  | python -c "import sys,json; d=json.load(sys.stdin); \
print(len(d), sum(1 for x in d if x.get('displayCaption')))"

0 on the right with a non-empty left on a stand that has Russian translations means the endpoint is in trap 1 or trap 2. Compare against a known-good one (/api/metadata/dictionaries) before concluding the translations themselves are missing.

Where next#

Was this page helpful?