diff --git a/content/docs/i18n.mdx b/content/docs/i18n.mdx new file mode 100644 index 00000000..2308e644 --- /dev/null +++ b/content/docs/i18n.mdx @@ -0,0 +1,357 @@ +--- +id: i18n +title: i18n +description: Translate jsonforms +--- + +import { I18nExample, ValuesTable } from '../../src/components/docs/i18n'; + +:::note + +Please note that the renderer sets themselves are not yet translatable. +You can find the current status here: +https://github.com/eclipsesource/jsonforms/issues/1826 + +::: + +The translate functionality of JSON Forms is integrated into the core component. +In order to translate JSON Forms, you need to set a translation function and provide it to the JSON Forms component: +```ts +const translator = (key, defaultMessage) => { + console.log(`Key: ${key}, Default Message: ${defaultMessage}`); + return defaultMessage; +}; + +const [locale, setLocale] = useState<'de'|'en'>('de'); +const translation = useMemo(() => translator(locale), [locale]); + + +``` + +The `i18n` prop consists of three components: `locale`, `translate` and `translateError`. + +## `locale` + +Specifies the current locale of the application. +This can be used by renderers to render locale specific UI elements, for example for locale-aware formatting of numbers. + + +## `translate` + +Provides a translation function handling the actual translation. + +:::caution + +The translate function should be side effect free and should be stable (memoized) to avoid unnecessary re-renderings, i.e. the translate function should only change if there are new translations. + +::: + +The type of the translate is + +```ts +(key: string, defaultMessage: string | undefined, context: any) => string | undefined) +``` + +with the following parameters: + +### `key` + +The key is used to identify the string that needs to be translated. +It can be set using the `UI Schema`, the `JSON Schema` or is generated by default based on the property path. + +#### UI Schema option + +The key can be set via the `i18n` UI Schema option: +```json +{ + "type": "Control", + "label": "name", + "scope": "#/properties/name", + "i18n": "customName" +} +``` + +Therefore, the translation will be invoked with `customName.label` & `customName.description`. + +#### JSON Schema + +The key can also be set with the custom JSON Schema `i18n` option: +```json +{ + "name": { + "type": "string", + "i18n": "myCustomName" + } +} +``` + +Therefore, the translation will be invoked with `myCustomName.label` & `myCustomName.description`. + +#### Default: Property path + +If none of the above is set, the property path will be used as the key. + +```json +{ + "properties": { + "firstName": { + "type": "string" + }, + "address": { + "type": "object", + "properties": { + "street": { + "type": "string" + } + } + }, + "comments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + } + } + } + } +} +``` + +The paths in the above example are: +- `firstName.label` & `firstName.description` +- `address.label` & `address.description` +- `address.street.label` & `address.street.description` +- `comments.message.label` & `comments.message.description` (the path for arrays will not contain indices) + +### `defaultMessage` + +The default message is provided by JSON Forms and can act as a fallback or could be translated. + +:::note + +If the `defaultMessage` is `undefined`, you should also return `undefined` if there is no translation for the given key. +Returning an empty string (or something similar) instead may result in undesired behavior. +JSON Forms will use `undefined` when the message could be skipped or another more generic key could be tried. + +::: + +### `context` + +`context` can contain additional information for the current translation. The following parameters can be provided: + + + +Schema translations provide all properties, while UI schema translations only provide the `uischema` property. + +## `translateError` + +The `translateError` function is called whenever a single message is to be extracted from an AJV error object. + +The type of the `translateError` function is + +```ts +(error: ErrorObject, translate: Translator, uischema?: UISchemaElement) => string +``` + +* The `error` is the AJV error object +* `translate` is the i18n `translate` function handed over to JSON Forms +* In cases where a UI Schema Element can be correlated to an `error` it will also be handed over +* The `translateError` function always returns a `string` + +Usually this method does not need to be customized as JSON Forms already provides a sensible default implementation. +A reason to customize this method could be to integrate third party frameworks like `ajv-i18n`. + +For more information about how errors can be customized, see the following section: + +## Error Customizations + +For each control a list of errors is determined. +This section describes the default behavior of JSON forms to offer translation support for them. + +### `.error.custom` + +Before invoking the `translateError` function, JSON Forms will check whether a `.error.custom` translation exists. +This is useful if there are many JSON Schema validaton keywords defined for a single property, but a single cohesive message shall be displayed. + +If no `.error.custom` message is returned by the `translate` function, `translateError` will be called for each AJV error and the results combined. + +The default implementation of `translateError` will invoke `translate` multiple times to determine the best message for the given error. Therefore it's usually not necessary to customize `translateError` itself. +By default error it works like this: + +`` in the sections below refers to the key of the field (see `key` section above). + +### Evaluation order + +#### `.error.` + +Example keys: `name.error.required`, `address.street.error.pattern` + +The default `translateError` will first look for a concrete message for a specific field and a specific error type. + +#### `error.` + +Example keys: `error.required`, `error.pattern` + +After checking field specific translations, `translateError` will then look for form-wide translations of errors, independent of each respective field. +This is useful to customize for example `required` or `pattern` messages for all properties. + +#### error message + +At last the default `translateError` implementation will check whether the `message` of the error object has a specific translation. + +#### Default AJV error message + +If none of the above apply, the `message` provided by the AJV error object will be used. + +### Example + +Consider the following schema for an object attribute: +```js +{ + phone: { + type: "string", + minLength: 10, + pattern: "^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$" + } +} +``` +The order in which the error keys are evaluated is the following: +- `phone.error.custom`: if this is set, the `pattern` and `minLength` errors will be ignores and just this message is used +- `phone.error.pattern` & `phone.error.minLength` +- `error.pattern` & `error.minLength` + +## Enum translation + +Enum translations are based on the respective entries: + - For JSON Schema `enum`, the stringified value is used. + - For JSON Schema `oneOf` enums which consist of (`title`, `const`) pairs a specialized `i18n` key or `title` is used. + +Therefore, in order to translate enum values, an additional key is checked for each enum entry. +For example: +Let's assume we have an enum attribute `gender`, which looks like this: + +```js +{ + gender: { + type: "string", + enum: ["male", "female", "other"] + } +} +``` + +In this case the `translate` function will be invoked with the keys `gender.male`, `gender.female` and `gender.other` to translate these enum values. In case `gender` had an `i18n` property, it would be used instead, i.e. `.male` etc. + +Let's assume we have a `oneOf` enum attribute gender which looks like this: + +```js +{ + gender: { + oneOf: [ + { + title: "Male", + const: 0 + }, + { + title: "Female", + const: "f", + i18n: "fem" + }, + { + const: null + } + ] + } +} +``` + +Here the requested keys are: + +* `gender.Male` - property path + `title` of the `oneOf` entry +* `fem` - direct usage of the `i18n` property for the `oneOf` entry +* `null` - the `title` attribute is missing, therefore the `null` value is stringified to `'null'`. + +## UI Schema Translation + +The UI schema has the elements `group`, `category` and `label`, which can also be translated. + +If a i18n-key is provided in a `group` or `category` element, `.label` will be used as key. +If no i18n key is provided, the value of the `label`-property is used as key. +In case neither a i18n-key nor a label is provided, `.label` will be used as default key. + +The `label` UI schema element will use `.text` as key, if provided. +If no i18n key is provided, the value of the `text`-property is used as key. + + +Let's assume we have the following UI schema: + +```js +const uischema = { + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/user', + options: { + detail: { + type: 'Group', + i18n: 'i18nUser', + } + } + }, + { + type: 'Control', + scope: '#/properties/address', + options: { + detail: { + type: 'Group', + label: 'labelAddress', + } + } + }, + { + type: 'Control', + scope: '#/properties/address' + }, + { + type: 'Label', + i18n: 'i18nLabel' + }, + { + type: 'Label', + text: 'textLabel' + }, + ] +}; +``` + +Here the requested keys are: + +* `i18nUser.label` - i18n + `label` +* `labelAddress` - direct usage of the `label` property +* `address.label` - property path + `label` +* `i18nLabel.text` - i18n + `text` +* `textLabel` - direct usage of the `text` property + +## Access translation in custom renderer sets + +If you want to directly access the i18n properties within a custom renderer, you can use the JSON Forms context for that: + +```js +const ctx = useJsonForms(); + +const locale = ctx.i18n.locale; +const translate = ctx.i18n.translate; +const translateError = ctx.i18n.translateError; +``` + +With this you can for example change phone number patterns based on the current locale for validation. + +## Example + + diff --git a/content/pages/assets/support.module.scss b/content/pages/assets/support.module.scss index 9b56c4f2..03158831 100644 --- a/content/pages/assets/support.module.scss +++ b/content/pages/assets/support.module.scss @@ -1,4 +1,4 @@ -h3 { +.comparison_container h3 { font-size: 2rem; padding: 0; } diff --git a/src/components/docs/i18n.js b/src/components/docs/i18n.js new file mode 100644 index 00000000..07ca5d0a --- /dev/null +++ b/src/components/docs/i18n.js @@ -0,0 +1,176 @@ +import React, { useMemo, useState } from 'react'; +import get from 'lodash/get'; +import { + materialCells, + materialRenderers, +} from '@jsonforms/material-renderers'; +import Button from '@mui/material/Button'; +import { JsonForms } from '@jsonforms/react'; +import TableCell from '@mui/material/TableCell/TableCell'; +import TableHead from '@mui/material/TableHead/TableHead'; +import Table from '@mui/material/Table/Table'; +import TableBody from '@mui/material/TableBody/TableBody'; +import TableRow from '@mui/material/TableRow/TableRow'; + +const i18n = { + schema: { + properties: { + firstName: { + type: "string" + }, + lastName: { + type: "string" + }, + email: { + type: "string" + }, + gender: { + type: "string", + enum: [ + "Male", + "Female", + "Other" + ] + } + }, + required: [ + "email" + ] + }, + uischema: { + type: "VerticalLayout", + elements: [ + { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/firstName" + }, + { + type: "Control", + scope: "#/properties/lastName" + } + ] + }, + { + type: "Control", + scope: "#/properties/email" + }, + { + type: "Control", + scope: "#/properties/gender" + } + ] + } +} + +export const en = { + firstName: { + label: 'First Name', + description: 'The first name of the person', + }, + lastName: { + label: 'Last Name', + }, + email: { + label: 'Email' + }, + gender: { + label: 'Gender', + Male: 'Male', + Female: 'Female', + Other: 'Diverse' + }, + error: { + required: 'This field is required', + }, +}; + +export const de = { + firstName: { + label: 'Vorname', + description: 'Der Vorname der Person', + }, + lastName: { + label: 'Nachname', + }, + email: { + label: 'Email' + }, + gender: { + label: 'Geschlecht', + Male: 'Männlich', + Female: 'Weiblich', + Other: 'Divers' + }, + error: { + required: 'Dieses Feld muss ausgefüllt werden.', + }, +}; + +export const I18nExample = () => { + const [locale, setLocale] = useState('de'); + + const createTranslator = (locale) => (key, defaultMessage) => { + return get(locale === 'en' ? en : de, key) ?? defaultMessage; + }; + + const translation = useMemo(() => createTranslator(locale), [locale]); + + const switchLocale = () => { + if(locale === 'en') { + setLocale('de'); + } else { + setLocale('en'); + } + }; + + return ( +
+ + +
+ Current language: {locale} +
+ ); +}; + +export const ValuesTable = () => ( +
+ + + + Parameter + Description + + + + + errors + Array of AJV errors, that occurred during validation + + + path + The path of the translated element + + + schema + The schema of the translated element + + + uischema + The uischema of the translated element + + +
+
+); \ No newline at end of file diff --git a/src/sidebars/docs.js b/src/sidebars/docs.js index 3a53ad5f..813acdcb 100644 --- a/src/sidebars/docs.js +++ b/src/sidebars/docs.js @@ -10,6 +10,7 @@ module.exports = { items: ['uischema/uischema', 'uischema/controls', 'uischema/layouts', 'uischema/rules'], }, 'labels', + 'i18n', 'renderer-sets', 'ref-resolving', 'readonly',