mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Internationalization: Correctly generate plural forms (#71341)
* user essentials mob! 🔱 lastFile:public/locales/pseudo-LOCALE/grafana.json * user essentials mob! 🔱 * user essentials mob! 🔱 lastFile:contribute/internationalization.md * user essentials mob! 🔱 lastFile:contribute/internationalization.md * move pseudo generation to precommit hook if en-US file is modified Co-authored-by: L-M-K-B <48948963+L-M-K-B@users.noreply.github.com> Co-authored-by: tskarhed <1438972+tskarhed@users.noreply.github.com> * fix unit tests --------- Co-authored-by: Joao Silva <joao.silva@grafana.com> Co-authored-by: joshhunt <josh@trtr.co> Co-authored-by: Roxana Turc <anamaria-roxana.turc@grafana.com> Co-authored-by: eledobleefe <laura.fernandez@grafana.com> Co-authored-by: L-M-K-B <48948963+L-M-K-B@users.noreply.github.com> Co-authored-by: tskarhed <1438972+tskarhed@users.noreply.github.com>
This commit is contained in:
@@ -9,7 +9,7 @@ Grafana uses the [i18next](https://www.i18next.com/) framework for managing tran
|
|||||||
- Use `<Trans i18nKey="search-results.panel-link">Go to {{ pageTitle }}</Trans>` in code to add a translatable phrase
|
- Use `<Trans i18nKey="search-results.panel-link">Go to {{ pageTitle }}</Trans>` in code to add a translatable phrase
|
||||||
- Translations are stored in JSON files in `public/locales/{locale}/grafana.json`
|
- Translations are stored in JSON files in `public/locales/{locale}/grafana.json`
|
||||||
- If a particular phrase is not available in the a language then it will fall back to English
|
- If a particular phrase is not available in the a language then it will fall back to English
|
||||||
- To update phrases in English, edit the default phrase in the component's source, then run `yarn i18n:extract`. Do not edit the `en-ES/grafana.json` or update the english phrase in Crowdin
|
- To update phrases in English, edit the default phrase in both the component's source and the [English grafana.json message catalogue](../public/locales/en-US/grafana.json), then run `yarn i18n:extract`.
|
||||||
- To update phrases in any translated language, edit the phrase in Crowdin. Do not edit the `{locale}/grafana.json`
|
- To update phrases in any translated language, edit the phrase in Crowdin. Do not edit the `{locale}/grafana.json`
|
||||||
|
|
||||||
## How to add a new translation phrase
|
## How to add a new translation phrase
|
||||||
@@ -80,7 +80,7 @@ Grafana uses the [i18next](https://www.i18next.com/) framework for managing tran
|
|||||||
- Extracts phrases into messages catalogues for translating in external systems
|
- Extracts phrases into messages catalogues for translating in external systems
|
||||||
- Manages the user's locale and putting the translated phrases in the UI
|
- Manages the user's locale and putting the translated phrases in the UI
|
||||||
|
|
||||||
English phrases remain in our Javascript bundle in the source components (as the `<Trans />` or `t()` default phrase). At runtime, we don't need to load any messages for en-US. If the user's language preference is set to another language, Grafana will load that translations's messages JSON before the initial render.
|
Grafana will load the message catalogue JSON before the initial render.
|
||||||
|
|
||||||
### Phrase ID naming convention
|
### Phrase ID naming convention
|
||||||
|
|
||||||
@@ -162,17 +162,32 @@ import { Trans } from "app/core/internationalization"
|
|||||||
|
|
||||||
### Plurals
|
### Plurals
|
||||||
|
|
||||||
Plurals require special handling to make sure they can be translating according to the rules of each locale (which may be more complex that you think!). Use the `<Trans />` component, with the `count` prop.
|
Plurals require special handling to make sure they can be translating according to the rules of each locale (which may be more complex that you think!). Use either the `<Trans />` component or the `t` function, with the `count` prop to provide a singular form.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { Trans } from 'app/core/internationalization';
|
import { Trans } from 'app/core/internationalization';
|
||||||
|
|
||||||
<Trans i18nKey="newMessages" count={messages.length}>
|
<Trans i18nKey="inbox.heading" count={messages.length}>
|
||||||
You got {{ count: messages.length }} messages.
|
You got {{ count: messages.length }} message
|
||||||
</Trans>;
|
</Trans>;
|
||||||
```
|
```
|
||||||
|
|
||||||
Once extracted with `yarn i18n:extract` you will need to manually fill in the grafana.json message catalogues with the additional plural forms. See the [react-i18next docs](https://react.i18next.com/latest/trans-component#plural) for more details.
|
```js
|
||||||
|
import { t } from 'app/core/internationalization';
|
||||||
|
|
||||||
|
const translatedString = t('inbox.heading', 'You got {{count}} message', { count: messages.length });
|
||||||
|
```
|
||||||
|
|
||||||
|
Once extracted with `yarn i18n:extract` you will need to manually edit the [English grafana.json message catalogue](../public/locales/en-US/grafana.json) to correct the plural forms. See the [react-i18next docs](https://react.i18next.com/latest/trans-component#plural) for more details.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"inbox": {
|
||||||
|
"heading__one": "You got {{count}} message",
|
||||||
|
"heading__other": "You got {{count}} messages"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
@@ -46,8 +46,9 @@
|
|||||||
"ci:test-frontend": "yarn run test:ci",
|
"ci:test-frontend": "yarn run test:ci",
|
||||||
"postinstall": "husky install",
|
"postinstall": "husky install",
|
||||||
"i18n:clean": "rimraf public/locales/en-US/grafana.json",
|
"i18n:clean": "rimraf public/locales/en-US/grafana.json",
|
||||||
"i18n:extract": "yarn i18n:clean && yarn run i18next -c public/locales/i18next-parser.config.js 'public/**/*.{tsx,ts}' 'packages/grafana-ui/**/*.{tsx,ts}' && node ./public/locales/psuedo.js",
|
"i18n:extract": "yarn run i18next -c public/locales/i18next-parser.config.js 'public/**/*.{tsx,ts}' 'packages/grafana-ui/**/*.{tsx,ts}'",
|
||||||
"i18n:compile": "echo 'no i18n compile yet, all good'",
|
"i18n:compile": "echo 'no i18n compile yet, all good'",
|
||||||
|
"i18n:pseudo": "node ./public/locales/pseudo.js",
|
||||||
"betterer": "betterer",
|
"betterer": "betterer",
|
||||||
"betterer:merge": "betterer merge",
|
"betterer:merge": "betterer merge",
|
||||||
"betterer:stats": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/reportBettererStats.ts",
|
"betterer:stats": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/reportBettererStats.ts",
|
||||||
@@ -76,6 +77,9 @@
|
|||||||
],
|
],
|
||||||
"*public/app/plugins/**/**/*.cue": [
|
"*public/app/plugins/**/**/*.cue": [
|
||||||
"make fix-cue"
|
"make fix-cue"
|
||||||
|
],
|
||||||
|
"./public/locales/en-US/grafana.json": [
|
||||||
|
"yarn i18n:pseudo"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@@ -24,7 +24,7 @@ export const LANGUAGES: LanguageDefinitions[] = [
|
|||||||
{
|
{
|
||||||
code: ENGLISH_US,
|
code: ENGLISH_US,
|
||||||
name: 'English',
|
name: 'English',
|
||||||
loader: () => Promise.resolve({}),
|
loader: () => import('../../../locales/en-US/grafana.json'),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
@@ -23,11 +23,8 @@ export function initializeI18n(language: string) {
|
|||||||
const validLocale = VALID_LANGUAGES.includes(language) ? language : DEFAULT_LANGUAGE;
|
const validLocale = VALID_LANGUAGES.includes(language) ? language : DEFAULT_LANGUAGE;
|
||||||
|
|
||||||
// This is a placeholder so we can put a 'comment' in the message json files.
|
// This is a placeholder so we can put a 'comment' in the message json files.
|
||||||
// Starts with an underscore so it's sorted to the top of the file
|
// Starts with an underscore so it's sorted to the top of the file. Even though it is in a comment the following line is still extracted
|
||||||
t(
|
// t('_comment', 'This file is the source of truth for English strings. Edit this to change plurals and other phrases for the UI.');
|
||||||
'_comment',
|
|
||||||
'Do not manually edit this file, or update these source phrases in Crowdin. The source of truth for English strings are in the code source'
|
|
||||||
);
|
|
||||||
|
|
||||||
return i18n
|
return i18n
|
||||||
.use(loadTranslations)
|
.use(loadTranslations)
|
||||||
|
@@ -2,17 +2,19 @@ import { buildBreakdownString } from './utils';
|
|||||||
|
|
||||||
describe('browse-dashboards utils', () => {
|
describe('browse-dashboards utils', () => {
|
||||||
describe('buildBreakdownString', () => {
|
describe('buildBreakdownString', () => {
|
||||||
|
// note: pluralisation is handled as part of the i18n framework
|
||||||
|
// in tests, only the fallback singular message is used
|
||||||
it.each`
|
it.each`
|
||||||
folderCount | dashboardCount | libraryPanelCount | alertRuleCount | expected
|
folderCount | dashboardCount | libraryPanelCount | alertRuleCount | expected
|
||||||
${0} | ${0} | ${0} | ${0} | ${'0 items'}
|
${0} | ${0} | ${0} | ${0} | ${'0 item'}
|
||||||
${1} | ${0} | ${0} | ${0} | ${'1 item: 1 folder'}
|
${1} | ${0} | ${0} | ${0} | ${'1 item: 1 folder'}
|
||||||
${2} | ${0} | ${0} | ${0} | ${'2 items: 2 folders'}
|
${2} | ${0} | ${0} | ${0} | ${'2 item: 2 folder'}
|
||||||
${0} | ${1} | ${0} | ${0} | ${'1 item: 1 dashboard'}
|
${0} | ${1} | ${0} | ${0} | ${'1 item: 1 dashboard'}
|
||||||
${0} | ${2} | ${0} | ${0} | ${'2 items: 2 dashboards'}
|
${0} | ${2} | ${0} | ${0} | ${'2 item: 2 dashboard'}
|
||||||
${1} | ${0} | ${1} | ${1} | ${'3 items: 1 folder, 1 library panel, 1 alert rule'}
|
${1} | ${0} | ${1} | ${1} | ${'3 item: 1 folder, 1 library panel, 1 alert rule'}
|
||||||
${2} | ${0} | ${3} | ${4} | ${'9 items: 2 folders, 3 library panels, 4 alert rules'}
|
${2} | ${0} | ${3} | ${4} | ${'9 item: 2 folder, 3 library panel, 4 alert rule'}
|
||||||
${1} | ${1} | ${1} | ${1} | ${'4 items: 1 folder, 1 dashboard, 1 library panel, 1 alert rule'}
|
${1} | ${1} | ${1} | ${1} | ${'4 item: 1 folder, 1 dashboard, 1 library panel, 1 alert rule'}
|
||||||
${1} | ${2} | ${3} | ${4} | ${'10 items: 1 folder, 2 dashboards, 3 library panels, 4 alert rules'}
|
${1} | ${2} | ${3} | ${4} | ${'10 item: 1 folder, 2 dashboard, 3 library panel, 4 alert rule'}
|
||||||
`(
|
`(
|
||||||
'returns the correct message for the various inputs',
|
'returns the correct message for the various inputs',
|
||||||
({ folderCount, dashboardCount, libraryPanelCount, alertRuleCount, expected }) => {
|
({ folderCount, dashboardCount, libraryPanelCount, alertRuleCount, expected }) => {
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import { t } from 'app/core/internationalization';
|
||||||
|
|
||||||
export function buildBreakdownString(
|
export function buildBreakdownString(
|
||||||
folderCount: number,
|
folderCount: number,
|
||||||
dashboardCount: number,
|
dashboardCount: number,
|
||||||
@@ -7,18 +9,18 @@ export function buildBreakdownString(
|
|||||||
const total = folderCount + dashboardCount + libraryPanelCount + alertRuleCount;
|
const total = folderCount + dashboardCount + libraryPanelCount + alertRuleCount;
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (folderCount) {
|
if (folderCount) {
|
||||||
parts.push(`${folderCount} ${folderCount === 1 ? 'folder' : 'folders'}`);
|
parts.push(t('browse-dashboards.counts.folder', '{{count}} folder', { count: folderCount }));
|
||||||
}
|
}
|
||||||
if (dashboardCount) {
|
if (dashboardCount) {
|
||||||
parts.push(`${dashboardCount} ${dashboardCount === 1 ? 'dashboard' : 'dashboards'}`);
|
parts.push(t('browse-dashboards.counts.dashboard', '{{count}} dashboard', { count: dashboardCount }));
|
||||||
}
|
}
|
||||||
if (libraryPanelCount) {
|
if (libraryPanelCount) {
|
||||||
parts.push(`${libraryPanelCount} ${libraryPanelCount === 1 ? 'library panel' : 'library panels'}`);
|
parts.push(t('browse-dashboards.counts.libraryPanel', '{{count}} library panel', { count: libraryPanelCount }));
|
||||||
}
|
}
|
||||||
if (alertRuleCount) {
|
if (alertRuleCount) {
|
||||||
parts.push(`${alertRuleCount} ${alertRuleCount === 1 ? 'alert rule' : 'alert rules'}`);
|
parts.push(t('browse-dashboards.counts.alertRule', '{{count}} alert rule', { count: alertRuleCount }));
|
||||||
}
|
}
|
||||||
let breakdownString = `${total} ${total === 1 ? 'item' : 'items'}`;
|
let breakdownString = t('browse-dashboards.counts.total', '{{count}} item', { count: total });
|
||||||
if (parts.length > 0) {
|
if (parts.length > 0) {
|
||||||
breakdownString += `: ${parts.join(', ')}`;
|
breakdownString += `: ${parts.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_comment": "Do not manually edit this file. Translations must be made in Crowdin which will sync them back into this file",
|
"_comment": "",
|
||||||
"access-control": {
|
"access-control": {
|
||||||
"add-permission": {
|
"add-permission": {
|
||||||
"role-label": "",
|
"role-label": "",
|
||||||
@@ -39,6 +39,18 @@
|
|||||||
"moving": "",
|
"moving": "",
|
||||||
"new-folder-name-required-phrase": ""
|
"new-folder-name-required-phrase": ""
|
||||||
},
|
},
|
||||||
|
"counts": {
|
||||||
|
"alertRule__one": "",
|
||||||
|
"alertRule__other": "",
|
||||||
|
"dashboard__one": "",
|
||||||
|
"dashboard__other": "",
|
||||||
|
"folder__one": "",
|
||||||
|
"folder__other": "",
|
||||||
|
"libraryPanel__one": "",
|
||||||
|
"libraryPanel__other": "",
|
||||||
|
"total__one": "",
|
||||||
|
"total__other": ""
|
||||||
|
},
|
||||||
"dashboards-tree": {
|
"dashboards-tree": {
|
||||||
"name-column": "",
|
"name-column": "",
|
||||||
"tags-column": ""
|
"tags-column": ""
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_comment": "Do not manually edit this file, or update these source phrases in Crowdin. The source of truth for English strings are in the code source",
|
"_comment": "This file is the source of truth for English strings. Edit this to change plurals and other phrases for the UI.",
|
||||||
"access-control": {
|
"access-control": {
|
||||||
"add-permission": {
|
"add-permission": {
|
||||||
"role-label": "Role",
|
"role-label": "Role",
|
||||||
@@ -39,6 +39,18 @@
|
|||||||
"moving": "Moving...",
|
"moving": "Moving...",
|
||||||
"new-folder-name-required-phrase": "Folder name is required."
|
"new-folder-name-required-phrase": "Folder name is required."
|
||||||
},
|
},
|
||||||
|
"counts": {
|
||||||
|
"alertRule__one": "{{count}} alert rule",
|
||||||
|
"alertRule__other": "{{count}} alert rules",
|
||||||
|
"dashboard__one": "{{count}} dashboard",
|
||||||
|
"dashboard__other": "{{count}} dashboards",
|
||||||
|
"folder__one": "{{count}} folder",
|
||||||
|
"folder__other": "{{count}} folders",
|
||||||
|
"libraryPanel__one": "{{count}} library panel",
|
||||||
|
"libraryPanel__other": "{{count}} library panels",
|
||||||
|
"total__one": "{{count}} item",
|
||||||
|
"total__other": "{{count}} items"
|
||||||
|
},
|
||||||
"dashboards-tree": {
|
"dashboards-tree": {
|
||||||
"name-column": "Name",
|
"name-column": "Name",
|
||||||
"tags-column": "Tags"
|
"tags-column": "Tags"
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_comment": "Do not manually edit this file, or update these source phrases in Crowdin. The source of truth for English strings are in the code source",
|
"_comment": "",
|
||||||
"access-control": {
|
"access-control": {
|
||||||
"add-permission": {
|
"add-permission": {
|
||||||
"role-label": "",
|
"role-label": "",
|
||||||
@@ -39,6 +39,23 @@
|
|||||||
"moving": "",
|
"moving": "",
|
||||||
"new-folder-name-required-phrase": ""
|
"new-folder-name-required-phrase": ""
|
||||||
},
|
},
|
||||||
|
"counts": {
|
||||||
|
"alertRule__one": "",
|
||||||
|
"alertRule__many": "",
|
||||||
|
"alertRule__other": "",
|
||||||
|
"dashboard__one": "",
|
||||||
|
"dashboard__many": "",
|
||||||
|
"dashboard__other": "",
|
||||||
|
"folder__one": "",
|
||||||
|
"folder__many": "",
|
||||||
|
"folder__other": "",
|
||||||
|
"libraryPanel__one": "",
|
||||||
|
"libraryPanel__many": "",
|
||||||
|
"libraryPanel__other": "",
|
||||||
|
"total__one": "",
|
||||||
|
"total__many": "",
|
||||||
|
"total__other": ""
|
||||||
|
},
|
||||||
"dashboards-tree": {
|
"dashboards-tree": {
|
||||||
"name-column": "",
|
"name-column": "",
|
||||||
"tags-column": ""
|
"tags-column": ""
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_comment": "Do not manually edit this file. Translations must be made in Crowdin which will sync them back into this file",
|
"_comment": "",
|
||||||
"access-control": {
|
"access-control": {
|
||||||
"add-permission": {
|
"add-permission": {
|
||||||
"role-label": "",
|
"role-label": "",
|
||||||
@@ -39,6 +39,23 @@
|
|||||||
"moving": "",
|
"moving": "",
|
||||||
"new-folder-name-required-phrase": ""
|
"new-folder-name-required-phrase": ""
|
||||||
},
|
},
|
||||||
|
"counts": {
|
||||||
|
"alertRule__one": "",
|
||||||
|
"alertRule__many": "",
|
||||||
|
"alertRule__other": "",
|
||||||
|
"dashboard__one": "",
|
||||||
|
"dashboard__many": "",
|
||||||
|
"dashboard__other": "",
|
||||||
|
"folder__one": "",
|
||||||
|
"folder__many": "",
|
||||||
|
"folder__other": "",
|
||||||
|
"libraryPanel__one": "",
|
||||||
|
"libraryPanel__many": "",
|
||||||
|
"libraryPanel__other": "",
|
||||||
|
"total__one": "",
|
||||||
|
"total__many": "",
|
||||||
|
"total__other": ""
|
||||||
|
},
|
||||||
"dashboards-tree": {
|
"dashboards-tree": {
|
||||||
"name-column": "",
|
"name-column": "",
|
||||||
"tags-column": ""
|
"tags-column": ""
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_comment": "Đő ʼnőŧ mäʼnūäľľy ęđįŧ ŧĥįş ƒįľę, őř ūpđäŧę ŧĥęşę şőūřčę pĥřäşęş įʼn Cřőŵđįʼn. Ŧĥę şőūřčę őƒ ŧřūŧĥ ƒőř Ēʼnģľįşĥ şŧřįʼnģş äřę įʼn ŧĥę čőđę şőūřčę",
|
"_comment": "Ŧĥįş ƒįľę įş ŧĥę şőūřčę őƒ ŧřūŧĥ ƒőř Ēʼnģľįşĥ şŧřįʼnģş. Ēđįŧ ŧĥįş ŧő čĥäʼnģę pľūřäľş äʼnđ őŧĥęř pĥřäşęş ƒőř ŧĥę ŮĨ.",
|
||||||
"access-control": {
|
"access-control": {
|
||||||
"add-permission": {
|
"add-permission": {
|
||||||
"role-label": "Ŗőľę",
|
"role-label": "Ŗőľę",
|
||||||
@@ -39,6 +39,18 @@
|
|||||||
"moving": "Mővįʼnģ...",
|
"moving": "Mővįʼnģ...",
|
||||||
"new-folder-name-required-phrase": "Főľđęř ʼnämę įş řęqūįřęđ."
|
"new-folder-name-required-phrase": "Főľđęř ʼnämę įş řęqūįřęđ."
|
||||||
},
|
},
|
||||||
|
"counts": {
|
||||||
|
"alertRule__one": "{{count}} äľęřŧ řūľę",
|
||||||
|
"alertRule__other": "{{count}} äľęřŧ řūľęş",
|
||||||
|
"dashboard__one": "{{count}} đäşĥþőäřđ",
|
||||||
|
"dashboard__other": "{{count}} đäşĥþőäřđş",
|
||||||
|
"folder__one": "{{count}} ƒőľđęř",
|
||||||
|
"folder__other": "{{count}} ƒőľđęřş",
|
||||||
|
"libraryPanel__one": "{{count}} ľįþřäřy päʼnęľ",
|
||||||
|
"libraryPanel__other": "{{count}} ľįþřäřy päʼnęľş",
|
||||||
|
"total__one": "{{count}} įŧęm",
|
||||||
|
"total__other": "{{count}} įŧęmş"
|
||||||
|
},
|
||||||
"dashboards-tree": {
|
"dashboards-tree": {
|
||||||
"name-column": "Ńämę",
|
"name-column": "Ńämę",
|
||||||
"tags-column": "Ŧäģş"
|
"tags-column": "Ŧäģş"
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_comment": "Do not manually edit this file. Translations must be made in Crowdin which will sync them back into this file",
|
"_comment": "",
|
||||||
"access-control": {
|
"access-control": {
|
||||||
"add-permission": {
|
"add-permission": {
|
||||||
"role-label": "",
|
"role-label": "",
|
||||||
@@ -39,6 +39,13 @@
|
|||||||
"moving": "",
|
"moving": "",
|
||||||
"new-folder-name-required-phrase": ""
|
"new-folder-name-required-phrase": ""
|
||||||
},
|
},
|
||||||
|
"counts": {
|
||||||
|
"alertRule__other": "",
|
||||||
|
"dashboard__other": "",
|
||||||
|
"folder__other": "",
|
||||||
|
"libraryPanel__other": "",
|
||||||
|
"total__other": ""
|
||||||
|
},
|
||||||
"dashboards-tree": {
|
"dashboards-tree": {
|
||||||
"name-column": "",
|
"name-column": "",
|
||||||
"tags-column": ""
|
"tags-column": ""
|
||||||
|
Reference in New Issue
Block a user