From e9bc501998f7bf4f508668427b19d57428e3cb46 Mon Sep 17 00:00:00 2001 From: Daniel Shuy <17351764+daniel-shuy@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:03:41 +0800 Subject: [PATCH 1/3] angular-material: Assert input value in AutocompleteControlRenderer input event tests --- packages/angular-material/test/autocomplete-control.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/angular-material/test/autocomplete-control.spec.ts b/packages/angular-material/test/autocomplete-control.spec.ts index 53a0bd645..c36f4c931 100644 --- a/packages/angular-material/test/autocomplete-control.spec.ts +++ b/packages/angular-material/test/autocomplete-control.spec.ts @@ -213,6 +213,7 @@ describe('AutoComplete control Input Event Tests', () => { let fixture: ComponentFixture; let component: AutocompleteControlRenderer; let loader: HarnessLoader; + let inputElement: HTMLInputElement; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [componentUT, ...imports], @@ -223,6 +224,8 @@ describe('AutoComplete control Input Event Tests', () => { fixture = TestBed.createComponent(componentUT); component = fixture.componentInstance; loader = TestbedHarnessEnvironment.loader(fixture); + + inputElement = fixture.debugElement.query(By.css('input')).nativeElement; })); it('should update via input event', fakeAsync(async () => { @@ -250,6 +253,7 @@ describe('AutoComplete control Input Event Tests', () => { .args[0] as MatAutocompleteSelectedEvent; expect(event.option.value).toBe('B'); + expect(inputElement.value).toBe('B'); })); it('options should prefer own props', fakeAsync(async () => { setupMockStore(fixture, { uischema, schema, data }); @@ -274,6 +278,7 @@ describe('AutoComplete control Input Event Tests', () => { const event = spy.calls.mostRecent() .args[0] as MatAutocompleteSelectedEvent; expect(event.option.value).toBe('Y'); + expect(inputElement.value).toBe('Y'); })); }); describe('AutoComplete control Error Tests', () => { From a447afce82cf777d31c6232784ecaa3a3033023c Mon Sep 17 00:00:00 2001 From: Daniel Shuy <17351764+daniel-shuy@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:59:05 +0800 Subject: [PATCH 2/3] angular-material: Translate enum for AutocompleteControlRenderer --- .../library/controls/autocomplete.renderer.ts | 67 ++++++++--- .../test/autocomplete-control.spec.ts | 113 ++++++++++++++++-- 2 files changed, 155 insertions(+), 25 deletions(-) diff --git a/packages/angular-material/src/library/controls/autocomplete.renderer.ts b/packages/angular-material/src/library/controls/autocomplete.renderer.ts index 69e308c32..e7ec957f9 100644 --- a/packages/angular-material/src/library/controls/autocomplete.renderer.ts +++ b/packages/angular-material/src/library/controls/autocomplete.renderer.ts @@ -34,10 +34,15 @@ import { Actions, composeWithUi, ControlElement, + EnumOption, isEnumControl, + JsonFormsState, + mapStateToEnumControlProps, OwnPropsOfControl, + OwnPropsOfEnum, RankedTester, rankWith, + StatePropsOfControl, } from '@jsonforms/core'; import type { Observable } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; @@ -67,12 +72,13 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete'; autoActiveFirstOption #auto="matAutocomplete" (optionSelected)="onSelect($event)" + [displayWith]="displayFn" > - {{ option }} + {{ option.label }} {{ @@ -105,14 +111,22 @@ export class AutocompleteControlRenderer extends JsonFormsControl implements OnInit { - @Input() options: string[]; - filteredOptions: Observable; + @Input() options?: EnumOption[] | string[]; + translatedOptions?: EnumOption[]; + filteredOptions: Observable; shouldFilter: boolean; focused = false; constructor(jsonformsService: JsonFormsAngularService) { super(jsonformsService); } + + protected mapToProps( + state: JsonFormsState + ): StatePropsOfControl & OwnPropsOfEnum { + return mapStateToEnumControlProps(state, this.getOwnProps()); + } + getEventValue = (event: any) => event.target.value; ngOnInit() { @@ -124,6 +138,10 @@ export class AutocompleteControlRenderer ); } + mapAdditionalProps(_props: StatePropsOfControl & OwnPropsOfEnum) { + this.translatedOptions = _props.options; + } + updateFilter(event: any) { // ENTER if (event.keyCode === 13) { @@ -136,30 +154,49 @@ export class AutocompleteControlRenderer onSelect(ev: MatAutocompleteSelectedEvent) { const path = composeWithUi(this.uischema as ControlElement, this.path); this.shouldFilter = false; - this.jsonFormsService.updateCore( - Actions.update(path, () => ev.option.value) - ); + const option: EnumOption = ev.option.value; + this.jsonFormsService.updateCore(Actions.update(path, () => option.value)); this.triggerValidation(); } - filter(val: string): string[] { - return (this.options || this.scopedSchema.enum || []).filter( + displayFn(option?: EnumOption): string { + return option?.label ?? ''; + } + + filter(val: string): EnumOption[] { + return (this.translatedOptions || []).filter( (option) => !this.shouldFilter || !val || - option.toLowerCase().indexOf(val.toLowerCase()) === 0 + option.label.toLowerCase().indexOf(val.toLowerCase()) === 0 ); } - protected getOwnProps(): OwnPropsOfAutoComplete { + protected getOwnProps(): OwnPropsOfControl & OwnPropsOfEnum { return { ...super.getOwnProps(), - options: this.options, + options: this.stringOptionsToEnumOptions(this.options), }; } -} -export const enumControlTester: RankedTester = rankWith(2, isEnumControl); + /** + * For {@link options} input backwards compatibility + */ + protected stringOptionsToEnumOptions( + options: typeof this.options + ): EnumOption[] | undefined { + if (!options) { + return undefined; + } -interface OwnPropsOfAutoComplete extends OwnPropsOfControl { - options: string[]; + return options.every((item) => typeof item === 'string') + ? options.map((str) => { + return { + label: str, + value: str, + } satisfies EnumOption; + }) + : options; + } } + +export const enumControlTester: RankedTester = rankWith(2, isEnumControl); diff --git a/packages/angular-material/test/autocomplete-control.spec.ts b/packages/angular-material/test/autocomplete-control.spec.ts index c36f4c931..8c25e362e 100644 --- a/packages/angular-material/test/autocomplete-control.spec.ts +++ b/packages/angular-material/test/autocomplete-control.spec.ts @@ -44,7 +44,13 @@ import { setupMockStore, getJsonFormsService, } from './common'; -import { ControlElement, JsonSchema, Actions } from '@jsonforms/core'; +import { + ControlElement, + JsonSchema, + Actions, + JsonFormsCore, + EnumOption, +} from '@jsonforms/core'; import { AutocompleteControlRenderer } from '../src'; import { JsonFormsAngularService } from '@jsonforms/angular'; import { ErrorObject } from 'ajv'; @@ -52,7 +58,12 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing'; -const data = { foo: 'A' }; +const data = { + foo: { + label: 'A', + value: 'a', + }, +}; const schema: JsonSchema = { type: 'object', properties: { @@ -107,7 +118,10 @@ describe('Autocomplete control Base Tests', () => { component.ngOnInit(); fixture.detectChanges(); tick(); - expect(component.data).toBe('A'); + expect(component.data).toEqual({ + label: 'A', + value: 'a', + }); expect(inputElement.value).toBe('A'); expect(inputElement.disabled).toBe(false); })); @@ -120,10 +134,20 @@ describe('Autocomplete control Base Tests', () => { component.ngOnInit(); fixture.detectChanges(); tick(); - getJsonFormsService(component).updateCore(Actions.update('foo', () => 'B')); + getJsonFormsService(component).updateCore( + Actions.update('foo', () => { + return { + label: 'B', + value: 'b', + } satisfies EnumOption; + }) + ); tick(); fixture.detectChanges(); - expect(component.data).toBe('B'); + expect(component.data).toEqual({ + label: 'B', + value: 'b', + } satisfies EnumOption); expect(inputElement.value).toBe('B'); })); @@ -165,11 +189,28 @@ describe('Autocomplete control Base Tests', () => { component.ngOnInit(); fixture.detectChanges(); tick(); - getJsonFormsService(component).updateCore(Actions.update('foo', () => 'A')); - getJsonFormsService(component).updateCore(Actions.update('bar', () => 'B')); + getJsonFormsService(component).updateCore( + Actions.update('foo', () => { + return { + label: 'A', + value: 'a', + } satisfies EnumOption; + }) + ); + getJsonFormsService(component).updateCore( + Actions.update('bar', () => { + return { + label: 'B', + value: 'b', + } satisfies EnumOption; + }) + ); fixture.detectChanges(); tick(); - expect(component.data).toBe('A'); + expect(component.data).toEqual({ + label: 'A', + value: 'a', + } satisfies EnumOption); expect(inputElement.value).toBe('A'); })); // store needed as we evaluate the calculated enabled value to disable/enable the control @@ -252,7 +293,10 @@ describe('AutoComplete control Input Event Tests', () => { const event = spy.calls.mostRecent() .args[0] as MatAutocompleteSelectedEvent; - expect(event.option.value).toBe('B'); + expect(event.option.value).toEqual({ + label: 'B', + value: 'B', + } satisfies EnumOption); expect(inputElement.value).toBe('B'); })); it('options should prefer own props', fakeAsync(async () => { @@ -277,9 +321,58 @@ describe('AutoComplete control Input Event Tests', () => { const event = spy.calls.mostRecent() .args[0] as MatAutocompleteSelectedEvent; - expect(event.option.value).toBe('Y'); + expect(event.option.value).toEqual({ + label: 'Y', + value: 'Y', + } satisfies EnumOption); expect(inputElement.value).toBe('Y'); })); + it('should render translated enum correctly', fakeAsync(async () => { + setupMockStore(fixture, { uischema, schema, data }); + const state: JsonFormsCore = { + data, + schema, + uischema, + }; + getJsonFormsService(component).init({ + core: state, + i18n: { + translate: (key, defaultMessage) => { + const translations: { [key: string]: string } = { + 'foo.A': 'Translated A', + 'foo.B': 'Translated B', + 'foo.C': 'Translated C', + }; + return translations[key] ?? defaultMessage; + }, + }, + }); + getJsonFormsService(component).updateCore( + Actions.init(data, schema, uischema) + ); + component.ngOnInit(); + fixture.detectChanges(); + const spy = spyOn(component, 'onSelect'); + + await (await loader.getHarness(MatAutocompleteHarness)).focus(); + fixture.detectChanges(); + + await ( + await loader.getHarness(MatAutocompleteHarness) + ).selectOption({ + text: 'Translated B', + }); + fixture.detectChanges(); + tick(); + + const event = spy.calls.mostRecent() + .args[0] as MatAutocompleteSelectedEvent; + expect(event.option.value).toEqual({ + label: 'Translated B', + value: 'B', + } satisfies EnumOption); + expect(inputElement.value).toBe('Translated B'); + })); }); describe('AutoComplete control Error Tests', () => { let fixture: ComponentFixture; From 53021530dd9256371ebcb5b84bad112aa9221479 Mon Sep 17 00:00:00 2001 From: Daniel Shuy <17351764+daniel-shuy@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:54:25 +0800 Subject: [PATCH 3/3] examples: Add enumI18n example --- packages/examples/src/examples/enumI18n.ts | 138 +++++++++++++++++++++ packages/examples/src/index.ts | 2 + 2 files changed, 140 insertions(+) create mode 100644 packages/examples/src/examples/enumI18n.ts diff --git a/packages/examples/src/examples/enumI18n.ts b/packages/examples/src/examples/enumI18n.ts new file mode 100644 index 000000000..fdfdf733a --- /dev/null +++ b/packages/examples/src/examples/enumI18n.ts @@ -0,0 +1,138 @@ +/* + The MIT License + + Copyright (c) 2017-2019 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 { registerExamples } from '../register'; +import { Translator } from '@jsonforms/core'; +import get from 'lodash/get'; + +export const schema = { + type: 'object', + properties: { + country: { + type: 'string', + enum: ['DE', 'IT', 'JP', 'US', 'RU', 'Other'], + }, + countryNoAutocomplete: { + type: 'string', + enum: ['DE', 'IT', 'JP', 'US', 'RU', 'Other'], + }, + status: { + type: 'string', + oneOf: [ + { const: 'pending', title: 'Pending' }, + { const: 'approved', title: 'Approved' }, + { const: 'rejected', title: 'Rejected' }, + ], + }, + }, +}; + +export const uischema = { + type: 'VerticalLayout', + elements: [ + { + type: 'Group', + label: 'Enum with i18n (Autocomplete)', + elements: [ + { + type: 'Control', + scope: '#/properties/country', + label: 'Country (with autocomplete)', + }, + ], + }, + { + type: 'Group', + label: 'Enum with i18n (Dropdown)', + elements: [ + { + type: 'Control', + scope: '#/properties/countryNoAutocomplete', + label: 'Country (dropdown)', + options: { + autocomplete: false, + }, + }, + ], + }, + { + type: 'Group', + label: 'OneOf Enum with i18n', + elements: [ + { + type: 'Control', + scope: '#/properties/status', + label: 'Status', + }, + ], + }, + ], +}; + +export const data = { + country: 'DE', +}; + +export const translations: Record = { + // Translations for country enum values + // Key format: . + 'country.DE': 'Germany', + 'country.IT': 'Italy', + 'country.JP': 'Japan', + 'country.US': 'United States', + 'country.RU': 'Russia', + 'country.Other': 'Other Country', + // Same translations for the non-autocomplete version + 'countryNoAutocomplete.DE': 'Germany', + 'countryNoAutocomplete.IT': 'Italy', + 'countryNoAutocomplete.JP': 'Japan', + 'countryNoAutocomplete.US': 'United States', + 'countryNoAutocomplete.RU': 'Russia', + 'countryNoAutocomplete.Other': 'Other Country', + // Translations for status oneOf enum + 'status.pending': 'Awaiting Review', + 'status.approved': 'Approved', + 'status.rejected': 'Declined', +}; + +export const translate: Translator = ( + key: string, + defaultMessage: string | undefined +) => { + return get(translations, key) ?? defaultMessage; +}; + +registerExamples([ + { + name: 'enum-i18n', + label: 'Enums (i18n)', + data, + schema, + uischema, + i18n: { + translate: translate, + locale: 'en', + }, + }, +]); diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index e200dc8e3..34adc0330 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -67,6 +67,7 @@ import * as onChange from './examples/onChange'; import * as enumExample from './examples/enum'; import * as radioGroupExample from './examples/radioGroup'; import * as multiEnum from './examples/enum-multi'; +import * as enumI18n from './examples/enumI18n'; import * as enumInArray from './examples/enumInArray'; import * as readonly from './examples/readonly'; import * as listWithDetailPrimitives from './examples/list-with-detail-primitives'; @@ -134,6 +135,7 @@ export { radioGroupExample, multiEnum, multiEnumWithLabelAndDesc, + enumI18n, enumInArray, readonly, listWithDetailPrimitives,