diff --git a/MIGRATION.md b/MIGRATION.md index 796a3a8dc8..41f7bfcc89 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,45 @@ # Migration guide +## Migrating to JSON Forms 3.8 + +### `Translator` type changed from overloaded signatures to a generic conditional type + +The `Translator` type was changed to improve compatibility with TypeScript's `strictFunctionTypes` and `strictNullChecks` compiler options (see [#2528](https://github.com/eclipsesource/jsonforms/issues/2528)). + +If you were previously assigning a function directly to the `Translator` type, this may no longer compile: + +```ts +// No longer compiles +const t: Translator = (id, defaultMessage) => defaultMessage ?? id; +``` + +Use the new `createTranslator` helper instead: + +```ts +import { createTranslator } from '@jsonforms/core'; + +const t = createTranslator((id, defaultMessage) => defaultMessage ?? id); +``` + +This also replaces the `as Translator` workaround that was previously needed under strict TypeScript settings. + +#### Vue: `Translator` return type in Options API + +If you have custom Vue renderers that access a `Translator` via `this` (Options API), the return type may no longer narrow to `string` even when a `defaultMessage` is provided. +This is because Vue's ref unwrapping loses the generic parameter of the new conditional type. + +To fix this, use `as string` when you know a default message is always provided: + +```ts +// Before (may now return string | undefined) +return this.t(label, label); + +// After +return this.t(label, label) as string; +``` + +This does not affect the Composition API where `Translator` is accessed directly from a `ComputedRef`. + ## Migrating to JSON Forms 3.7 ### Angular support now targets Angular 19 to 21 diff --git a/packages/core/src/i18n/i18nUtil.ts b/packages/core/src/i18n/i18nUtil.ts index bcef4e5a17..e51eece979 100644 --- a/packages/core/src/i18n/i18nUtil.ts +++ b/packages/core/src/i18n/i18nUtil.ts @@ -65,10 +65,25 @@ export const addI18nKeyToPrefix = ( return `${i18nKeyPrefix}.${key}`; }; -export const defaultTranslator: Translator = ( - _id: string, - defaultMessage: string | undefined -) => defaultMessage; +export const createTranslator = + ( + fn: ( + id: string, + defaultMessage: string | undefined, + values?: any + ) => string | undefined + ): Translator => + (id: string, defaultMessage?: string, values?: any) => { + const translation = fn(id, defaultMessage, values); + if (translation === undefined) { + return defaultMessage; + } + return translation; + }; + +export const defaultTranslator: Translator = createTranslator( + (_id, defaultMessage) => defaultMessage +); export const defaultErrorTranslator: ErrorTranslator = (error, t, uischema) => { // check whether there is a special keyword message diff --git a/packages/core/src/store/i18nTypes.ts b/packages/core/src/store/i18nTypes.ts index 64002f393a..f4a68e6ddc 100644 --- a/packages/core/src/store/i18nTypes.ts +++ b/packages/core/src/store/i18nTypes.ts @@ -1,11 +1,11 @@ import type { ErrorObject } from 'ajv'; import type { JsonSchema, UISchemaElement } from '../models'; -export type Translator = { - (id: string, defaultMessage: string, values?: any): string; - (id: string, defaultMessage: undefined, values?: any): string | undefined; - (id: string, defaultMessage?: string, values?: any): string | undefined; -}; +export type Translator = ( + id: string, + defaultMessage?: D, + values?: any +) => D extends string ? string : string | undefined; export type ErrorTranslator = ( error: ErrorObject, diff --git a/packages/examples/src/examples/arrays-with-translated-custom-element-label.ts b/packages/examples/src/examples/arrays-with-translated-custom-element-label.ts index efd39f0ed4..d4a06f8416 100644 --- a/packages/examples/src/examples/arrays-with-translated-custom-element-label.ts +++ b/packages/examples/src/examples/arrays-with-translated-custom-element-label.ts @@ -23,7 +23,7 @@ THE SOFTWARE. */ import { registerExamples } from '../register'; -import { JsonSchema7, Translator } from '@jsonforms/core'; +import { createTranslator, JsonSchema7, Translator } from '@jsonforms/core'; export const data = { article: { @@ -265,9 +265,9 @@ export const uischema = { ], }; -export const translate: Translator = (key: string) => { +export const translate: Translator = createTranslator((key) => { return 'translator.' + key; -}; +}); registerExamples([ { diff --git a/packages/examples/src/examples/arraysI18n.ts b/packages/examples/src/examples/arraysI18n.ts index 8b17339304..4a0e074ef9 100644 --- a/packages/examples/src/examples/arraysI18n.ts +++ b/packages/examples/src/examples/arraysI18n.ts @@ -23,7 +23,11 @@ THE SOFTWARE. */ import { registerExamples } from '../register'; -import { ArrayTranslationEnum, Translator } from '@jsonforms/core'; +import { + ArrayTranslationEnum, + createTranslator, + Translator, +} from '@jsonforms/core'; import get from 'lodash/get'; export const schema = { @@ -88,9 +92,9 @@ export const translations = { 'Are you sure you want to delete this comment?', }, }; -export const translate: Translator = (key: string, defaultMessage: string) => { +export const translate: Translator = createTranslator((key, defaultMessage) => { return get(translations, key) ?? defaultMessage; -}; +}); registerExamples([ { diff --git a/packages/examples/src/examples/categorization.ts b/packages/examples/src/examples/categorization.ts index 63a83e5215..6b70ba2fa1 100644 --- a/packages/examples/src/examples/categorization.ts +++ b/packages/examples/src/examples/categorization.ts @@ -22,7 +22,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { Translator } from '@jsonforms/core'; +import { createTranslator, Translator } from '@jsonforms/core'; import get from 'lodash/get'; import { registerExamples } from '../register'; @@ -292,9 +292,9 @@ export const translations = { label: 'Address', }, }; -export const translate: Translator = (key: string, defaultMessage: string) => { +export const translate: Translator = createTranslator((key, defaultMessage) => { return get(translations, key) ?? defaultMessage; -}; +}); registerExamples([ { diff --git a/packages/examples/src/examples/enumI18n.ts b/packages/examples/src/examples/enumI18n.ts index fdfdf733a5..66f0044fd4 100644 --- a/packages/examples/src/examples/enumI18n.ts +++ b/packages/examples/src/examples/enumI18n.ts @@ -23,7 +23,7 @@ THE SOFTWARE. */ import { registerExamples } from '../register'; -import { Translator } from '@jsonforms/core'; +import { createTranslator, Translator } from '@jsonforms/core'; import get from 'lodash/get'; export const schema = { @@ -116,12 +116,9 @@ export const translations: Record = { 'status.rejected': 'Declined', }; -export const translate: Translator = ( - key: string, - defaultMessage: string | undefined -) => { +export const translate: Translator = createTranslator((key, defaultMessage) => { return get(translations, key) ?? defaultMessage; -}; +}); registerExamples([ { diff --git a/packages/examples/src/examples/i18n.ts b/packages/examples/src/examples/i18n.ts index 43f7ccf474..c2c67850c4 100644 --- a/packages/examples/src/examples/i18n.ts +++ b/packages/examples/src/examples/i18n.ts @@ -30,6 +30,7 @@ import { AnyAction, Dispatch, Translator, + createTranslator, } from '@jsonforms/core'; import get from 'lodash/get'; import localize from 'ajv-i18n/localize'; @@ -99,9 +100,9 @@ export const translations = { }, additionalInformationLabel: 'Additional Information', }; -export const translate: Translator = (key: string, defaultMessage: string) => { +export const translate: Translator = createTranslator((key, defaultMessage) => { return get(translations, key) ?? defaultMessage; -}; +}); registerExamples([ { diff --git a/packages/material-renderers/test/renderers/util.ts b/packages/material-renderers/test/renderers/util.ts index 9b2092d79f..c97b3bd9df 100644 --- a/packages/material-renderers/test/renderers/util.ts +++ b/packages/material-renderers/test/renderers/util.ts @@ -28,6 +28,7 @@ import { JsonFormsCore, JsonSchema, TesterContext, + createTranslator, Translator, UISchemaElement, } from '@jsonforms/core'; @@ -58,4 +59,6 @@ export const createTesterContext = ( return { rootSchema, config }; }; -export const testTranslator: Translator = (key: string) => 'translator.' + key; +export const testTranslator: Translator = createTranslator( + (key) => 'translator.' + key +); diff --git a/packages/vue-vuetify/src/controls/DateControlRenderer.vue b/packages/vue-vuetify/src/controls/DateControlRenderer.vue index 4a1e7c5760..0581413a2a 100644 --- a/packages/vue-vuetify/src/controls/DateControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/DateControlRenderer.vue @@ -384,14 +384,14 @@ const controlRenderer = defineComponent({ ? this.appliedOptions.cancelLabel : 'Cancel'; - return this.t(label, label); + return this.t(label, label) as string; }, okLabel(): string { const label = typeof this.appliedOptions.okLabel == 'string' ? this.appliedOptions.okLabel : 'OK'; - return this.t(label, label); + return this.t(label, label) as string; }, showActions(): boolean { return this.appliedOptions.showActions === true; diff --git a/packages/vue-vuetify/src/controls/DateTimeControlRenderer.vue b/packages/vue-vuetify/src/controls/DateTimeControlRenderer.vue index 76d2367881..5dfaf3918f 100644 --- a/packages/vue-vuetify/src/controls/DateTimeControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/DateTimeControlRenderer.vue @@ -584,14 +584,14 @@ const controlRenderer = defineComponent({ ? this.appliedOptions.cancelLabel : 'Cancel'; - return this.t(label, label); + return this.t(label, label) as string; }, okLabel(): string { const label = typeof this.appliedOptions.okLabel == 'string' ? this.appliedOptions.okLabel : 'OK'; - return this.t(label, label); + return this.t(label, label) as string; }, showActions(): boolean { return this.appliedOptions.showActions === true; diff --git a/packages/vue-vuetify/src/controls/TimeControlRenderer.vue b/packages/vue-vuetify/src/controls/TimeControlRenderer.vue index 2e65528e1b..ebc4faaae2 100644 --- a/packages/vue-vuetify/src/controls/TimeControlRenderer.vue +++ b/packages/vue-vuetify/src/controls/TimeControlRenderer.vue @@ -365,7 +365,7 @@ const controlRenderer = defineComponent({ ? this.appliedOptions.clearLabel : 'Clear'; - return this.t(label, label); + return this.t(label, label) as string; }, cancelLabel(): string { const label = @@ -373,14 +373,14 @@ const controlRenderer = defineComponent({ ? this.appliedOptions.cancelLabel : 'Cancel'; - return this.t(label, label); + return this.t(label, label) as string; }, okLabel(): string { const label = typeof this.appliedOptions.okLabel == 'string' ? this.appliedOptions.okLabel : 'OK'; - return this.t(label, label); + return this.t(label, label) as string; }, showActions(): boolean { return this.appliedOptions.showActions === true; diff --git a/packages/vue-vuetify/tests/unit/additional/ListWithDetailRenderer.spec.ts b/packages/vue-vuetify/tests/unit/additional/ListWithDetailRenderer.spec.ts index 190b5ff49b..52dc80cab9 100644 --- a/packages/vue-vuetify/tests/unit/additional/ListWithDetailRenderer.spec.ts +++ b/packages/vue-vuetify/tests/unit/additional/ListWithDetailRenderer.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { clearAllIds, type Translator } from '@jsonforms/core'; +import { clearAllIds, createTranslator } from '@jsonforms/core'; import ListWithDetailRenderer from '../../../src/additional/ListWithDetailRenderer.vue'; import { entry as listWithDetailRendererEntry } from '../../../src/additional/ListWithDetailRenderer.entry'; import { mountJsonForms } from '../util'; @@ -30,12 +30,12 @@ describe('ListWithDetailRenderer.vue', () => { // clear all ids to guarantee that the snapshots will always be generated with the same ids clearAllIds(); wrapper = mountJsonForms(data, schema, renderers, uischema, undefined, { - translate: ((id, defaultMessage) => { + translate: createTranslator((id, defaultMessage) => { if (id.endsWith('addAriaLabel')) { return 'MyAdd'; } return defaultMessage; - }) as Translator, + }), }); }); diff --git a/packages/vue-vuetify/tests/unit/complex/ArrayControlRenderer.spec.ts b/packages/vue-vuetify/tests/unit/complex/ArrayControlRenderer.spec.ts index 0422ca64fd..d5ce02ebeb 100644 --- a/packages/vue-vuetify/tests/unit/complex/ArrayControlRenderer.spec.ts +++ b/packages/vue-vuetify/tests/unit/complex/ArrayControlRenderer.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { clearAllIds, type Translator } from '@jsonforms/core'; +import { clearAllIds, createTranslator } from '@jsonforms/core'; import ArrayControlRenderer from '../../../src/complex/ArrayControlRenderer.vue'; import { entry as arrayControlRendererEntry } from '../../../src/complex/ArrayControlRenderer.entry'; import { mountJsonForms } from '../util'; @@ -29,12 +29,12 @@ describe('ArrayControlRenderer.vue', () => { // clear all ids to guarantee that the snapshots will always be generated with the same ids clearAllIds(); wrapper = mountJsonForms(data, schema, renderers, uischema, undefined, { - translate: ((id, defaultMessage) => { + translate: createTranslator((id, defaultMessage) => { if (id.endsWith('addAriaLabel')) { return 'MyAdd'; } return defaultMessage; - }) as Translator, + }), }); }); diff --git a/packages/vue-vuetify/tests/unit/complex/OneOfRenderer.spec.ts b/packages/vue-vuetify/tests/unit/complex/OneOfRenderer.spec.ts index 30027fdb25..ab49545c0c 100644 --- a/packages/vue-vuetify/tests/unit/complex/OneOfRenderer.spec.ts +++ b/packages/vue-vuetify/tests/unit/complex/OneOfRenderer.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { clearAllIds, type Translator } from '@jsonforms/core'; +import { clearAllIds, createTranslator } from '@jsonforms/core'; import OneOfControlRenderer from '../../../src/complex/OneOfRenderer.vue'; import { entry as oneOfControlRendererEntry } from '../../../src/complex/OneOfRenderer.entry'; import { mountJsonForms } from '../util'; @@ -40,12 +40,12 @@ describe('OneOfRenderer.vue', () => { // clear all ids to guarantee that the snapshots will always be generated with the same ids clearAllIds(); wrapper = mountJsonForms(data, schema, renderers, uischema, undefined, { - translate: ((id, defaultMessage) => { + translate: createTranslator((id, defaultMessage) => { if (id.endsWith('clearDialogAccept')) { return 'Do the clear!'; } return defaultMessage; - }) as Translator, + }), }); }); diff --git a/packages/vue-vuetify/tests/unit/layout/ArrayLayoutRenderer.spec.ts b/packages/vue-vuetify/tests/unit/layout/ArrayLayoutRenderer.spec.ts index ba8a591c5a..d573121b57 100644 --- a/packages/vue-vuetify/tests/unit/layout/ArrayLayoutRenderer.spec.ts +++ b/packages/vue-vuetify/tests/unit/layout/ArrayLayoutRenderer.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { clearAllIds, type Translator } from '@jsonforms/core'; +import { clearAllIds, createTranslator } from '@jsonforms/core'; import ArrayLayoutRenderer from '../../../src/layouts/ArrayLayoutRenderer.vue'; import { entry as arrayLayoutRendererEntry } from '../../../src/layouts/ArrayLayoutRenderer.entry'; import { mountJsonForms } from '../util'; @@ -30,12 +30,12 @@ describe('ArrayLayoutRenderer.vue', () => { // clear all ids to guarantee that the snapshots will always be generated with the same ids clearAllIds(); wrapper = mountJsonForms(data, schema, renderers, uischema, undefined, { - translate: ((id, defaultMessage) => { + translate: createTranslator((id, defaultMessage) => { if (id.endsWith('addAriaLabel')) { return 'MyAdd'; } return defaultMessage; - }) as Translator, + }), }); });