From a8fa71246c95ed2b99ff80a169f47d1634800a90 Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Thu, 9 Dec 2021 16:54:31 +0100 Subject: [PATCH 1/2] Use default calculated i18n keys If there is no 'i18n' key in schema or ui schema a default calculated key based on the data path of the respective control is used. Additionally fixes the issue that the 'required' message of localized errors were overwritten by JSON Forms. --- packages/core/src/i18n/i18nUtil.ts | 57 +++++++++--- packages/core/src/reducers/reducers.ts | 20 ---- packages/core/src/util/cell.ts | 8 +- packages/core/src/util/renderer.ts | 29 +++--- packages/core/test/i18n/i18nUtil.test.ts | 48 ++++++++++ packages/core/test/reducers/core.test.ts | 5 +- packages/core/test/util/renderer.test.ts | 112 +++++++++++++++-------- 7 files changed, 183 insertions(+), 96 deletions(-) create mode 100644 packages/core/test/i18n/i18nUtil.test.ts diff --git a/packages/core/src/i18n/i18nUtil.ts b/packages/core/src/i18n/i18nUtil.ts index e44b0c7d5..75f6ffd6b 100644 --- a/packages/core/src/i18n/i18nUtil.ts +++ b/packages/core/src/i18n/i18nUtil.ts @@ -1,32 +1,60 @@ import { ErrorObject } from 'ajv'; import { UISchemaElement } from '../models'; +import { getControlPath } from '../reducers'; import { formatErrorMessage } from '../util'; import { i18nJsonSchema, ErrorTranslator, Translator } from './i18nTypes'; +export const getI18nKeyPrefixBySchema = ( + schema: i18nJsonSchema | undefined, + uischema: UISchemaElement | undefined +): string | undefined => { + return uischema?.options?.i18n ?? schema?.i18n ?? undefined; +}; + +/** + * Transforms a given path to a prefix which can be used for i18n keys. + * Returns 'root' for empty paths and removes array indices + */ +export const transformPathToI18nPrefix = (path: string) => { + return ( + path + ?.split('.') + .filter(segment => !/^\d+$/.test(segment)) + .join('.') || 'root' + ); +}; + +export const getI18nKeyPrefix = ( + schema: i18nJsonSchema | undefined, + uischema: UISchemaElement | undefined, + path: string | undefined +): string | undefined => { + return ( + getI18nKeyPrefixBySchema(schema, uischema) ?? + transformPathToI18nPrefix(path) + ); +}; + export const getI18nKey = ( schema: i18nJsonSchema | undefined, uischema: UISchemaElement | undefined, + path: string | undefined, key: string ): string | undefined => { - if (uischema?.options?.i18n) { - return `${uischema.options.i18n}.${key}`; - } - if (schema?.i18n) { - return `${schema.i18n}.${key}`; - } - return undefined; + return `${getI18nKeyPrefix(schema, uischema, path)}.${key}`; }; 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( + const i18nKey = getI18nKey( error.parentSchema, uischema, + getControlPath(error), `error.${error.keyword}` ); - const specializedKeywordMessage = keyInSchemas && t(keyInSchemas, undefined); + const specializedKeywordMessage = t(i18nKey, undefined); if (specializedKeywordMessage !== undefined) { return specializedKeywordMessage; } @@ -44,7 +72,7 @@ export const defaultErrorTranslator: ErrorTranslator = (error, t, uischema) => { } // rewrite required property messages (if they were not customized) as we place them next to the respective input - if (error.keyword === 'required') { + if (error.keyword === 'required' && error.message?.startsWith('must have required property')) { return t('is a required property', 'is a required property'); } @@ -53,19 +81,20 @@ export const defaultErrorTranslator: ErrorTranslator = (error, t, uischema) => { /** * Returns the determined error message for the given errors. - * All errors must correspond to the given schema and uischema. + * All errors must correspond to the given schema, uischema or path. */ export const getCombinedErrorMessage = ( errors: ErrorObject[], et: ErrorTranslator, t: Translator, schema?: i18nJsonSchema, - uischema?: UISchemaElement + uischema?: UISchemaElement, + path?: string ) => { 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); + const customErrorKey = getI18nKey(schema, uischema, path, 'error.custom'); + const specializedErrorMessage = t(customErrorKey, undefined); if (specializedErrorMessage !== undefined) { return specializedErrorMessage; } diff --git a/packages/core/src/reducers/reducers.ts b/packages/core/src/reducers/reducers.ts index 5f210670c..b5d35d9ce 100644 --- a/packages/core/src/reducers/reducers.ts +++ b/packages/core/src/reducers/reducers.ts @@ -27,11 +27,7 @@ import { ControlElement, UISchemaElement } from '../models'; import { coreReducer, errorAt, - errorsAt, - getControlPath, - JsonFormsCore, subErrorsAt, - ValidationMode } from './core'; import { defaultDataReducer } from './default-data'; import { rendererReducer } from './renderers'; @@ -40,7 +36,6 @@ import { findMatchingUISchema, JsonFormsUISchemaRegistryEntry, uischemaRegistryReducer, - UISchemaTester } from './uischemas'; import { fetchErrorTranslator, @@ -57,19 +52,6 @@ import get from 'lodash/get'; import { fetchTranslator } from '.'; import { ErrorTranslator, Translator } from '../i18n'; -export { - rendererReducer, - cellReducer, - coreReducer, - i18nReducer, - configReducer, - UISchemaTester, - uischemaRegistryReducer, - findMatchingUISchema, - JsonFormsUISchemaRegistryEntry -}; -export { JsonFormsCore, ValidationMode }; - export const jsonFormsReducerConfig = { core: coreReducer, renderers: rendererReducer, @@ -128,8 +110,6 @@ export const getErrorAt = (instancePath: string, schema: JsonSchema) => ( return errorAt(instancePath, schema)(state.jsonforms.core); }; -export { errorsAt, getControlPath }; - export const getSubErrorsAt = (instancePath: string, schema: JsonSchema) => ( state: JsonFormsState ) => subErrorsAt(instancePath, schema)(state.jsonforms.core); diff --git a/packages/core/src/util/cell.ts b/packages/core/src/util/cell.ts index 6eadb1b3a..2711edf60 100644 --- a/packages/core/src/util/cell.ts +++ b/packages/core/src/util/cell.ts @@ -55,7 +55,7 @@ import { } from './renderer'; import { JsonFormsState } from '../store'; import { JsonSchema } from '../models'; -import { i18nJsonSchema } from '..'; +import { getI18nKeyPrefix } from '..'; export { JsonFormsCellRendererRegistryEntry }; @@ -202,14 +202,14 @@ export const defaultMapStateToEnumCellProps = ( enumToEnumOptionMapper( e, getTranslator()(state), - props.uischema?.options?.i18n ?? (props.schema as i18nJsonSchema).i18n + getI18nKeyPrefix(props.schema, props.uischema, props.path) ) ) || (props.schema.const && [ enumToEnumOptionMapper( props.schema.const, getTranslator()(state), - props.uischema?.options?.i18n ?? (props.schema as i18nJsonSchema).i18n + getI18nKeyPrefix(props.schema, props.uischema, props.path) ) ]); return { @@ -235,7 +235,7 @@ export const mapStateToOneOfEnumCellProps = ( oneOfToEnumOptionMapper( oneOfSubSchema, getTranslator()(state), - props.uischema?.options?.i18n + getI18nKeyPrefix(props.schema, props.uischema, props.path) ) ); return { diff --git a/packages/core/src/util/renderer.ts b/packages/core/src/util/renderer.ts index 79655c4d1..f315b74f5 100644 --- a/packages/core/src/util/renderer.ts +++ b/packages/core/src/util/renderer.ts @@ -55,9 +55,7 @@ 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 }; +import { getCombinedErrorMessage, getI18nKey, getI18nKeyPrefix, Translator } from '../i18n'; const isRequired = ( schema: JsonSchema, @@ -195,7 +193,7 @@ export const enumToEnumOptionMapper = ( export const oneOfToEnumOptionMapper = ( e: any, t?: Translator, - uiSchemaI18nKey?: string + fallbackI18nKey?: string ): EnumOption => { let label = e.title ?? @@ -204,8 +202,8 @@ export const oneOfToEnumOptionMapper = ( // 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 if (fallbackI18nKey) { + label = t(`${fallbackI18nKey}.${label}`, label); } else { label = t(label, label); } @@ -464,9 +462,9 @@ export const mapStateToControlProps = ( 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); + const i18nLabel = t(getI18nKey(schema, uischema, path, 'label'), label); + const i18nDescription = t(getI18nKey(schema, uischema, path, 'description'), description); + const i18nErrorMessage = getCombinedErrorMessage(errors, te, t, schema, uischema, path); return { data, @@ -518,14 +516,14 @@ export const mapStateToEnumControlProps = ( enumToEnumOptionMapper( e, getTranslator()(state), - props.uischema?.options?.i18n ?? (props.schema as i18nJsonSchema).i18n + getI18nKeyPrefix(props.schema, props.uischema, props.path) ) ) || (props.schema.const && [ enumToEnumOptionMapper( props.schema.const, getTranslator()(state), - props.uischema?.options?.i18n ?? (props.schema as i18nJsonSchema).i18n + getI18nKeyPrefix(props.schema, props.uischema, props.path) ) ]); return { @@ -551,7 +549,7 @@ export const mapStateToOneOfEnumControlProps = ( oneOfToEnumOptionMapper( oneOfSubSchema, getTranslator()(state), - props.uischema?.options?.i18n + getI18nKeyPrefix(props.schema, props.uischema, props.path) ) ); return { @@ -579,14 +577,14 @@ export const mapStateToMultiEnumControlProps = ( oneOfToEnumOptionMapper( oneOfSubSchema, state.jsonforms.i18n?.translate, - props.uischema?.options?.i18n + getI18nKeyPrefix(props.schema, props.uischema, props.path) ) )) || items?.enum?.map(e => enumToEnumOptionMapper( e, state.jsonforms.i18n?.translate, - props.uischema?.options?.i18n ?? (props.schema as i18nJsonSchema).i18n + getI18nKeyPrefix(props.schema, props.uischema, props.path) ) ); return { @@ -923,7 +921,7 @@ export interface StatePropsOfCombinator extends OwnPropsOfControl { data: any; } -const mapStateToCombinatorRendererProps = ( +export const mapStateToCombinatorRendererProps = ( state: JsonFormsState, ownProps: OwnPropsOfControl, keyword: CombinatorKeyword @@ -1052,6 +1050,7 @@ export const mapStateToArrayLayoutProps = ( getErrorTranslator()(state), getTranslator()(state), undefined, + undefined, undefined ); diff --git a/packages/core/test/i18n/i18nUtil.test.ts b/packages/core/test/i18n/i18nUtil.test.ts new file mode 100644 index 000000000..4edc94eef --- /dev/null +++ b/packages/core/test/i18n/i18nUtil.test.ts @@ -0,0 +1,48 @@ +/* + The MIT License + + Copyright (c) 2017-2021 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import test from 'ava'; + +import { transformPathToI18nPrefix } from '../../src'; + +test('transformPathToI18nPrefix returns root when empty', t => { + t.is(transformPathToI18nPrefix(''), 'root'); +}); + +test('transformPathToI18nPrefix does not modify non-array paths', t => { + t.is(transformPathToI18nPrefix('foo'), 'foo'); + t.is(transformPathToI18nPrefix('foo.bar'), 'foo.bar'); + t.is(transformPathToI18nPrefix('bar3.foo2'), 'bar3.foo2'); +}); + +test('transformPathToI18nPrefix removes array indices', t => { + t.is(transformPathToI18nPrefix('foo.2.bar'), 'foo.bar'); + t.is(transformPathToI18nPrefix('foo.234324234.bar'), 'foo.bar'); + t.is(transformPathToI18nPrefix('foo.0.bar'), 'foo.bar'); + t.is(transformPathToI18nPrefix('foo.0.bar.1.foobar'), 'foo.bar.foobar'); + t.is(transformPathToI18nPrefix('3.foobar'), 'foobar'); + t.is(transformPathToI18nPrefix('foobar.3'), 'foobar'); + t.is(transformPathToI18nPrefix('foo1.23.b2ar3.1.5.foo'), 'foo1.b2ar3.foo'); + t.is(transformPathToI18nPrefix('3'), 'root'); +}); diff --git a/packages/core/test/reducers/core.test.ts b/packages/core/test/reducers/core.test.ts index ab5e8a5e3..a52edd57e 100644 --- a/packages/core/test/reducers/core.test.ts +++ b/packages/core/test/reducers/core.test.ts @@ -25,7 +25,7 @@ import test from 'ava'; import Ajv from 'ajv'; import { coreReducer } from '../../src/reducers'; -import { init, update, updateErrors } from '../../src/actions'; +import { init, setSchema, setValidationMode, update, updateCore, updateErrors } from '../../src/actions'; import { JsonSchema } from '../../src/models/jsonSchema'; import { errorAt, @@ -34,9 +34,8 @@ import { subErrorsAt } from '../../src/reducers/core'; -import { createAjv, updateCore } from '../../src'; -import { setSchema, setValidationMode } from '../../lib'; import { cloneDeep } from 'lodash'; +import { createAjv } from '../../src/util/validator'; test('core reducer should support v7', t => { const schema: JsonSchema = { diff --git a/packages/core/test/util/renderer.test.ts b/packages/core/test/util/renderer.test.ts index cba251725..1a1953efd 100644 --- a/packages/core/test/util/renderer.test.ts +++ b/packages/core/test/util/renderer.test.ts @@ -23,47 +23,25 @@ THE SOFTWARE. */ import * as _ from 'lodash'; -import { init, update, UPDATE_DATA, UpdateAction } from '../../src/actions'; import * as Redux from 'redux'; -import { - clearAllIds, - computeLabel, - createAjv, - createDefaultValue, - mapDispatchToArrayControlProps, - mapDispatchToControlProps, - mapStateToArrayLayoutProps, - mapStateToControlProps, - mapStateToJsonFormsRendererProps, - mapStateToLayoutProps, - mapStateToOneOfProps, - mapStateToMultiEnumControlProps, - mapDispatchToMultiEnumProps -} from '../../src/util'; import configureStore from 'redux-mock-store'; import test from 'ava'; -import { generateDefaultUISchema } from '../../src/generators'; -import { - ControlElement, - CoreActions, - coreReducer, - Dispatch, - JsonFormsCore, - JsonFormsState, - JsonSchema, - JsonSchema7, - mapStateToAnyOfProps, - OwnPropsOfControl, - rankWith, - RuleEffect, - UISchemaElement, - setValidationMode, - defaultJsonFormsI18nState, - i18nJsonSchema, - mapStateToEnumControlProps, - mapStateToOneOfEnumControlProps -} from '../../src'; + import { ErrorObject } from 'ajv'; +import { JsonFormsState } from '../../src/store'; +import { coreReducer, JsonFormsCore } from '../../src/reducers/core'; +import { Dispatch } from '../../src/util/type'; +import { CoreActions, init, setValidationMode, update, UpdateAction, UPDATE_DATA } from '../../src/actions/actions'; +import { ControlElement, RuleEffect, UISchemaElement } from '../../src/models/uischema'; +import { computeLabel, createDefaultValue, mapDispatchToArrayControlProps, mapDispatchToControlProps, mapDispatchToMultiEnumProps, mapStateToAnyOfProps, mapStateToArrayLayoutProps, mapStateToControlProps, mapStateToEnumControlProps, mapStateToJsonFormsRendererProps, mapStateToLayoutProps, mapStateToMultiEnumControlProps, mapStateToOneOfEnumControlProps, mapStateToOneOfProps, OwnPropsOfControl } from '../../src/util/renderer'; +import { clearAllIds } from '../../src/util/ids'; +import { generateDefaultUISchema } from '../../src/generators/uischema'; +import { JsonSchema } from '../../src/models/jsonSchema'; +import { rankWith } from '../../src/testers/testers'; +import { createAjv } from '../../src/util/validator'; +import { JsonSchema7 } from '../../src/models/jsonSchema7'; +import { defaultJsonFormsI18nState } from '../../src/reducers/i18n'; +import { i18nJsonSchema } from '../../src/i18n/i18nTypes'; const middlewares: Redux.Middleware[] = []; const mockStore = configureStore(middlewares); @@ -1304,7 +1282,7 @@ test('mapStateToControlProps - i18n - default translation has no effect', t => { t.is(props.description, undefined); }); -test('mapStateToControlProps - i18n - translation via label key', t => { +test('mapStateToControlProps - i18n - translation via path key', t => { const ownProps = { uischema: coreUISchema }; @@ -1312,6 +1290,24 @@ test('mapStateToControlProps - i18n - translation via label key', t => { state.jsonforms.i18n = defaultJsonFormsI18nState; state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { switch(key){ + case 'firstName.label': 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 default message', t => { + const ownProps = { + uischema: coreUISchema + }; + const state: JsonFormsState = createState(coreUISchema); + state.jsonforms.i18n = defaultJsonFormsI18nState; + state.jsonforms.i18n.translate = (_key: string, defaultMessage: string | undefined) => { + switch(defaultMessage){ case 'First Name': return 'my translation'; default: return defaultMessage; } @@ -1601,7 +1597,7 @@ test('mapStateToEnumControlProps - i18n - default translation has no effect', t t.is(props.options[0].label, 'a'); }); -test('mapStateToEnumControlProps - i18n - label translation', t => { +test('mapStateToEnumControlProps - i18n - path label translation', t => { const ownProps = { uischema: coreUISchema }; @@ -1610,6 +1606,24 @@ test('mapStateToEnumControlProps - i18n - label translation', t => { state.jsonforms.i18n = defaultJsonFormsI18nState; state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { switch(key){ + case 'firstName.a': return 'my message'; + default: return defaultMessage; + } + } + + const props = mapStateToEnumControlProps(state, ownProps); + t.is(props.options[0].label, 'my message'); +}); + +test('mapStateToEnumControlProps - i18n - defaultMessage 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(defaultMessage){ case 'a': return 'my message'; default: return defaultMessage; } @@ -1662,7 +1676,7 @@ test('mapStateToOneOfEnumControlProps- i18n - default translation has no effect' t.is(props.options[0].label, 'foo'); }); -test('mapStateToOneOfEnumControlProps - i18n - label translation', t => { +test('mapStateToOneOfEnumControlProps - i18n - path label translation', t => { const ownProps = { uischema: coreUISchema }; @@ -1671,6 +1685,24 @@ test('mapStateToOneOfEnumControlProps - i18n - label translation', t => { state.jsonforms.i18n = defaultJsonFormsI18nState; state.jsonforms.i18n.translate = (key: string, defaultMessage: string | undefined) => { switch(key){ + case 'firstName.foo': return 'my message'; + default: return defaultMessage; + } + } + + const props = mapStateToOneOfEnumControlProps(state, ownProps); + t.is(props.options[0].label, 'my message'); +}); + +test('mapStateToOneOfEnumControlProps - i18n - default message 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(defaultMessage){ case 'foo': return 'my message'; default: return defaultMessage; } From 9b83a44c724ea8f149eaf5f4a34616156d5373b5 Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Fri, 10 Dec 2021 09:29:40 +0100 Subject: [PATCH 2/2] Fix import in cell.ts --- packages/core/src/util/cell.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/util/cell.ts b/packages/core/src/util/cell.ts index 2711edf60..8ec53ede8 100644 --- a/packages/core/src/util/cell.ts +++ b/packages/core/src/util/cell.ts @@ -55,7 +55,7 @@ import { } from './renderer'; import { JsonFormsState } from '../store'; import { JsonSchema } from '../models'; -import { getI18nKeyPrefix } from '..'; +import { getI18nKeyPrefix } from '../i18n'; export { JsonFormsCellRendererRegistryEntry };