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/4] 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 42cf1d9ef35a2fc3143a765d9f559b20e35def49 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/4] angular-material: Translate enum for AutocompleteControlRenderer --- .../library/controls/autocomplete.renderer.ts | 116 ++++++++++++++---- .../test/autocomplete-control.spec.ts | 64 +++++++++- 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..becf83446 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,13 +72,13 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete'; autoActiveFirstOption #auto="matAutocomplete" (optionSelected)="onSelect($event)" + [displayWith]="displayFn" > - - {{ option }} + @for (option of filteredOptions | async; track option.value) { + + {{ option.label }} + } {{ description @@ -105,16 +110,40 @@ export class AutocompleteControlRenderer extends JsonFormsControl implements OnInit { - @Input() options: string[]; - filteredOptions: Observable; + @Input() options?: EnumOption[] | string[]; + valuesToTranslatedOptions?: Map; + filteredOptions: Observable; shouldFilter: boolean; focused = false; constructor(jsonformsService: JsonFormsAngularService) { super(jsonformsService); } + + protected override mapToProps( + state: JsonFormsState + ): StatePropsOfControl & OwnPropsOfEnum { + return mapStateToEnumControlProps(state, this.getOwnProps()); + } + getEventValue = (event: any) => event.target.value; + override onChange(ev: any) { + const eventValue = this.getEventValue(ev); + const option = Array.from(this.valuesToTranslatedOptions?.values() ?? []).find( + (option) => option.label === eventValue + ); + if (!option) { + super.onChange(ev); + return; + } + + this.jsonFormsService.updateCore( + Actions.update(this.propsPath, () => option.value) + ); + this.triggerValidation(); + } + ngOnInit() { super.ngOnInit(); this.shouldFilter = false; @@ -124,6 +153,12 @@ export class AutocompleteControlRenderer ); } + override mapAdditionalProps(_props: StatePropsOfControl & OwnPropsOfEnum) { + this.valuesToTranslatedOptions = new Map( + (_props.options ?? []).map((option) => [option.value, option]) + ); + } + updateFilter(event: any) { // ENTER if (event.keyCode === 13) { @@ -136,30 +171,67 @@ 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( - (option) => - !this.shouldFilter || - !val || - option.toLowerCase().indexOf(val.toLowerCase()) === 0 + // use arrow function to bind "this" reference + displayFn = (option?: string | EnumOption): string => { + if (!option) { + return ''; + } + + if (typeof option === 'string') { + if (!this.valuesToTranslatedOptions) { + return option; // show raw value until translations are ready + } + + // if no option matches, it is a manual input + return this.valuesToTranslatedOptions.get(option)?.label ?? option; + } + + return option?.label ?? ''; + }; + + filter(val: string | EnumOption | undefined): EnumOption[] { + const options = Array.from(this.valuesToTranslatedOptions?.values() || []); + + if (!val || !this.shouldFilter) { + return options; + } + + const label = typeof val === 'string' ? val : val.label; + return options.filter((option) => + option.label.toLowerCase().startsWith(label.toLowerCase()) ); } - 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..fe685bf15 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'; @@ -252,7 +258,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 +286,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 1180fffb452bf97229d05a4545aa16cf2c5b5622 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/4] 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, From 49a47a72c2ce3d50fa87e01366f8e32b3380ae6a Mon Sep 17 00:00:00 2001 From: Lucas Koehler Date: Thu, 26 Feb 2026 11:27:50 +0100 Subject: [PATCH 4/4] fix lint error --- .../src/library/controls/autocomplete.renderer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/angular-material/src/library/controls/autocomplete.renderer.ts b/packages/angular-material/src/library/controls/autocomplete.renderer.ts index becf83446..32e19437e 100644 --- a/packages/angular-material/src/library/controls/autocomplete.renderer.ts +++ b/packages/angular-material/src/library/controls/autocomplete.renderer.ts @@ -130,9 +130,9 @@ export class AutocompleteControlRenderer override onChange(ev: any) { const eventValue = this.getEventValue(ev); - const option = Array.from(this.valuesToTranslatedOptions?.values() ?? []).find( - (option) => option.label === eventValue - ); + const option = Array.from( + this.valuesToTranslatedOptions?.values() ?? [] + ).find((option) => option.label === eventValue); if (!option) { super.onChange(ev); return;