Skip to main content

Translatable labels pattern — the standard for every form and label

Every label, button, column header, section header, empty-state message, and error string in the frontend goes through the translation system — never a bare English string literal in JSX or in code that surfaces to the user. This is the standing default; build new UI this way unless explicitly told otherwise for that specific case.

Languages are real data, not a hardcoded list

Languages come from MAS_CHOICES where ChoiceType = 'LANGUAGES' — the same generic legacy picklist table used for any future choice-driven dropdown, composite key (ChoiceType, ChoiceCode).

Backend: IChoiceRepository/ChoiceRepository (Application/Common/Interfaces, Infrastructure/Persistence/Repositories/Mas) exposes ListByTypeAsync (cached per choiceType, 1hr TTL, same lookup-table caching rule as City/Country) and GetImageAsync (live, uncached blob read). ChoicesController (Api/Controllers/Operations) serves both — GetImage is deliberately [AllowAnonymous] because it's consumed via a plain <img src> tag, which never sends the Authorization header (the same reasoning already documented for CompaniesController.GetLogo in the territory-access pattern — apply this to any future choice-image or logo endpoint consumed the same way).

Frontend building blocks

  • LanguageProvider/useLanguage() (src/i18n/LanguageContext.tsx) fetches the real language list once, holds the current languageCode (persisted to localStorage), and is the outermost provider in App.tsx — above AuthProvider — so LoginPage can translate too, even though the real language-list fetch requires auth and silently comes back empty pre-login (falls back to EN).
  • LanguageSelector (src/layout/LanguageSelector.tsx) renders one flag per real language row — the flag is the real ChoiceImage blob via choiceImageUrl(choiceType, choiceCode), not an emoji — falling back to the raw code text if the entity has no image or the image fails to load. Selecting one calls setLanguageCode, which immediately changes what useTranslation reads from.
  • useTranslation() (src/i18n/useTranslation.ts) is the one hook every component uses:
t(key: string, fallback: string, params?: Record<string, string | number>): string

:::tip fallback is required, not optional If a dictionary entry is missing (very plausible when a dictionary is authored incrementally, e.g. by several parallel passes), the UI must show the correct English text instead of a broken raw key string. That's why fallback is a required parameter, not optional. :::

Dynamic text uses {placeholder} tokens in the fallback plus a params object, e.g.:

t("cities.editCity", "Edit {code}", { code: city.code })

Never string-concatenate a translated fragment with a raw value.

Per-namespace dictionary files

src/i18n/en/<namespace>.ts, one file per page/area (common.ts, shell.ts, login.ts, plus one per master-data entity: cities.ts, countries.ts, branches.ts, companies.ts, clients.ts, warehouses.ts, departments.ts, equipment.ts, forumTypes.ts, queues.ts, trCodes.ts, addresses.ts) — merged in src/i18n/en/index.ts.

One file per namespace specifically so adding a new page's dictionary is a new file + one import line in index.ts, never an edit to a file another page's translation work is also touching — this matters in practice because this rollout was done with one parallel agent per entity, each only touching its own dictionary file and its own page.

common.* carries every generic, reused-everywhere string — Code, Name, Description, Status, Active, Inactive, Actions, Search, Save, Saving…, Cancel, Edit, Delete, Confirm, the delete-confirm dialog's title/message, the grid pager's "Showing X–Y of Z"/Rows/Prev/Next/Page-of strings — reuse these instead of duplicating the same word into a per-entity file. Entity-specific strings (page title, "New X" button, empty-state text, failed-to-create/update/delete messages, field/column labels unique to that entity) live in that entity's own file.

Shared components translate themselves

DataGrid.tsx (the pager status bar), ConfirmDialog.tsx (default Cancel/Confirm labels), and the shell (TopBar.tsx, SideNav.tsx's chrome, LoginPage.tsx, DashboardPage.tsx, BranchSwitcherModal.tsx, ChangePasswordModal.tsx, IconPanelButton.tsx) all call useTranslation() themselves — a page never needs to pass translated strings down as props for these.

:::note SideNav.tsx exception SideNav.tsx deliberately does not translate dynamic menu item text (real MAS_MENU/MAS_APP_MENU data — the sidebar is 100% server-driven; the client-side staticMenu.ts stand-in this used to reference no longer exists, removed once real menu data replaced it) — only its own chrome (search placeholder, no-results text, collapse toggle). Menu item labels are content, not form labels, and are out of scope for this pattern until the menu data itself grows translation support. :::

Data fetching stays as-is

The language list fetch uses the existing axios + useEffect/useState pattern, no new data-fetching library introduced for this.

Reference files

ChoiceDto.cs, IChoiceRepository.cs, ChoiceRepository.cs, ChoicesController.cs, useTranslation.ts, LanguageContext.tsx, LanguageSelector.tsx, i18n/en/common.ts, i18n/en/cities.ts, CitiesPage.tsx (fully converted, the template every other page's conversion followed).