Skip to content

Commit 2778f01

Browse files
authored
Rework i18n support in JSON Forms core
UI strings can now be translated via special translation functions handed over to JSON Forms. Translations are automatically handed within the default mapping functions and are therefore available in all renderer sets. This includes a key-determination algorithm, allowing to either rely on using labels as keys or specifying 'i18n' keys in UI Schema options or directly within the JSON Schema. Errors are handled separately to allow for maximum flexibility. Includes test cases for the most common mapping functions. Also AJV is set to non-strict by default to not throw errors when handing over JSON Schemas containing 'i18n' keys.
1 parent 2bf9bcf commit 2778f01

File tree

22 files changed

+947
-261
lines changed

22 files changed

+947
-261
lines changed

packages/angular-material/example/app/app.component.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const itemTester: UISchemaTester = (_schema, schemaPath, _path) => {
7575
[schema]="selectedExample.schema"
7676
[uischema]="selectedExample.uischema"
7777
[renderers]="renderers"
78-
[locale]="currentLocale"
78+
[i18n]="i18n"
7979
[uischemas]="uischemas"
8080
[readonly]="readonly"
8181
[config]="config"
@@ -86,7 +86,9 @@ export class AppComponent {
8686
readonly renderers = angularMaterialRenderers;
8787
readonly examples = getExamples();
8888
selectedExample: ExampleDescription;
89-
currentLocale = 'en-US';
89+
i18n = {
90+
locale: 'en-US'
91+
}
9092
private readonly = false;
9193
data: any;
9294
uischemas: { tester: UISchemaTester; uischema: UISchemaElement; }[] = [
@@ -102,7 +104,7 @@ export class AppComponent {
102104
}
103105

104106
changeLocale(locale: string) {
105-
this.currentLocale = locale;
107+
this.i18n.locale = locale;
106108
}
107109

108110
toggleReadonly() {

packages/angular-material/test/number-control.spec.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,6 @@ describe(
144144
getJsonFormsService(component).init({
145145
core: state, i18n: {
146146
locale: 'en',
147-
localizedSchemas: undefined,
148-
localizedUISchemas: undefined
149147
}
150148
});
151149
getJsonFormsService(component).updateCore(
@@ -168,8 +166,6 @@ describe(
168166
getJsonFormsService(component).init({
169167
core: state, i18n: {
170168
locale: 'en',
171-
localizedSchemas: undefined,
172-
localizedUISchemas: undefined
173169
},config: {
174170
useGrouping: false
175171
},
@@ -196,8 +192,6 @@ describe(
196192
getJsonFormsService(component).init({
197193
core: state, i18n: {
198194
locale: 'en',
199-
localizedSchemas: undefined,
200-
localizedUISchemas: undefined
201195
},config: {
202196
useGrouping: true
203197
},

packages/angular/src/jsonforms-root.component.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,9 @@
2323
THE SOFTWARE.
2424
*/
2525
import {
26-
Component,
27-
EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges
26+
Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges
2827
} from '@angular/core';
29-
import { Actions, JsonFormsRendererRegistryEntry, JsonSchema, UISchemaElement, UISchemaTester, ValidationMode } from '@jsonforms/core';
28+
import { Actions, JsonFormsI18nState, JsonFormsRendererRegistryEntry, JsonSchema, UISchemaElement, UISchemaTester, ValidationMode } from '@jsonforms/core';
3029
import Ajv, { ErrorObject } from 'ajv';
3130
import { JsonFormsAngularService, USE_STATE_VALUE } from './jsonforms.service';
3231
@Component({
@@ -42,11 +41,11 @@ export class JsonForms implements OnChanges, OnInit {
4241
@Input() renderers: JsonFormsRendererRegistryEntry[];
4342
@Input() uischemas: { tester: UISchemaTester; uischema: UISchemaElement; }[];
4443
@Output() dataChange = new EventEmitter<any>();
45-
@Input() locale: string;
4644
@Input() readonly: boolean;
4745
@Input() validationMode: ValidationMode;
4846
@Input() ajv: Ajv;
4947
@Input() config: any;
48+
@Input() i18n: JsonFormsI18nState;
5049
@Output() errors = new EventEmitter<ErrorObject[]>();
5150

5251
private previousData:any;
@@ -67,7 +66,7 @@ export class JsonForms implements OnChanges, OnInit {
6766
validationMode: this.validationMode
6867
},
6968
uischemas: this.uischemas,
70-
i18n: { locale: this.locale, localizedSchemas: undefined, localizedUISchemas: undefined },
69+
i18n: this.i18n,
7170
renderers: this.renderers,
7271
config: this.config,
7372
readonly: this.readonly
@@ -87,6 +86,14 @@ export class JsonForms implements OnChanges, OnInit {
8786
this.initialized = true;
8887
}
8988

89+
ngDoCheck(): void {
90+
// we can't use ngOnChanges as then nested i18n changes will not be detected
91+
// the update will result in a no-op when the parameters did not change
92+
this.jsonformsService.updateI18n(
93+
Actions.updateI18n(this.i18n?.locale, this.i18n?.translate, this.i18n?.translateError)
94+
);
95+
}
96+
9097
// tslint:disable-next-line: cyclomatic-complexity
9198
ngOnChanges(changes: SimpleChanges): void {
9299
if (!this.initialized) {
@@ -97,7 +104,7 @@ export class JsonForms implements OnChanges, OnInit {
97104
const newUiSchema = changes.uischema;
98105
const newRenderers = changes.renderers;
99106
const newUischemas = changes.uischemas;
100-
const newLocale = changes.locale;
107+
const newI18n = changes.i18n;
101108
const newReadonly = changes.readonly;
102109
const newValidationMode = changes.validationMode;
103110
const newAjv = changes.ajv;
@@ -121,8 +128,10 @@ export class JsonForms implements OnChanges, OnInit {
121128
this.jsonformsService.setUiSchemas(newUischemas.currentValue);
122129
}
123130

124-
if (newLocale && !newLocale.isFirstChange()) {
125-
this.jsonformsService.setLocale(newLocale.currentValue);
131+
if (newI18n && !newI18n.isFirstChange()) {
132+
this.jsonformsService.updateI18n(
133+
Actions.updateI18n(newI18n.currentValue?.locale, newI18n.currentValue?.translate, newI18n.currentValue?.translateError)
134+
);
126135
}
127136

128137
if (newReadonly && !newReadonly.isFirstChange()) {

packages/angular/src/jsonforms.service.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,16 @@ import {
3434
JsonFormsState,
3535
JsonFormsSubStates,
3636
JsonSchema,
37-
LocaleActions,
37+
I18nActions,
3838
RankedTester,
3939
setConfig,
4040
SetConfigAction,
4141
UISchemaActions,
4242
UISchemaElement,
4343
uischemaRegistryReducer,
4444
UISchemaTester,
45-
ValidationMode
45+
ValidationMode,
46+
updateI18n
4647
} from '@jsonforms/core';
4748
import { BehaviorSubject, Observable } from 'rxjs';
4849
import { JsonFormsBaseRenderer } from './base.renderer';
@@ -56,9 +57,10 @@ export class JsonFormsAngularService {
5657
private _state: JsonFormsSubStates;
5758
private state: BehaviorSubject<JsonFormsState>;
5859

59-
init(initialState: JsonFormsSubStates = { core: { data: undefined, schema: undefined, uischema: undefined } }) {
60+
init(initialState: JsonFormsSubStates = { core: { data: undefined, schema: undefined, uischema: undefined, validationMode: 'ValidateAndShow' } }) {
6061
this._state = initialState;
6162
this._state.config = configReducer(undefined, setConfig(this._state.config));
63+
this._state.i18n = i18nReducer(this._state.i18n, updateI18n(this._state.i18n?.locale, this._state.i18n?.translate, this._state.i18n?.translateError));
6264
this.state = new BehaviorSubject({ jsonforms: this._state });
6365
const data = initialState.core.data;
6466
const schema = initialState.core.schema ?? generateJsonSchema(data);
@@ -117,16 +119,18 @@ export class JsonFormsAngularService {
117119
this.updateSubject();
118120
}
119121

120-
updateLocale<T extends LocaleActions>(localeAction: T): T {
121-
const localeState = i18nReducer(this._state.i18n, localeAction);
122-
this._state.i18n = localeState;
123-
this.updateSubject();
124-
return localeAction;
122+
updateI18n<T extends I18nActions>(i18nAction: T): T {
123+
const i18nState = i18nReducer(this._state.i18n, i18nAction);
124+
if (i18nState !== this._state.i18n) {
125+
this._state.i18n = i18nState;
126+
this.updateSubject();
127+
}
128+
return i18nAction;
125129
}
126130

127131
updateCore<T extends CoreActions>(coreAction: T): T {
128132
const coreState = coreReducer(this._state.core, coreAction);
129-
if(coreState !== this._state.core) {
133+
if (coreState !== this._state.core) {
130134
this._state.core = coreState;
131135
this.updateSubject();
132136
}

packages/core/src/actions/actions.ts

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { generateDefaultUISchema, generateJsonSchema } from '../generators';
2929

3030
import { RankedTester } from '../testers';
3131
import { UISchemaTester, ValidationMode } from '../reducers';
32+
import { ErrorTranslator, Translator } from '../i18n';
3233

3334
export const INIT: 'jsonforms/INIT' = 'jsonforms/INIT';
3435
export const UPDATE_CORE: 'jsonforms/UPDATE_CORE' = `jsonforms/UPDATE_CORE`;
@@ -51,10 +52,10 @@ export const SET_VALIDATION_MODE: 'jsonforms/SET_VALIDATION_MODE' =
5152
'jsonforms/SET_VALIDATION_MODE';
5253

5354
export const SET_LOCALE: 'jsonforms/SET_LOCALE' = `jsonforms/SET_LOCALE`;
54-
export const SET_LOCALIZED_SCHEMAS: 'jsonforms/SET_LOCALIZED_SCHEMAS' =
55-
'jsonforms/SET_LOCALIZED_SCHEMAS';
56-
export const SET_LOCALIZED_UISCHEMAS: 'jsonforms/SET_LOCALIZED_UISCHEMAS' =
57-
'jsonforms/SET_LOCALIZED_UISCHEMAS';
55+
export const SET_TRANSLATOR: 'jsonforms/SET_TRANSLATOR' =
56+
'jsonforms/SET_TRANSLATOR';
57+
export const UPDATE_I18N: 'jsonforms/UPDATE_I18N' =
58+
'jsonforms/UPDATE_I18N';
5859

5960
export const ADD_DEFAULT_DATA: 'jsonforms/ADD_DEFAULT_DATA' = `jsonforms/ADD_DEFAULT_DATA`;
6061
export const REMOVE_DEFAULT_DATA: 'jsonforms/REMOVE_DEFAULT_DATA' = `jsonforms/REMOVE_DEFAULT_DATA`;
@@ -275,33 +276,21 @@ export const unregisterUISchema = (
275276
};
276277
};
277278

278-
export type LocaleActions =
279+
export type I18nActions =
279280
| SetLocaleAction
280-
| SetLocalizedSchemasAction
281-
| SetLocalizedUISchemasAction;
281+
| SetTranslatorAction
282+
| UpdateI18nAction
282283

283284
export interface SetLocaleAction {
284285
type: 'jsonforms/SET_LOCALE';
285-
locale: string;
286+
locale: string | undefined;
286287
}
287288

288-
export const setLocale = (locale: string): SetLocaleAction => ({
289+
export const setLocale = (locale: string | undefined): SetLocaleAction => ({
289290
type: SET_LOCALE,
290291
locale
291292
});
292293

293-
export interface SetLocalizedSchemasAction {
294-
type: 'jsonforms/SET_LOCALIZED_SCHEMAS';
295-
localizedSchemas: Map<string, JsonSchema>;
296-
}
297-
298-
export const setLocalizedSchemas = (
299-
localizedSchemas: Map<string, JsonSchema>
300-
): SetLocalizedSchemasAction => ({
301-
type: SET_LOCALIZED_SCHEMAS,
302-
localizedSchemas
303-
});
304-
305294
export interface SetSchemaAction {
306295
type: 'jsonforms/SET_SCHEMA';
307296
schema: JsonSchema;
@@ -312,16 +301,37 @@ export const setSchema = (schema: JsonSchema): SetSchemaAction => ({
312301
schema
313302
});
314303

315-
export interface SetLocalizedUISchemasAction {
316-
type: 'jsonforms/SET_LOCALIZED_UISCHEMAS';
317-
localizedUISchemas: Map<string, UISchemaElement>;
304+
export interface SetTranslatorAction {
305+
type: 'jsonforms/SET_TRANSLATOR';
306+
translator?: Translator;
307+
errorTranslator?: ErrorTranslator;
308+
}
309+
310+
export const setTranslator = (
311+
translator?: Translator,
312+
errorTranslator?: ErrorTranslator
313+
): SetTranslatorAction => ({
314+
type: SET_TRANSLATOR,
315+
translator,
316+
errorTranslator
317+
});
318+
319+
export interface UpdateI18nAction {
320+
type: 'jsonforms/UPDATE_I18N';
321+
locale: string | undefined;
322+
translator: Translator | undefined;
323+
errorTranslator: ErrorTranslator | undefined;
318324
}
319325

320-
export const setLocalizedUISchemas = (
321-
localizedUISchemas: Map<string, UISchemaElement>
322-
): SetLocalizedUISchemasAction => ({
323-
type: SET_LOCALIZED_UISCHEMAS,
324-
localizedUISchemas
326+
export const updateI18n = (
327+
locale: string | undefined,
328+
translator: Translator | undefined,
329+
errorTranslator: ErrorTranslator | undefined
330+
): UpdateI18nAction => ({
331+
type: UPDATE_I18N,
332+
locale,
333+
translator,
334+
errorTranslator
325335
});
326336

327337
export interface SetUISchemaAction {

packages/core/src/i18n/i18nTypes.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ErrorObject } from 'ajv';
2+
import { JsonSchema, UISchemaElement } from '../models';
3+
4+
export type Translator = {
5+
(id: string, defaultMessage: string, values?: any): string;
6+
(id: string, defaultMessage: undefined, values?: any): string | undefined;
7+
}
8+
9+
export type ErrorTranslator = (error: ErrorObject, translate: Translator, uischema?: UISchemaElement) => string;
10+
11+
export interface JsonFormsI18nState {
12+
locale?: string;
13+
translate?: Translator;
14+
translateError?: ErrorTranslator;
15+
}
16+
17+
export type i18nJsonSchema = JsonSchema & {i18n?: string};

packages/core/src/i18n/i18nUtil.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { ErrorObject } from 'ajv';
2+
import { UISchemaElement } from '../models';
3+
import { formatErrorMessage } from '../util';
4+
import { i18nJsonSchema, ErrorTranslator, Translator } from './i18nTypes';
5+
6+
export const getI18nKey = (
7+
schema: i18nJsonSchema | undefined,
8+
uischema: UISchemaElement | undefined,
9+
key: string
10+
): string | undefined => {
11+
if (uischema?.options?.i18n) {
12+
return `${uischema.options.i18n}.${key}`;
13+
}
14+
if (schema?.i18n) {
15+
return `${schema.i18n}.${key}`;
16+
}
17+
return undefined;
18+
};
19+
20+
export const defaultTranslator: Translator = (_id: string, defaultMessage: string | undefined) => defaultMessage;
21+
22+
export const defaultErrorTranslator: ErrorTranslator = (error, t, uischema) => {
23+
// check whether there is a special keyword message
24+
const keyInSchemas = getI18nKey(
25+
error.parentSchema,
26+
uischema,
27+
`error.${error.keyword}`
28+
);
29+
const specializedKeywordMessage = keyInSchemas && t(keyInSchemas, undefined);
30+
if (specializedKeywordMessage !== undefined) {
31+
return specializedKeywordMessage;
32+
}
33+
34+
// check whether there is a generic keyword message
35+
const genericKeywordMessage = t(`error.${error.keyword}`, undefined);
36+
if (genericKeywordMessage !== undefined) {
37+
return genericKeywordMessage;
38+
}
39+
40+
// check whether there is a customization for the default message
41+
const messageCustomization = t(error.message, undefined);
42+
if (messageCustomization !== undefined) {
43+
return messageCustomization;
44+
}
45+
46+
// rewrite required property messages (if they were not customized) as we place them next to the respective input
47+
if (error.keyword === 'required') {
48+
return t('is a required property', 'is a required property');
49+
}
50+
51+
return error.message;
52+
};
53+
54+
/**
55+
* Returns the determined error message for the given errors.
56+
* All errors must correspond to the given schema and uischema.
57+
*/
58+
export const getCombinedErrorMessage = (
59+
errors: ErrorObject[],
60+
et: ErrorTranslator,
61+
t: Translator,
62+
schema?: i18nJsonSchema,
63+
uischema?: UISchemaElement
64+
) => {
65+
if (errors.length > 0 && t) {
66+
// check whether there is a special message which overwrites all others
67+
const keyInSchemas = getI18nKey(schema, uischema, 'error.custom');
68+
const specializedErrorMessage = keyInSchemas && t(keyInSchemas, undefined);
69+
if (specializedErrorMessage !== undefined) {
70+
return specializedErrorMessage;
71+
}
72+
}
73+
return formatErrorMessage(
74+
errors.map(error => et(error, t, uischema))
75+
);
76+
};

packages/core/src/i18n/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './i18nTypes';
2+
export * from './i18nUtil';

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,4 @@ export * from './util';
3434

3535
export * from './Helpers';
3636
export * from './store';
37+
export * from './i18n';

0 commit comments

Comments
 (0)