diff --git a/packages/angular-material/example/app/app.component.ts b/packages/angular-material/example/app/app.component.ts index cbbcfd85c..99308d882 100644 --- a/packages/angular-material/example/app/app.component.ts +++ b/packages/angular-material/example/app/app.component.ts @@ -75,7 +75,7 @@ const itemTester: UISchemaTester = (_schema, schemaPath, _path) => { [schema]="selectedExample.schema" [uischema]="selectedExample.uischema" [renderers]="renderers" - [locale]="currentLocale" + [i18n]="i18n" [uischemas]="uischemas" [readonly]="readonly" [config]="config" @@ -86,7 +86,9 @@ export class AppComponent { readonly renderers = angularMaterialRenderers; readonly examples = getExamples(); selectedExample: ExampleDescription; - currentLocale = 'en-US'; + i18n = { + locale: 'en-US' + } private readonly = false; data: any; uischemas: { tester: UISchemaTester; uischema: UISchemaElement; }[] = [ @@ -102,7 +104,7 @@ export class AppComponent { } changeLocale(locale: string) { - this.currentLocale = locale; + this.i18n.locale = locale; } toggleReadonly() { diff --git a/packages/angular-material/test/number-control.spec.ts b/packages/angular-material/test/number-control.spec.ts index 75e1b7d79..17882802f 100644 --- a/packages/angular-material/test/number-control.spec.ts +++ b/packages/angular-material/test/number-control.spec.ts @@ -144,8 +144,6 @@ describe( getJsonFormsService(component).init({ core: state, i18n: { locale: 'en', - localizedSchemas: undefined, - localizedUISchemas: undefined } }); getJsonFormsService(component).updateCore( @@ -168,8 +166,6 @@ describe( getJsonFormsService(component).init({ core: state, i18n: { locale: 'en', - localizedSchemas: undefined, - localizedUISchemas: undefined },config: { useGrouping: false }, @@ -196,8 +192,6 @@ describe( getJsonFormsService(component).init({ core: state, i18n: { locale: 'en', - localizedSchemas: undefined, - localizedUISchemas: undefined },config: { useGrouping: true }, diff --git a/packages/angular/src/jsonforms-root.component.ts b/packages/angular/src/jsonforms-root.component.ts index 193a1703e..74ba7afab 100644 --- a/packages/angular/src/jsonforms-root.component.ts +++ b/packages/angular/src/jsonforms-root.component.ts @@ -23,10 +23,9 @@ THE SOFTWARE. */ import { - Component, - EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges + Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; -import { Actions, JsonFormsRendererRegistryEntry, JsonSchema, UISchemaElement, UISchemaTester, ValidationMode } from '@jsonforms/core'; +import { Actions, JsonFormsI18nState, JsonFormsRendererRegistryEntry, JsonSchema, UISchemaElement, UISchemaTester, ValidationMode } from '@jsonforms/core'; import Ajv, { ErrorObject } from 'ajv'; import { JsonFormsAngularService, USE_STATE_VALUE } from './jsonforms.service'; @Component({ @@ -42,11 +41,11 @@ export class JsonForms implements OnChanges, OnInit { @Input() renderers: JsonFormsRendererRegistryEntry[]; @Input() uischemas: { tester: UISchemaTester; uischema: UISchemaElement; }[]; @Output() dataChange = new EventEmitter(); - @Input() locale: string; @Input() readonly: boolean; @Input() validationMode: ValidationMode; @Input() ajv: Ajv; @Input() config: any; + @Input() i18n: JsonFormsI18nState; @Output() errors = new EventEmitter(); private previousData:any; @@ -67,7 +66,7 @@ export class JsonForms implements OnChanges, OnInit { validationMode: this.validationMode }, uischemas: this.uischemas, - i18n: { locale: this.locale, localizedSchemas: undefined, localizedUISchemas: undefined }, + i18n: this.i18n, renderers: this.renderers, config: this.config, readonly: this.readonly @@ -87,6 +86,14 @@ export class JsonForms implements OnChanges, OnInit { this.initialized = true; } + ngDoCheck(): void { + // we can't use ngOnChanges as then nested i18n changes will not be detected + // the update will result in a no-op when the parameters did not change + this.jsonformsService.updateI18n( + Actions.updateI18n(this.i18n?.locale, this.i18n?.translate, this.i18n?.translateError) + ); + } + // tslint:disable-next-line: cyclomatic-complexity ngOnChanges(changes: SimpleChanges): void { if (!this.initialized) { @@ -97,7 +104,7 @@ export class JsonForms implements OnChanges, OnInit { const newUiSchema = changes.uischema; const newRenderers = changes.renderers; const newUischemas = changes.uischemas; - const newLocale = changes.locale; + const newI18n = changes.i18n; const newReadonly = changes.readonly; const newValidationMode = changes.validationMode; const newAjv = changes.ajv; @@ -121,8 +128,10 @@ export class JsonForms implements OnChanges, OnInit { this.jsonformsService.setUiSchemas(newUischemas.currentValue); } - if (newLocale && !newLocale.isFirstChange()) { - this.jsonformsService.setLocale(newLocale.currentValue); + if (newI18n && !newI18n.isFirstChange()) { + this.jsonformsService.updateI18n( + Actions.updateI18n(newI18n.currentValue?.locale, newI18n.currentValue?.translate, newI18n.currentValue?.translateError) + ); } if (newReadonly && !newReadonly.isFirstChange()) { diff --git a/packages/angular/src/jsonforms.service.ts b/packages/angular/src/jsonforms.service.ts index 72951daa9..fe5605555 100644 --- a/packages/angular/src/jsonforms.service.ts +++ b/packages/angular/src/jsonforms.service.ts @@ -34,7 +34,7 @@ import { JsonFormsState, JsonFormsSubStates, JsonSchema, - LocaleActions, + I18nActions, RankedTester, setConfig, SetConfigAction, @@ -42,7 +42,8 @@ import { UISchemaElement, uischemaRegistryReducer, UISchemaTester, - ValidationMode + ValidationMode, + updateI18n } from '@jsonforms/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { JsonFormsBaseRenderer } from './base.renderer'; @@ -56,9 +57,10 @@ export class JsonFormsAngularService { private _state: JsonFormsSubStates; private state: BehaviorSubject; - init(initialState: JsonFormsSubStates = { core: { data: undefined, schema: undefined, uischema: undefined } }) { + init(initialState: JsonFormsSubStates = { core: { data: undefined, schema: undefined, uischema: undefined, validationMode: 'ValidateAndShow' } }) { this._state = initialState; this._state.config = configReducer(undefined, setConfig(this._state.config)); + this._state.i18n = i18nReducer(this._state.i18n, updateI18n(this._state.i18n?.locale, this._state.i18n?.translate, this._state.i18n?.translateError)); this.state = new BehaviorSubject({ jsonforms: this._state }); const data = initialState.core.data; const schema = initialState.core.schema ?? generateJsonSchema(data); @@ -117,16 +119,18 @@ export class JsonFormsAngularService { this.updateSubject(); } - updateLocale(localeAction: T): T { - const localeState = i18nReducer(this._state.i18n, localeAction); - this._state.i18n = localeState; - this.updateSubject(); - return localeAction; + updateI18n(i18nAction: T): T { + const i18nState = i18nReducer(this._state.i18n, i18nAction); + if (i18nState !== this._state.i18n) { + this._state.i18n = i18nState; + this.updateSubject(); + } + return i18nAction; } updateCore(coreAction: T): T { const coreState = coreReducer(this._state.core, coreAction); - if(coreState !== this._state.core) { + if (coreState !== this._state.core) { this._state.core = coreState; this.updateSubject(); } diff --git a/packages/core/src/actions/actions.ts b/packages/core/src/actions/actions.ts index ddd7fe54a..aed400b0a 100644 --- a/packages/core/src/actions/actions.ts +++ b/packages/core/src/actions/actions.ts @@ -29,6 +29,7 @@ import { generateDefaultUISchema, generateJsonSchema } from '../generators'; import { RankedTester } from '../testers'; import { UISchemaTester, ValidationMode } from '../reducers'; +import { ErrorTranslator, Translator } from '../i18n'; export const INIT: 'jsonforms/INIT' = 'jsonforms/INIT'; export const UPDATE_CORE: 'jsonforms/UPDATE_CORE' = `jsonforms/UPDATE_CORE`; @@ -51,10 +52,10 @@ export const SET_VALIDATION_MODE: 'jsonforms/SET_VALIDATION_MODE' = 'jsonforms/SET_VALIDATION_MODE'; export const SET_LOCALE: 'jsonforms/SET_LOCALE' = `jsonforms/SET_LOCALE`; -export const SET_LOCALIZED_SCHEMAS: 'jsonforms/SET_LOCALIZED_SCHEMAS' = - 'jsonforms/SET_LOCALIZED_SCHEMAS'; -export const SET_LOCALIZED_UISCHEMAS: 'jsonforms/SET_LOCALIZED_UISCHEMAS' = - 'jsonforms/SET_LOCALIZED_UISCHEMAS'; +export const SET_TRANSLATOR: 'jsonforms/SET_TRANSLATOR' = + 'jsonforms/SET_TRANSLATOR'; +export const UPDATE_I18N: 'jsonforms/UPDATE_I18N' = + 'jsonforms/UPDATE_I18N'; export const ADD_DEFAULT_DATA: 'jsonforms/ADD_DEFAULT_DATA' = `jsonforms/ADD_DEFAULT_DATA`; export const REMOVE_DEFAULT_DATA: 'jsonforms/REMOVE_DEFAULT_DATA' = `jsonforms/REMOVE_DEFAULT_DATA`; @@ -275,33 +276,21 @@ export const unregisterUISchema = ( }; }; -export type LocaleActions = +export type I18nActions = | SetLocaleAction - | SetLocalizedSchemasAction - | SetLocalizedUISchemasAction; + | SetTranslatorAction + | UpdateI18nAction export interface SetLocaleAction { type: 'jsonforms/SET_LOCALE'; - locale: string; + locale: string | undefined; } -export const setLocale = (locale: string): SetLocaleAction => ({ +export const setLocale = (locale: string | undefined): SetLocaleAction => ({ type: SET_LOCALE, locale }); -export interface SetLocalizedSchemasAction { - type: 'jsonforms/SET_LOCALIZED_SCHEMAS'; - localizedSchemas: Map; -} - -export const setLocalizedSchemas = ( - localizedSchemas: Map -): SetLocalizedSchemasAction => ({ - type: SET_LOCALIZED_SCHEMAS, - localizedSchemas -}); - export interface SetSchemaAction { type: 'jsonforms/SET_SCHEMA'; schema: JsonSchema; @@ -312,16 +301,37 @@ export const setSchema = (schema: JsonSchema): SetSchemaAction => ({ schema }); -export interface SetLocalizedUISchemasAction { - type: 'jsonforms/SET_LOCALIZED_UISCHEMAS'; - localizedUISchemas: Map; +export interface SetTranslatorAction { + type: 'jsonforms/SET_TRANSLATOR'; + translator?: Translator; + errorTranslator?: ErrorTranslator; +} + +export const setTranslator = ( + translator?: Translator, + errorTranslator?: ErrorTranslator +): SetTranslatorAction => ({ + type: SET_TRANSLATOR, + translator, + errorTranslator +}); + +export interface UpdateI18nAction { + type: 'jsonforms/UPDATE_I18N'; + locale: string | undefined; + translator: Translator | undefined; + errorTranslator: ErrorTranslator | undefined; } -export const setLocalizedUISchemas = ( - localizedUISchemas: Map -): SetLocalizedUISchemasAction => ({ - type: SET_LOCALIZED_UISCHEMAS, - localizedUISchemas +export const updateI18n = ( + locale: string | undefined, + translator: Translator | undefined, + errorTranslator: ErrorTranslator | undefined +): UpdateI18nAction => ({ + type: UPDATE_I18N, + locale, + translator, + errorTranslator }); export interface SetUISchemaAction { diff --git a/packages/core/src/i18n/i18nTypes.ts b/packages/core/src/i18n/i18nTypes.ts new file mode 100644 index 000000000..e13d904e4 --- /dev/null +++ b/packages/core/src/i18n/i18nTypes.ts @@ -0,0 +1,17 @@ +import { ErrorObject } from 'ajv'; +import { JsonSchema, UISchemaElement } from '../models'; + +export type Translator = { + (id: string, defaultMessage: string, values?: any): string; + (id: string, defaultMessage: undefined, values?: any): string | undefined; +} + +export type ErrorTranslator = (error: ErrorObject, translate: Translator, uischema?: UISchemaElement) => string; + +export interface JsonFormsI18nState { + locale?: string; + translate?: Translator; + translateError?: ErrorTranslator; +} + +export type i18nJsonSchema = JsonSchema & {i18n?: string}; diff --git a/packages/core/src/i18n/i18nUtil.ts b/packages/core/src/i18n/i18nUtil.ts new file mode 100644 index 000000000..e44b0c7d5 --- /dev/null +++ b/packages/core/src/i18n/i18nUtil.ts @@ -0,0 +1,76 @@ +import { ErrorObject } from 'ajv'; +import { UISchemaElement } from '../models'; +import { formatErrorMessage } from '../util'; +import { i18nJsonSchema, ErrorTranslator, Translator } from './i18nTypes'; + +export const getI18nKey = ( + schema: i18nJsonSchema | undefined, + uischema: UISchemaElement | undefined, + key: string +): string | undefined => { + if (uischema?.options?.i18n) { + return `${uischema.options.i18n}.${key}`; + } + if (schema?.i18n) { + return `${schema.i18n}.${key}`; + } + return undefined; +}; + +export const defaultTranslator: Translator = (_id: string, defaultMessage: string | undefined) => defaultMessage; + +export const defaultErrorTranslator: ErrorTranslator = (error, t, uischema) => { + // check whether there is a special keyword message + const keyInSchemas = getI18nKey( + error.parentSchema, + uischema, + `error.${error.keyword}` + ); + const specializedKeywordMessage = keyInSchemas && t(keyInSchemas, undefined); + if (specializedKeywordMessage !== undefined) { + return specializedKeywordMessage; + } + + // check whether there is a generic keyword message + const genericKeywordMessage = t(`error.${error.keyword}`, undefined); + if (genericKeywordMessage !== undefined) { + return genericKeywordMessage; + } + + // check whether there is a customization for the default message + const messageCustomization = t(error.message, undefined); + if (messageCustomization !== undefined) { + return messageCustomization; + } + + // rewrite required property messages (if they were not customized) as we place them next to the respective input + if (error.keyword === 'required') { + return t('is a required property', 'is a required property'); + } + + return error.message; +}; + +/** + * Returns the determined error message for the given errors. + * All errors must correspond to the given schema and uischema. + */ +export const getCombinedErrorMessage = ( + errors: ErrorObject[], + et: ErrorTranslator, + t: Translator, + schema?: i18nJsonSchema, + uischema?: UISchemaElement +) => { + if (errors.length > 0 && t) { + // check whether there is a special message which overwrites all others + const keyInSchemas = getI18nKey(schema, uischema, 'error.custom'); + const specializedErrorMessage = keyInSchemas && t(keyInSchemas, undefined); + if (specializedErrorMessage !== undefined) { + return specializedErrorMessage; + } + } + return formatErrorMessage( + errors.map(error => et(error, t, uischema)) + ); +}; diff --git a/packages/core/src/i18n/index.ts b/packages/core/src/i18n/index.ts new file mode 100644 index 000000000..298d5f4d5 --- /dev/null +++ b/packages/core/src/i18n/index.ts @@ -0,0 +1,2 @@ +export * from './i18nTypes'; +export * from './i18nUtil'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4e44cb35a..70bd993c9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -34,3 +34,4 @@ export * from './util'; export * from './Helpers'; export * from './store'; +export * from './i18n'; diff --git a/packages/core/src/reducers/core.ts b/packages/core/src/reducers/core.ts index b6c5ce546..9f398982a 100644 --- a/packages/core/src/reducers/core.ts +++ b/packages/core/src/reducers/core.ts @@ -77,7 +77,7 @@ const initState: JsonFormsCore = { errors: [], validator: undefined, ajv: undefined, - validationMode: 'ValidateAndShow' + validationMode: 'ValidateAndShow', }; const reuseAjvForSchema = (ajv: Ajv, schema: JsonSchema): Ajv => { @@ -154,7 +154,7 @@ export const coreReducer: Reducer = ( errors: e, validator: v, ajv: thisAjv, - validationMode + validationMode, }; } case UPDATE_CORE: { @@ -184,17 +184,17 @@ export const coreReducer: Reducer = ( state.ajv !== thisAjv || state.errors !== errors || state.validator !== validator || - state.validationMode !== validationMode; + state.validationMode !== validationMode return stateChanged ? { ...state, - data: state.data === action.data ? state.data : action.data, - schema: state.schema === action.schema ? state.schema : action.schema, - uischema: state.uischema === action.uischema ? state.uischema : action.uischema, - ajv: thisAjv === state.ajv ? state.ajv : thisAjv, + data: action.data, + schema: action.schema, + uischema: action.uischema, + ajv: thisAjv, errors: isEqual(errors, state.errors) ? state.errors : errors, - validator: validator === state.validator ? state.validator : validator, - validationMode: validationMode === state.validationMode ? state.validationMode : validationMode + validator: validator, + validationMode: validationMode, } : state; } diff --git a/packages/core/src/reducers/i18n.ts b/packages/core/src/reducers/i18n.ts index f8e91d9a3..924a6a08c 100644 --- a/packages/core/src/reducers/i18n.ts +++ b/packages/core/src/reducers/i18n.ts @@ -23,66 +23,72 @@ THE SOFTWARE. */ -import { SET_LOCALE, SET_LOCALIZED_SCHEMAS, SET_LOCALIZED_UISCHEMAS } from '../actions'; -import { JsonSchema, UISchemaElement } from '../models'; +import { defaultErrorTranslator, defaultTranslator, JsonFormsI18nState } from '../i18n'; +import { I18nActions, SET_LOCALE, SET_TRANSLATOR, UPDATE_I18N } from '../actions'; import { Reducer } from '../util'; -export interface JsonFormsLocaleState { - locale?: string; - localizedSchemas: Map; - localizedUISchemas: Map; -} - -const initState: JsonFormsLocaleState = { - locale: undefined, - localizedSchemas: new Map(), - localizedUISchemas: new Map() +export const defaultJsonFormsI18nState: JsonFormsI18nState = { + locale: 'en', + translate: defaultTranslator, + translateError: defaultErrorTranslator }; -export const i18nReducer: Reducer = (state = initState, action) => { +export const i18nReducer: Reducer = (state = defaultJsonFormsI18nState, action) => { switch (action.type) { - case SET_LOCALIZED_SCHEMAS: - return { - ...state, - localizedSchemas: action.localizedSchemas - }; - case SET_LOCALIZED_UISCHEMAS: + case UPDATE_I18N: { + const locale = action.locale ?? defaultJsonFormsI18nState.locale; + const translate = + action.translator ?? defaultJsonFormsI18nState.translate; + const translateError = + action.errorTranslator ?? defaultJsonFormsI18nState.translateError; + + if ( + locale !== state.locale || + translate !== state.translate || + translateError !== state.translateError + ) { + return { + ...state, + locale, + translate, + translateError + }; + } + return state; + } + case SET_TRANSLATOR: return { ...state, - localizedUISchemas: action.localizedUISchemas + translate: action.translator ?? defaultTranslator, + translateError: action.errorTranslator ?? defaultErrorTranslator }; case SET_LOCALE: return { ...state, - locale: - action.locale === undefined ? navigator.languages[0] : action.locale + locale: action.locale ?? navigator.languages[0] }; default: return state; } }; -export const fetchLocale = (state?: JsonFormsLocaleState) => { +export const fetchLocale = (state?: JsonFormsI18nState) => { if (state === undefined) { return undefined; } return state.locale; }; -export const findLocalizedSchema = (locale: string) => ( - state?: JsonFormsLocaleState -): JsonSchema => { +export const fetchTranslator = (state?: JsonFormsI18nState) => { if (state === undefined) { - return undefined; + return defaultTranslator; } - return state.localizedSchemas.get(locale); -}; + return state.translate; +} -export const findLocalizedUISchema = (locale: string) => ( - state?: JsonFormsLocaleState -): UISchemaElement => { +export const fetchErrorTranslator = (state?: JsonFormsI18nState) => { if (state === undefined) { - return undefined; + return defaultErrorTranslator; } - return state.localizedUISchemas.get(locale); -}; + return state.translateError; +} diff --git a/packages/core/src/reducers/reducers.ts b/packages/core/src/reducers/reducers.ts index 38dd2f87a..5f210670c 100644 --- a/packages/core/src/reducers/reducers.ts +++ b/packages/core/src/reducers/reducers.ts @@ -43,10 +43,9 @@ import { UISchemaTester } from './uischemas'; import { + fetchErrorTranslator, fetchLocale, - findLocalizedSchema, - findLocalizedUISchema, - i18nReducer + i18nReducer, } from './i18n'; import { Generate } from '../generators'; @@ -55,6 +54,8 @@ import { JsonSchema } from '../models/jsonSchema'; import { cellReducer } from './cells'; import { configReducer } from './config'; import get from 'lodash/get'; +import { fetchTranslator } from '.'; +import { ErrorTranslator, Translator } from '../i18n'; export { rendererReducer, @@ -138,11 +139,10 @@ export const getConfig = (state: JsonFormsState) => state.jsonforms.config; export const getLocale = (state: JsonFormsState) => fetchLocale(get(state, 'jsonforms.i18n')); -export const getLocalizedSchema = (locale: string) => ( +export const getTranslator = () => ( state: JsonFormsState -): JsonSchema => findLocalizedSchema(locale)(get(state, 'jsonforms.i18n')); +): Translator => fetchTranslator(get(state, 'jsonforms.i18n')); -export const getLocalizedUISchema = (locale: string) => ( +export const getErrorTranslator = () => ( state: JsonFormsState -): UISchemaElement => - findLocalizedUISchema(locale)(get(state, 'jsonforms.i18n')); +): ErrorTranslator => fetchErrorTranslator(get(state, 'jsonforms.i18n')); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 6b8a18204..a0987478b 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -28,9 +28,9 @@ import { JsonFormsCore, JsonFormsCellRendererRegistryEntry, JsonFormsRendererRegistryEntry, - JsonFormsLocaleState, JsonFormsUISchemaRegistryEntry } from './reducers'; +import { JsonFormsI18nState } from './i18n'; /** * JSONForms store. @@ -65,11 +65,11 @@ export interface JsonFormsSubStates { */ cells?: JsonFormsCellRendererRegistryEntry[]; /** - * + * I18n settings. */ - i18n?: JsonFormsLocaleState; + i18n?: JsonFormsI18nState; /** - * + * The UI schema registry used in detail renderers. */ uischemas?: JsonFormsUISchemaRegistryEntry[]; /** diff --git a/packages/core/src/util/cell.ts b/packages/core/src/util/cell.ts index 805fe23e4..6eadb1b3a 100644 --- a/packages/core/src/util/cell.ts +++ b/packages/core/src/util/cell.ts @@ -31,7 +31,8 @@ import { getErrorAt, getSchema, getAjv, - JsonFormsCellRendererRegistryEntry + JsonFormsCellRendererRegistryEntry, + getTranslator } from '../reducers'; import { AnyAction, Dispatch } from './type'; import { @@ -54,6 +55,7 @@ import { } from './renderer'; import { JsonFormsState } from '../store'; import { JsonSchema } from '../models'; +import { i18nJsonSchema } from '..'; export { JsonFormsCellRendererRegistryEntry }; @@ -196,8 +198,20 @@ export const defaultMapStateToEnumCellProps = ( const props: StatePropsOfCell = mapStateToCellProps(state, ownProps); const options: EnumOption[] = ownProps.options || - props.schema.enum?.map(enumToEnumOptionMapper) || - (props.schema.const && [enumToEnumOptionMapper(props.schema.const)]); + props.schema.enum?.map(e => + enumToEnumOptionMapper( + e, + getTranslator()(state), + props.uischema?.options?.i18n ?? (props.schema as i18nJsonSchema).i18n + ) + ) || + (props.schema.const && [ + enumToEnumOptionMapper( + props.schema.const, + getTranslator()(state), + props.uischema?.options?.i18n ?? (props.schema as i18nJsonSchema).i18n + ) + ]); return { ...props, options @@ -217,7 +231,13 @@ export const mapStateToOneOfEnumCellProps = ( const props: StatePropsOfCell = mapStateToCellProps(state, ownProps); const options: EnumOption[] = ownProps.options || - (props.schema.oneOf as JsonSchema[])?.map(oneOfToEnumOptionMapper); + (props.schema.oneOf as JsonSchema[])?.map(oneOfSubSchema => + oneOfToEnumOptionMapper( + oneOfSubSchema, + getTranslator()(state), + props.uischema?.options?.i18n + ) + ); return { ...props, options diff --git a/packages/core/src/util/renderer.ts b/packages/core/src/util/renderer.ts index 2a492ac6d..79655c4d1 100644 --- a/packages/core/src/util/renderer.ts +++ b/packages/core/src/util/renderer.ts @@ -25,7 +25,6 @@ import get from 'lodash/get'; import { ControlElement, JsonSchema, UISchemaElement } from '../models'; -import union from 'lodash/union'; import find from 'lodash/find'; import { findUISchema, @@ -34,9 +33,11 @@ import { getConfig, getData, getErrorAt, + getErrorTranslator, getRenderers, getSchema, getSubErrorsAt, + getTranslator, getUiSchema, JsonFormsCellRendererRegistryEntry, JsonFormsRendererRegistryEntry, @@ -48,12 +49,13 @@ import { createLabelDescriptionFrom } from './label'; import { CombinatorKeyword, resolveSubSchemas } from './combinators'; import { moveDown, moveUp } from './array'; import { AnyAction, Dispatch } from './type'; -import { formatErrorMessage, Resolve } from './util'; +import { Resolve } from './util'; import { composePaths, composeWithUi } from './path'; import { isVisible } from './runtime'; import { CoreActions, update } from '../actions'; import { ErrorObject } from 'ajv'; import { JsonFormsState } from '../store'; +import { getCombinedErrorMessage, getI18nKey, i18nJsonSchema, Translator } from '../i18n'; export { JsonFormsRendererRegistryEntry, JsonFormsCellRendererRegistryEntry }; @@ -174,16 +176,45 @@ export interface EnumOption { value: any; } -export const enumToEnumOptionMapper = (e: any): EnumOption => { - const stringifiedEnum = typeof e === 'string' ? e : JSON.stringify(e); - return { label: stringifiedEnum, value: e }; +export const enumToEnumOptionMapper = ( + e: any, + t?: Translator, + i18nKey?: string +): EnumOption => { + let label = typeof e === 'string' ? e : JSON.stringify(e); + if (t) { + if (i18nKey) { + label = t(`${i18nKey}.${label}`, label); + } else { + label = t(label, label); + } + } + return { label, value: e }; }; -export const oneOfToEnumOptionMapper = (e: any): EnumOption => ({ - value: e.const, - label: - e.title ?? (typeof e.const === 'string' ? e.const : JSON.stringify(e.const)) -}); +export const oneOfToEnumOptionMapper = ( + e: any, + t?: Translator, + uiSchemaI18nKey?: string +): EnumOption => { + let label = + e.title ?? + (typeof e.const === 'string' ? e.const : JSON.stringify(e.const)); + if (t) { + // prefer schema keys as they can be more specialized + if (e.i18n) { + label = t(e.i18n, label); + } else if (uiSchemaI18nKey) { + label = t(`${uiSchemaI18nKey}.${label}`, label); + } else { + label = t(label, label); + } + } + return { + label, + value: e.const, + }; +}; export interface OwnPropsOfRenderer { /** @@ -385,14 +416,6 @@ export interface ControlState { isFocused: boolean; } -const getControlErrorMessage = (error: ErrorObject) => { - if (error.keyword === 'required') { - // change validation message to refer to the property (and not its parent) - return 'is a required property'; - } - return error.message; -} - /** * Map state to control props. * @param state the store's state @@ -421,9 +444,8 @@ export const mapStateToControlProps = ( controlElement.scope, rootSchema ); - const errors = formatErrorMessage( - union(getErrorAt(path, resolvedSchema)(state).map(error => getControlErrorMessage(error))) - ); + const errors = getErrorAt(path, resolvedSchema)(state); + const description = resolvedSchema !== undefined ? resolvedSchema.description : ''; const data = Resolve.data(rootData, path); @@ -438,18 +460,26 @@ export const mapStateToControlProps = ( rootData, config ); + + const schema = resolvedSchema ?? rootSchema; + const t = getTranslator()(state); + const te = getErrorTranslator()(state); + const i18nLabel = t(getI18nKey(schema, uischema, 'label') ?? label, label); + const i18nDescription = t(getI18nKey(schema, uischema, 'description') ?? description, description); + const i18nErrorMessage = getCombinedErrorMessage(errors, te, t, schema, uischema); + return { data, - description, - errors, - label, + description: i18nDescription, + errors: i18nErrorMessage, + label: i18nLabel, visible, enabled, id, path, required, - uischema: ownProps.uischema, - schema: resolvedSchema || rootSchema, + uischema, + schema, config: getConfig(state), cells: ownProps.cells || state.jsonforms.cells, rootSchema @@ -484,8 +514,20 @@ export const mapStateToEnumControlProps = ( const props: StatePropsOfControl = mapStateToControlProps(state, ownProps); const options: EnumOption[] = ownProps.options || - props.schema.enum?.map(enumToEnumOptionMapper) || - (props.schema.const && [enumToEnumOptionMapper(props.schema.const)]); + props.schema.enum?.map(e => + enumToEnumOptionMapper( + e, + getTranslator()(state), + props.uischema?.options?.i18n ?? (props.schema as i18nJsonSchema).i18n + ) + ) || + (props.schema.const && [ + enumToEnumOptionMapper( + props.schema.const, + getTranslator()(state), + props.uischema?.options?.i18n ?? (props.schema as i18nJsonSchema).i18n + ) + ]); return { ...props, options @@ -505,7 +547,13 @@ export const mapStateToOneOfEnumControlProps = ( const props: StatePropsOfControl = mapStateToControlProps(state, ownProps); const options: EnumOption[] = ownProps.options || - (props.schema.oneOf as JsonSchema[])?.map(oneOfToEnumOptionMapper); + (props.schema.oneOf as JsonSchema[])?.map(oneOfSubSchema => + oneOfToEnumOptionMapper( + oneOfSubSchema, + getTranslator()(state), + props.uischema?.options?.i18n + ) + ); return { ...props, options @@ -527,8 +575,20 @@ export const mapStateToMultiEnumControlProps = ( const options: EnumOption[] = ownProps.options || (items?.oneOf && - (items.oneOf as JsonSchema[]).map(oneOfToEnumOptionMapper)) || - items?.enum?.map(enumToEnumOptionMapper); + (items.oneOf as JsonSchema[]).map(oneOfSubSchema => + oneOfToEnumOptionMapper( + oneOfSubSchema, + state.jsonforms.i18n?.translate, + props.uischema?.options?.i18n + ) + )) || + items?.enum?.map(e => + enumToEnumOptionMapper( + e, + state.jsonforms.i18n?.translate, + props.uischema?.options?.i18n ?? (props.schema as i18nJsonSchema).i18n + ) + ); return { ...props, options @@ -984,9 +1044,17 @@ export const mapStateToArrayLayoutProps = ( } = mapStateToControlWithDetailProps(state, ownProps); const resolvedSchema = Resolve.schema(schema, 'items', props.rootSchema); - const childErrors = formatErrorMessage( - getSubErrorsAt(path, resolvedSchema)(state).map(error => error.message) + + // TODO Does not consider a specialized '.custom' error message overriding all other error messages + // TODO Does not consider 'i18n' keys which are specified in the ui schemas of the sub errors + const childErrors = getCombinedErrorMessage( + getSubErrorsAt(path, resolvedSchema)(state), + getErrorTranslator()(state), + getTranslator()(state), + undefined, + undefined ); + const allErrors = errors + (errors.length > 0 && childErrors.length > 0 ? '\n' : '') + diff --git a/packages/core/src/util/validator.ts b/packages/core/src/util/validator.ts index 6321fa8b8..4577bf473 100644 --- a/packages/core/src/util/validator.ts +++ b/packages/core/src/util/validator.ts @@ -30,6 +30,7 @@ export const createAjv = (options?: Options) => { const ajv = new Ajv({ allErrors: true, verbose: true, + strict: false, ...options }); addFormats(ajv); diff --git a/packages/core/test/util/cell.test.ts b/packages/core/test/util/cell.test.ts index f96949f6e..e0e2447e7 100644 --- a/packages/core/test/util/cell.test.ts +++ b/packages/core/test/util/cell.test.ts @@ -286,7 +286,7 @@ test('mapStateToEnumCellProps - set default options for dropdown list', t => { const props = defaultMapStateToEnumCellProps(createState(uischema), ownProps); t.deepEqual( props.options, - ['DE', 'IT', 'JP', 'US', 'RU', 'Other'].map(enumToEnumOptionMapper) + ['DE', 'IT', 'JP', 'US', 'RU', 'Other'].map(e => enumToEnumOptionMapper(e)) ); t.is(props.data, undefined); }); @@ -315,7 +315,7 @@ test('mapStateToOneOfEnumCellProps - set one of options for dropdown list', t => }; const props = mapStateToOneOfEnumCellProps(createState(uischema), ownProps); - t.deepEqual(props.options, [{title: 'Australia' , const: 'AU', }, { title: 'New Zealand', const: 'NZ' }].map(oneOfToEnumOptionMapper)); + t.deepEqual(props.options, [{title: 'Australia' , const: 'AU', }, { title: 'New Zealand', const: 'NZ' }].map(schema => oneOfToEnumOptionMapper(schema))); t.is(props.data, undefined); }); diff --git a/packages/core/test/util/renderer.test.ts b/packages/core/test/util/renderer.test.ts index bfbcb2c3a..cba251725 100644 --- a/packages/core/test/util/renderer.test.ts +++ b/packages/core/test/util/renderer.test.ts @@ -56,10 +56,14 @@ import { OwnPropsOfControl, rankWith, RuleEffect, - UISchemaElement + UISchemaElement, + setValidationMode, + defaultJsonFormsI18nState, + i18nJsonSchema, + mapStateToEnumControlProps, + mapStateToOneOfEnumControlProps } from '../../src'; import { ErrorObject } from 'ajv'; -import { setValidationMode } from '../../lib'; const middlewares: Redux.Middleware[] = []; const mockStore = configureStore(middlewares); @@ -1274,6 +1278,423 @@ test('mapStateToAnyOfProps - const constraint in anyOf schema should return corr } }; const props = mapStateToAnyOfProps(state, ownProps); - console.log(JSON.stringify(props, null, 2)); t.is(props.indexOfFittingSchema, 2); }); + +test('mapStateToControlProps - i18n - mapStateToControlProps should not crash without i18n', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.i18n = undefined; + + const props = mapStateToControlProps(state, ownProps); + t.is(props.label, 'First Name'); +}); + +test('mapStateToControlProps - i18n - default translation has no effect', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.i18n = defaultJsonFormsI18nState; + + const props = mapStateToControlProps(state, ownProps); + t.is(props.label, 'First Name'); + t.is(props.description, undefined); +}); + +test('mapStateToControlProps - i18n - translation via label key', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.i18n = defaultJsonFormsI18nState; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'First Name': return 'my translation'; + default: return defaultMessage; + } + } + + const props = mapStateToControlProps(state, ownProps); + t.is(props.label, 'my translation'); + t.is(props.description, undefined); +}); + +test('mapStateToControlProps - i18n - translation via JSON Schema i18n key', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.i18n = defaultJsonFormsI18nState; + (state.jsonforms.core.schema.properties.firstName as i18nJsonSchema).i18n = 'my-key'; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'my-key.label': return 'my label'; + case 'my-key.description': return 'my description'; + default: return defaultMessage; + } + } + + const props = mapStateToControlProps(state, ownProps); + t.is(props.label, 'my label'); + t.is(props.description, 'my description'); +}); + +test('mapStateToControlProps - i18n - translation via UI Schema i18n key', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.i18n = defaultJsonFormsI18nState; + ownProps.uischema = {...ownProps.uischema, options: {i18n: 'my-key'}}; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'my-key.label': return 'my label'; + case 'my-key.description': return 'my description'; + default: return defaultMessage; + } + } + + const props = mapStateToControlProps(state, ownProps); + t.is(props.label, 'my label'); + t.is(props.description, 'my description'); +}); + +test('mapStateToControlProps - i18n errors - should not crash without i18n', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.pattern = "[0-9]+" + state.jsonforms.core = coreReducer( + state.jsonforms.core, + init( + state.jsonforms.core.data, + state.jsonforms.core.schema, + state.jsonforms.core.uischema, + createAjv() + ) + ); + state.jsonforms.i18n = undefined; + + const props = mapStateToControlProps(state, ownProps); + t.is(props.errors, 'must match pattern "[0-9]+"'); +}); + +test('mapStateToControlProps - i18n errors - default translation has no effect', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.pattern = "[0-9]+" + state.jsonforms.core = coreReducer( + state.jsonforms.core, + init( + state.jsonforms.core.data, + state.jsonforms.core.schema, + state.jsonforms.core.uischema, + createAjv() + ) + ); + state.jsonforms.i18n = defaultJsonFormsI18nState; + + const props = mapStateToControlProps(state, ownProps); + t.is(props.errors, 'must match pattern "[0-9]+"'); +}); + +test('mapStateToControlProps - i18n errors - translate via error message key', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.pattern = "[0-9]+" + state.jsonforms.core = coreReducer( + state.jsonforms.core, + init( + state.jsonforms.core.data, + state.jsonforms.core.schema, + state.jsonforms.core.uischema, + createAjv() + ) + ); + state.jsonforms.i18n = defaultJsonFormsI18nState; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'must match pattern "[0-9]+"': return 'my error message'; + default: return defaultMessage; + } + } + + const props = mapStateToControlProps(state, ownProps); + t.is(props.errors, 'my error message'); +}); + +test('mapStateToControlProps - i18n errors - translate via i18 specialized error keyword key', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.pattern = "[0-9]+"; + (state.jsonforms.core.schema.properties.firstName as i18nJsonSchema).i18n = 'my-key'; + state.jsonforms.core = coreReducer( + state.jsonforms.core, + init( + state.jsonforms.core.data, + state.jsonforms.core.schema, + state.jsonforms.core.uischema, + createAjv() + ) + ); + state.jsonforms.i18n = defaultJsonFormsI18nState; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'my-key.error.pattern': return 'my error message'; + default: return defaultMessage; + } + } + + const props = mapStateToControlProps(state, ownProps); + t.is(props.errors, 'my error message'); +}); + +test('mapStateToControlProps - i18n errors - translate via i18 general error keyword key', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.pattern = "[0-9]+"; + (state.jsonforms.core.schema.properties.firstName as i18nJsonSchema).i18n = 'my-key'; + state.jsonforms.core = coreReducer( + state.jsonforms.core, + init( + state.jsonforms.core.data, + state.jsonforms.core.schema, + state.jsonforms.core.uischema, + createAjv() + ) + ); + state.jsonforms.i18n = defaultJsonFormsI18nState; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'error.pattern': return 'my error message'; + default: return defaultMessage; + } + } + + const props = mapStateToControlProps(state, ownProps); + t.is(props.errors, 'my error message'); +}); + +test('mapStateToControlProps - i18n errors - specialized keyword wins over generic keyword', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.pattern = "[0-9]+"; + (state.jsonforms.core.schema.properties.firstName as i18nJsonSchema).i18n = 'my-key'; + state.jsonforms.core = coreReducer( + state.jsonforms.core, + init( + state.jsonforms.core.data, + state.jsonforms.core.schema, + state.jsonforms.core.uischema, + createAjv() + ) + ); + state.jsonforms.i18n = defaultJsonFormsI18nState; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'my-key.error.pattern': return 'my key error message'; + case 'error.pattern': return 'my error message'; + default: return defaultMessage; + } + } + + const props = mapStateToControlProps(state, ownProps); + t.is(props.errors, 'my key error message'); +}); + +test('mapStateToControlProps - i18n errors - multiple errors customization', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.pattern = "[0-9]+"; + state.jsonforms.core.schema.properties.firstName.maxLength = 2; + (state.jsonforms.core.schema.properties.firstName as i18nJsonSchema).i18n = 'my-key'; + state.jsonforms.core = coreReducer( + state.jsonforms.core, + init( + state.jsonforms.core.data, + state.jsonforms.core.schema, + state.jsonforms.core.uischema, + createAjv() + ) + ); + state.jsonforms.i18n = defaultJsonFormsI18nState; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'error.maxLength': return 'max length message'; + case 'my-key.error.pattern': return 'my key error message'; + default: return defaultMessage; + } + } + + const props = mapStateToControlProps(state, ownProps); + t.is(props.errors, 'max length message\nmy key error message'); +}); + +test('mapStateToControlProps - i18n errors - custom keyword wins over all other errors', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.pattern = "[0-9]+"; + state.jsonforms.core.schema.properties.firstName.maxLength = 2; + (state.jsonforms.core.schema.properties.firstName as i18nJsonSchema).i18n = 'my-key'; + state.jsonforms.core = coreReducer( + state.jsonforms.core, + init( + state.jsonforms.core.data, + state.jsonforms.core.schema, + state.jsonforms.core.uischema, + createAjv() + ) + ); + state.jsonforms.i18n = defaultJsonFormsI18nState; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'my-key.error.custom': return 'this is my error custom error message'; + case 'my-key.error.pattern': return 'my key error message'; + case 'error.pattern': return 'my error message'; + default: return defaultMessage; + } + } + + const props = mapStateToControlProps(state, ownProps); + t.is(props.errors, 'this is my error custom error message'); +}); + +test('mapStateToEnumControlProps - i18n - should not crash without i18n', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.enum = ['a', 'b']; + state.jsonforms.i18n = undefined; + + const props = mapStateToEnumControlProps(state, ownProps); + t.is(props.options[0].label, 'a'); +}); + +test('mapStateToEnumControlProps - i18n - default translation has no effect', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.enum = ['a', 'b']; + state.jsonforms.i18n = defaultJsonFormsI18nState; + + const props = mapStateToEnumControlProps(state, ownProps); + t.is(props.options[0].label, 'a'); +}); + +test('mapStateToEnumControlProps - i18n - label translation', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.enum = ['a', 'b']; + state.jsonforms.i18n = defaultJsonFormsI18nState; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'a': return 'my message'; + default: return defaultMessage; + } + } + + const props = mapStateToEnumControlProps(state, ownProps); + t.is(props.options[0].label, 'my message'); +}); + +test('mapStateToEnumControlProps - i18n - i18n key translation', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.enum = ['a', 'b']; + (state.jsonforms.core.schema.properties.firstName as i18nJsonSchema).i18n = 'my-key'; + state.jsonforms.i18n = defaultJsonFormsI18nState; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'my-key.a': return 'my message'; + default: return defaultMessage; + } + } + + const props = mapStateToEnumControlProps(state, ownProps); + t.is(props.options[0].label, 'my message'); +}); + +test('mapStateToOneOfEnumControlProps - i18n - should not crash without i18n', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.oneOf = [{const: 'a', title: 'foo'}, {const: 'b', title: 'bar'}] + state.jsonforms.i18n = undefined; + + const props = mapStateToOneOfEnumControlProps(state, ownProps); + t.is(props.options[0].label, 'foo'); +}); + +test('mapStateToOneOfEnumControlProps- i18n - default translation has no effect', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.oneOf = [{const: 'a', title: 'foo'}, {const: 'b', title: 'bar'}] + state.jsonforms.i18n = defaultJsonFormsI18nState; + + const props = mapStateToOneOfEnumControlProps(state, ownProps); + t.is(props.options[0].label, 'foo'); +}); + +test('mapStateToOneOfEnumControlProps - i18n - label translation', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.core.schema.properties.firstName.oneOf = [{const: 'a', title: 'foo'}, {const: 'b', title: 'bar'}] + state.jsonforms.i18n = defaultJsonFormsI18nState; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'foo': return 'my message'; + default: return defaultMessage; + } + } + + const props = mapStateToOneOfEnumControlProps(state, ownProps); + t.is(props.options[0].label, 'my message'); +}); + +test('mapStateToOneOfEnumControlProps - i18n - i18n key translation', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + (state.jsonforms.core.schema.properties.firstName.oneOf as any) = + [{const: 'a', title: 'foo', i18n: 'my-foo'}, {const: 'b', title: 'bar', i18n:'my-bar'}]; + state.jsonforms.i18n = defaultJsonFormsI18nState; + state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { + switch(key){ + case 'my-foo': return 'my message'; + default: return defaultMessage; + } + } + + const props = mapStateToOneOfEnumControlProps(state, ownProps); + t.is(props.options[0].label, 'my message'); +}); diff --git a/packages/react/src/JsonForms.tsx b/packages/react/src/JsonForms.tsx index 95f3fb08f..39ee68ea0 100644 --- a/packages/react/src/JsonForms.tsx +++ b/packages/react/src/JsonForms.tsx @@ -32,6 +32,7 @@ import { isControl, JsonFormsCellRendererRegistryEntry, JsonFormsCore, + JsonFormsI18nState, JsonFormsProps, JsonFormsRendererRegistryEntry, JsonFormsUISchemaRegistryEntry, @@ -181,6 +182,7 @@ export interface JsonFormsInitStateProps { uischemas?: JsonFormsUISchemaRegistryEntry[]; readonly?: boolean; validationMode?: ValidationMode; + i18n?: JsonFormsI18nState; } export const JsonForms = ( @@ -197,7 +199,8 @@ export const JsonForms = ( config, uischemas, readonly, - validationMode + validationMode, + i18n } = props; const schemaToUse = useMemo( () => (schema !== undefined ? schema : Generate.jsonSchema(data)), @@ -224,6 +227,7 @@ export const JsonForms = ( renderers, cells, readonly, + i18n }} onChange={onChange} > diff --git a/packages/react/src/JsonFormsContext.tsx b/packages/react/src/JsonFormsContext.tsx index 2e13759e0..c976607f8 100644 --- a/packages/react/src/JsonFormsContext.tsx +++ b/packages/react/src/JsonFormsContext.tsx @@ -67,7 +67,8 @@ import { mapStateToMultiEnumControlProps, DispatchPropsOfMultiEnumControl, mapDispatchToControlProps, - mapDispatchToArrayControlProps + mapDispatchToArrayControlProps, + i18nReducer } from '@jsonforms/core'; import React, { ComponentType, Dispatch, ReducerAction, useContext, useEffect, useMemo, useReducer, useRef } from 'react'; @@ -108,7 +109,7 @@ const useEffectAfterFirstRender = ( }; export const JsonFormsStateProvider = ({ children, initState, onChange }: any) => { - const { data, schema, uischema, ajv, validationMode} = initState.core; + const { data, schema, uischema, ajv, validationMode } = initState.core; // Initialize core immediately const [core, coreDispatch] = useReducer( coreReducer, @@ -133,6 +134,20 @@ export const JsonFormsStateProvider = ({ children, initState, onChange }: any) = configDispatch(Actions.setConfig(initState.config)); }, [initState.config]); + const [i18n, i18nDispatch] = useReducer( + i18nReducer, + undefined, + () => i18nReducer( + initState.i18n, + Actions.updateI18n(initState.i18n?.locale, initState.i18n?.translate, initState.i18n?.translateError) + ) + ); + useEffect(() => { + i18nDispatch( + Actions.updateI18n(initState.i18n?.locale, initState.i18n?.translate, initState.i18n?.translateError) + ); + }, [initState.i18n?.locale, initState.i18n?.translate, initState.i18n?.translateError]); + const contextValue = useMemo(() => ({ core, renderers: initState.renderers, @@ -140,9 +155,10 @@ export const JsonFormsStateProvider = ({ children, initState, onChange }: any) = config: config, uischemas: initState.uischemas, readonly: initState.readonly, + i18n: i18n, // only core dispatch available dispatch: coreDispatch, - }), [core, initState.renderers, initState.cells, config, initState.readonly]); + }), [core, initState.renderers, initState.cells, config, initState.readonly, i18n]); const onChangeRef = useRef(onChange); useEffect(() => { diff --git a/packages/vue/vue-vanilla/dev/components/App.vue b/packages/vue/vue-vanilla/dev/components/App.vue index 376eff201..4fc322be2 100644 --- a/packages/vue/vue-vanilla/dev/components/App.vue +++ b/packages/vue/vue-vanilla/dev/components/App.vue @@ -3,59 +3,62 @@ import { defineComponent } from '../../config/vue'; import { JsonForms, JsonFormsChangeEvent } from '../../config/jsonforms'; import { vanillaRenderers, mergeStyles, defaultStyles } from '../../src'; import '../../vanilla.css'; +import { get } from 'lodash'; +import { JsonFormsI18nState } from '@jsonforms/core'; const schema = { properties: { string: { type: 'string', - description: 'a string' + description: 'a string', + pattern: '[a-z]+' }, multiString: { type: 'string', - description: 'a string' + description: 'a string', }, boolean: { type: 'boolean', - description: 'enable / disable number' + description: 'enable / disable number', }, boolean2: { type: 'boolean', - description: 'show / hide integer' + description: 'show / hide integer', }, number: { type: 'number', - description: 'a number' + description: 'a number', }, integer: { type: 'integer', - description: 'an integer' + description: 'an integer', }, enum: { type: 'string', enum: ['a', 'b', 'c'], - description: 'an enum' + description: 'an enum', }, oneOfEnum: { oneOf: [ { const: '1', title: 'Number 1' }, - { const: 'B', title: 'Foo' } + { const: 'B', title: 'Foo' }, ], - description: 'one of enum' + description: 'one of enum', }, date: { type: 'string', format: 'date', - description: 'a date' + description: 'a date', }, dateTime: { type: 'string', format: 'date-time', - description: 'a date time' + description: 'a date time', }, time: { type: 'string', format: 'time', - description: 'a time' + description: 'a time', }, array: { type: 'array', @@ -63,12 +66,12 @@ const schema = { type: 'object', properties: { name: { type: 'string' }, - age: { type: 'integer' } - } - } - } + age: { type: 'integer' }, + }, + }, + }, }, - required: ['string', 'number'] + required: ['string', 'number'], } as any; const uischema = { @@ -84,23 +87,23 @@ const uischema = { type: 'Control', scope: '#/properties/string', options: { - placeholder: 'this is a placeholder' - } + placeholder: 'this is a placeholder', + }, }, { type: 'Control', - scope: '#/properties/multiString' + scope: '#/properties/multiString', }, { type: 'Control', scope: '#/properties/boolean', options: { - placeholder: 'boolean placeholder' - } + placeholder: 'boolean placeholder', + }, }, { type: 'Control', - scope: '#/properties/boolean2' + scope: '#/properties/boolean2', }, { type: 'Control', @@ -110,12 +113,12 @@ const uischema = { condition: { scope: '#/properties/boolean', schema: { - const: true - } - } - } - } - ] + const: true, + }, + }, + }, + }, + ], }, { type: 'Group', @@ -129,37 +132,37 @@ const uischema = { condition: { scope: '#/properties/boolean2', schema: { - const: true - } - } - } + const: true, + }, + }, + }, }, { type: 'HorizontalLayout', elements: [ { type: 'Control', - scope: '#/properties/enum' + scope: '#/properties/enum', }, { type: 'Control', - scope: '#/properties/oneOfEnum' + scope: '#/properties/oneOfEnum', }, { type: 'Control', scope: '#/properties/date', options: { - placeholder: 'date placeholder' - } - } - ] + placeholder: 'date placeholder', + }, + }, + ], }, { type: 'Control', scope: '#/properties/dateTime', options: { - placeholder: 'date-time placeholder' - } + placeholder: 'date-time placeholder', + }, }, { type: 'Control', @@ -168,50 +171,52 @@ const uischema = { placeholder: 'time placeholder', styles: { control: { - root: 'control my-time' - } - } - } - } - ] - } - ] + root: 'control my-time', + }, + }, + }, + }, + ], + }, + ], }, { type: 'Label', - text: 'This is my label' + text: 'This is my label', }, { type: 'Control', scope: '#/properties/array', options: { - childLabelProp: 'age' - } - } - ] + childLabelProp: 'age', + }, + }, + ], } as any; // mergeStyles combines all classes from both styles definitions into one const myStyles = mergeStyles(defaultStyles, { - control: { root: 'my-control' } + control: { root: 'my-control' }, }); export default defineComponent({ name: 'app', components: { - JsonForms + JsonForms, }, - data: function() { + data: function () { + const i18n: Partial = { locale: 'en' }; return { renderers: Object.freeze(vanillaRenderers), data: { - number: 5 + number: 5, }, schema, uischema, config: { - hideRequiredAsterisk: true - } + hideRequiredAsterisk: true, + }, + i18n }; }, methods: { @@ -221,15 +226,26 @@ export default defineComponent({ name: { type: 'string', title: 'NAME', - description: 'The name' - } - } + description: 'The name', + }, + }, }; }, onChange(event: JsonFormsChangeEvent) { console.log(event); this.data = event.data; }, + translationChange(event: any) { + try { + const input = JSON.parse(event.target.value); + (this as any).i18n.translate = (key: string, defaultMessage: string | undefined) => { + const translated = get(input, key) as string; + return translated ?? defaultMessage; + }; + } catch (error) { + console.log('invalid translation input'); + } + }, switchAsterisk() { this.config.hideRequiredAsterisk = !this.config.hideRequiredAsterisk; }, @@ -250,23 +266,23 @@ export default defineComponent({ type: 'Control', scope: '#/properties/string', options: { - placeholder: 'this is a placeholder' - } + placeholder: 'this is a placeholder', + }, }, { type: 'Control', - scope: '#/properties/multiString' + scope: '#/properties/multiString', }, { type: 'Control', scope: '#/properties/boolean', options: { - placeholder: 'boolean placeholder' - } + placeholder: 'boolean placeholder', + }, }, { type: 'Control', - scope: '#/properties/boolean2' + scope: '#/properties/boolean2', }, { type: 'Control', @@ -276,12 +292,12 @@ export default defineComponent({ condition: { scope: '#/properties/boolean', schema: { - const: true - } - } - } - } - ] + const: true, + }, + }, + }, + }, + ], }, { type: 'Group', @@ -295,37 +311,37 @@ export default defineComponent({ condition: { scope: '#/properties/boolean2', schema: { - const: true - } - } - } + const: true, + }, + }, + }, }, { type: 'HorizontalLayout', elements: [ { type: 'Control', - scope: '#/properties/enum' + scope: '#/properties/enum', }, { type: 'Control', - scope: '#/properties/oneOfEnum' + scope: '#/properties/oneOfEnum', }, { type: 'Control', scope: '#/properties/date', options: { - placeholder: 'date placeholder' - } - } - ] + placeholder: 'date placeholder', + }, + }, + ], }, { type: 'Control', scope: '#/properties/dateTime', options: { - placeholder: 'date-time placeholder' - } + placeholder: 'date-time placeholder', + }, }, { type: 'Control', @@ -334,35 +350,35 @@ export default defineComponent({ placeholder: 'time placeholder', styles: { control: { - root: 'control my-time' - } - } - } - } - ] - } - ] + root: 'control my-time', + }, + }, + }, + }, + ], + }, + ], }, { type: 'Label', - text: 'This is my label' + text: 'This is my label', }, { type: 'Control', scope: '#/properties/array', options: { - childLabelProp: 'age' - } - } - ] + childLabelProp: 'age', + }, + }, + ], }; - } + }, }, provide() { return { - styles: myStyles + styles: myStyles, }; - } + }, }); @@ -388,6 +404,7 @@ export default defineComponent({ :uischema="uischema" :renderers="renderers" :config="config" + :i18n="i18n" @change="onChange" /> @@ -404,6 +421,7 @@ export default defineComponent({ >{{ JSON.stringify(config, null, 2) }} +