From 990ee7edc6238e9f6561573b07bb45cc53c1b0a2 Mon Sep 17 00:00:00 2001 From: Lukas Matta Date: Fri, 15 May 2026 09:32:25 +0200 Subject: [PATCH 1/3] Support funcs for optionLabel and optionInfo --- .../cps-autocomplete.component.html | 10 ++--- .../cps-autocomplete.component.ts | 43 ++++++++++++------- .../cps-select/cps-select.component.html | 8 ++-- .../cps-select/cps-select.component.ts | 32 +++++++++----- .../cps-base-tree-dropdown.component.ts | 17 ++++---- .../internal/check-option-selected.pipe.ts | 7 +-- .../lib/pipes/internal/combine-labels.pipe.ts | 13 +++--- .../lib/pipes/internal/label-by-value.pipe.ts | 11 +++-- .../src/lib/utils/internal/option-utils.ts | 6 +++ projects/cps-ui-kit/src/public-api.ts | 1 + 10 files changed, 93 insertions(+), 55 deletions(-) create mode 100644 projects/cps-ui-kit/src/lib/utils/internal/option-utils.ts diff --git a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.html b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.html index b60bb0d7..48a51ffd 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.html @@ -55,7 +55,7 @@
{{ returnObject - ? value[optionLabel] + ? getProp(value, optionLabel) : (value | labelByValue: options : optionValue : optionLabel) }} @@ -87,7 +87,7 @@ }"> {{ returnObject - ? val[optionLabel] + ? getProp(val, optionLabel) : (val | labelByValue : options @@ -125,7 +125,7 @@ }" [label]=" returnObject - ? val[optionLabel] + ? getProp(val, optionLabel) : (val | labelByValue: options : optionValue : optionLabel) "> @@ -371,14 +371,14 @@ data-testid="cps-autocomplete-options" class="cps-autocomplete-options-option-label" [class.virtual-row]="virtualScroll" - >{{ item[optionLabel] }}{{ getProp(item, optionLabel) }} {{ item[optionInfo] }}{{ getProp(item, optionInfo) }}
diff --git a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts index 8e7414be..ee2d2291 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts @@ -41,6 +41,7 @@ import { LabelByValuePipe } from '../../pipes/internal/label-by-value.pipe'; import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected.pipe'; import { isEqual } from 'lodash-es'; import { CpsTooltipPosition } from '../../directives/cps-tooltip/cps-tooltip.directive'; +import { getOptionProp, OptionKey } from '../../utils/internal/option-utils'; import { CpsMenuComponent, CpsMenuHideReason @@ -194,22 +195,22 @@ export class CpsAutocompleteComponent @Input() keepInitialOrder = false; /** - * Name of the label field of an option. + * Name of the label field of an option, or a function that receives the option and returns the label. * @group Props */ - @Input() optionLabel = 'label'; + @Input() optionLabel: OptionKey = 'label'; /** - * Name of the value field of an option. Needed only if returnObject prop is false. + * Name of the value field of an option, or a function that receives the option and returns the value. Needed only if returnObject prop is false. * @group Props */ - @Input() optionValue = 'value'; + @Input() optionValue: OptionKey = 'value'; /** - * Name of the info field of an option, shows the additional information text. + * Name of the info field of an option, or a function that receives the option and returns the info text. * @group Props */ - @Input() optionInfo = 'info'; + @Input() optionInfo: OptionKey = 'info'; /** * Hides hint and validation errors. @@ -544,6 +545,10 @@ export class CpsAutocompleteComponent this._destroy$.complete(); } + getProp(option: any, key: OptionKey): any { + return getOptionProp(option, key); + } + select( option: any, byValue: boolean, @@ -559,7 +564,7 @@ export class CpsAutocompleteComponent ? option : this.returnObject ? option - : option[this.optionValue]; + : getOptionProp(option, this.optionValue); if (this.multiple) { let res = []; if (includes(this.value, val)) { @@ -567,7 +572,9 @@ export class CpsAutocompleteComponent } else { if (this.keepInitialOrder) { this.options.forEach((o) => { - const ov = this.returnObject ? o : o[this.optionValue]; + const ov = this.returnObject + ? o + : getOptionProp(o, this.optionValue); if ( this.value.some((v: any) => isEqual(v, ov)) || isEqual(val, ov) @@ -577,12 +584,15 @@ export class CpsAutocompleteComponent }); } else { const opt = this.options.find((o) => { - return isEqual(val, this.returnObject ? o : o[this.optionValue]); + return isEqual( + val, + this.returnObject ? o : getOptionProp(o, this.optionValue) + ); }); if (opt) { res = [ ...this.value, - this.returnObject ? opt : opt[this.optionValue] + this.returnObject ? opt : getOptionProp(opt, this.optionValue) ]; } } @@ -613,7 +623,7 @@ export class CpsAutocompleteComponent res = this.options; } else { this.options.forEach((o) => { - res.push(o[this.optionValue]); + res.push(getOptionProp(o, this.optionValue)); }); } } @@ -649,7 +659,9 @@ export class CpsAutocompleteComponent this.backspaceClickedOnce = false; let _filteredOptions = this.options.filter((o: any) => { - let res = o[this.optionLabel].toLowerCase().includes(searchVal); + let res = (getOptionProp(o, this.optionLabel) || '') + .toLowerCase() + .includes(searchVal); if ( !res && this.withOptionsAliases && @@ -921,7 +933,7 @@ export class CpsAutocompleteComponent ? undefined : this.returnObject ? option - : option[this.optionValue]; + : getOptionProp(option, this.optionValue); } private _toggleOptions(show?: boolean): void { @@ -1013,7 +1025,7 @@ export class CpsAutocompleteComponent private _getValueLabel() { return !this.isEmptyValue() ? this.returnObject - ? this.value[this.optionLabel] + ? getOptionProp(this.value, this.optionLabel) : this._labelByValue.transform( this.value, this.options, @@ -1162,7 +1174,8 @@ export class CpsAutocompleteComponent } const found = this.filteredOptions.find( - (o: any) => o[this.optionLabel].toLowerCase() === searchVal + (o: any) => + (getOptionProp(o, this.optionLabel) || '').toLowerCase() === searchVal ); if (found) { this.select(found, false, true, needFocusInput); diff --git a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.html b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.html index e2294228..a4f01af9 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.html @@ -54,7 +54,7 @@ {{ returnObject - ? value[optionLabel] + ? getProp(value, optionLabel) : (value | labelByValue: options : optionValue : optionLabel) }} @@ -212,14 +212,14 @@ {{ item[optionLabel] }}{{ getProp(item, optionLabel) }} - {{ item[optionInfo] }} + {{ getProp(item, optionInfo) }} @if (item[optionIcon]) { isEqual(item, val)) || false; @@ -427,7 +432,7 @@ export class CpsSelectComponent ? option : this.returnObject ? option - : option[this.optionValue]; + : getOptionProp(option, this.optionValue); if (this.multiple) { let res = []; if (includes(this.value, val)) { @@ -435,7 +440,9 @@ export class CpsSelectComponent } else { if (this.keepInitialOrder) { this.options.forEach((o) => { - const ov = this.returnObject ? o : o[this.optionValue]; + const ov = this.returnObject + ? o + : getOptionProp(o, this.optionValue); if ( this.value.some((v: any) => isEqual(v, ov)) || isEqual(val, ov) @@ -445,12 +452,15 @@ export class CpsSelectComponent }); } else { const opt = this.options.find((o) => { - return isEqual(val, this.returnObject ? o : o[this.optionValue]); + return isEqual( + val, + this.returnObject ? o : getOptionProp(o, this.optionValue) + ); }); if (opt) { res = [ ...this.value, - this.returnObject ? opt : opt[this.optionValue] + this.returnObject ? opt : getOptionProp(opt, this.optionValue) ]; } } @@ -578,7 +588,7 @@ export class CpsSelectComponent res = this.options; } else { this.options.forEach((o) => { - res.push(o[this.optionValue]); + res.push(getOptionProp(o, this.optionValue)); }); } } diff --git a/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts b/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts index 8400fbb4..40f3d713 100644 --- a/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts +++ b/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts @@ -20,6 +20,7 @@ import { Subscription } from 'rxjs'; import { Tree } from 'primeng/tree'; import { isEqual } from 'lodash-es'; import { IconType, iconSizeType } from '../../cps-icon/cps-icon.component'; +import { getOptionProp, OptionKey } from '../../../utils/internal/option-utils'; import { convertSize } from '../../../utils/internal/size-utils'; import { CpsTooltipPosition } from '../../../directives/cps-tooltip/cps-tooltip.directive'; import { CpsMenuComponent } from '../../cps-menu/cps-menu.component'; @@ -90,16 +91,16 @@ export class CpsBaseTreeDropdownComponent @Input() openOnClear = true; /** - * Name of the label field of an option. + * Name of the label field of an option, or a function that receives the option and returns the label. * @group Props */ - @Input() optionLabel = 'label'; + @Input() optionLabel: OptionKey = 'label'; /** - * Name of the info field of an option, shows the additional information text. + * Name of the info field of an option, or a function that receives the option and returns the info text. * @group Props */ - @Input() optionInfo = 'info'; + @Input() optionInfo: OptionKey = 'info'; /** * Options for hiding details. @@ -649,15 +650,15 @@ export class CpsBaseTreeDropdownComponent private _toInnerOptions(_options: any[]): TreeNode[] { const mapOption = ( o: any, - optionLabel: string, - optionInfo: string, + optionLabel: OptionKey, + optionInfo: OptionKey, key: string, originalOptionsMap: any ) => { const inner = { inner: true, - label: o[optionLabel], - info: o[optionInfo], + label: getOptionProp(o, optionLabel), + info: getOptionProp(o, optionInfo), key, styleClass: 'key-' + key } as TreeNode; diff --git a/projects/cps-ui-kit/src/lib/pipes/internal/check-option-selected.pipe.ts b/projects/cps-ui-kit/src/lib/pipes/internal/check-option-selected.pipe.ts index 4d2c17d8..c66451e3 100644 --- a/projects/cps-ui-kit/src/lib/pipes/internal/check-option-selected.pipe.ts +++ b/projects/cps-ui-kit/src/lib/pipes/internal/check-option-selected.pipe.ts @@ -1,5 +1,6 @@ import { Pipe, PipeTransform } from '@angular/core'; import { isEqual } from 'lodash-es'; +import { getOptionProp, OptionKey } from '../../utils/internal/option-utils'; @Pipe({ standalone: true, name: 'checkOptionSelected' }) export class CheckOptionSelectedPipe implements PipeTransform { @@ -8,7 +9,7 @@ export class CheckOptionSelectedPipe implements PipeTransform { value: any, multiple: boolean, returnObject: boolean, - optionValue: string + optionValue: OptionKey ): boolean { function includes(array: any[], val: any): boolean { return array?.some((item) => isEqual(item, val)) || false; @@ -17,9 +18,9 @@ export class CheckOptionSelectedPipe implements PipeTransform { return multiple ? returnObject ? includes(value, option) - : includes(value, option[optionValue]) + : includes(value, getOptionProp(option, optionValue)) : returnObject ? isEqual(option, value) - : isEqual(option[optionValue], value); + : isEqual(getOptionProp(option, optionValue), value); } } diff --git a/projects/cps-ui-kit/src/lib/pipes/internal/combine-labels.pipe.ts b/projects/cps-ui-kit/src/lib/pipes/internal/combine-labels.pipe.ts index 7f3f66a3..f570ac3a 100644 --- a/projects/cps-ui-kit/src/lib/pipes/internal/combine-labels.pipe.ts +++ b/projects/cps-ui-kit/src/lib/pipes/internal/combine-labels.pipe.ts @@ -1,22 +1,25 @@ import { Pipe, PipeTransform } from '@angular/core'; import { isEqual } from 'lodash-es'; +import { getOptionProp, OptionKey } from '../../utils/internal/option-utils'; @Pipe({ standalone: true, name: 'combineLabels' }) export class CombineLabelsPipe implements PipeTransform { transform( values: any[], options: any[], - valueKey: string, - labelKey: string, + valueKey: OptionKey, + labelKey: OptionKey, returnObject: boolean ): string { return values .map((v) => { if (returnObject) { - return v[labelKey]; + return getOptionProp(v, labelKey); } else { - const option = options.find((opt) => isEqual(opt[valueKey], v)); - return option ? option[labelKey] : ''; + const option = options.find((opt) => + isEqual(getOptionProp(opt, valueKey), v) + ); + return option ? getOptionProp(option, labelKey) : ''; } }) .join(', '); diff --git a/projects/cps-ui-kit/src/lib/pipes/internal/label-by-value.pipe.ts b/projects/cps-ui-kit/src/lib/pipes/internal/label-by-value.pipe.ts index a4201005..da1d468a 100644 --- a/projects/cps-ui-kit/src/lib/pipes/internal/label-by-value.pipe.ts +++ b/projects/cps-ui-kit/src/lib/pipes/internal/label-by-value.pipe.ts @@ -1,15 +1,18 @@ import { Pipe, PipeTransform } from '@angular/core'; import { isEqual } from 'lodash-es'; +import { getOptionProp, OptionKey } from '../../utils/internal/option-utils'; @Pipe({ standalone: true, name: 'labelByValue' }) export class LabelByValuePipe implements PipeTransform { transform( value: any, options: any[], - valueKey: string, - labelKey: string + valueKey: OptionKey, + labelKey: OptionKey ): string { - const option = options.find((opt) => isEqual(opt[valueKey], value)); - return option ? option[labelKey] : ''; + const option = options.find((opt) => + isEqual(getOptionProp(opt, valueKey), value) + ); + return option ? getOptionProp(option, labelKey) : ''; } } diff --git a/projects/cps-ui-kit/src/lib/utils/internal/option-utils.ts b/projects/cps-ui-kit/src/lib/utils/internal/option-utils.ts new file mode 100644 index 00000000..1dea7772 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/utils/internal/option-utils.ts @@ -0,0 +1,6 @@ +export type OptionKey = string | ((option: any) => any); + +export function getOptionProp(option: any, key: OptionKey): any { + if (typeof key === 'function') return key(option); + return option?.[key]; +} diff --git a/projects/cps-ui-kit/src/public-api.ts b/projects/cps-ui-kit/src/public-api.ts index 797cb1af..6c7b05f3 100644 --- a/projects/cps-ui-kit/src/public-api.ts +++ b/projects/cps-ui-kit/src/public-api.ts @@ -65,3 +65,4 @@ export * from './lib/services/cps-theme/cps-theme.service'; export * from './lib/services/cps-cron-validation/cps-cron-validation.service'; export * from './lib/utils/colors-utils'; +export { OptionKey } from './lib/utils/internal/option-utils'; From 00b0fc1a2203ec4271788fb706d4de829fd9fd44 Mon Sep 17 00:00:00 2001 From: Lukas Matta Date: Fri, 15 May 2026 09:39:11 +0200 Subject: [PATCH 2/3] Add examples --- .../autocomplete-page.component.html | 15 +++++++++++++++ .../autocomplete-page.component.ts | 2 ++ .../autocomplete-page.examples.ts | 17 +++++++++++++++++ .../select-page/select-page.component.html | 8 ++++++++ .../pages/select-page/select-page.component.ts | 2 ++ .../tree-autocomplete-page.component.html | 9 +++++++++ .../tree-autocomplete-page.component.ts | 3 +++ .../tree-select-page.component.html | 9 +++++++++ .../tree-select-page.component.ts | 3 +++ 9 files changed, 68 insertions(+) diff --git a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.html b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.html index 6bc8b53a..67214f3c 100644 --- a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.html +++ b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.html @@ -207,6 +207,21 @@ + + + + + option.data.code; + get availableOptionInfo() { return this.options.map((option) => option.name).join(', '); } diff --git a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.examples.ts b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.examples.ts index 0803205e..46732151 100644 --- a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.examples.ts +++ b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.examples.ts @@ -236,6 +236,23 @@ syncVal: string[] = [];` ts: citiesOptionsTs }, + functionOptionKey: { + html: ` + +`, + ts: ` +${citiesOptionsTs.trim()} + +getCode = (option: any): string => option.data.code;` + }, + asyncValidation: { html: ` {{ syncVal }} + + option.data.code; + // eslint-disable-next-line no-useless-constructor constructor(private _formBuilder: UntypedFormBuilder) {} diff --git a/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.html b/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.html index 3f931667..e45d00c4 100644 --- a/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.html +++ b/projects/composition/src/app/pages/tree-autocomplete-page/tree-autocomplete-page.component.html @@ -90,6 +90,15 @@ {{ syncVal?.label }} + + + option.label + (option.attrType ? ` (${option.attrType})` : ''); + // eslint-disable-next-line no-useless-constructor constructor(private _formBuilder: UntypedFormBuilder) {} diff --git a/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html b/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html index ea7291fd..d7b6adc6 100644 --- a/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html +++ b/projects/composition/src/app/pages/tree-select-page/tree-select-page.component.html @@ -89,6 +89,15 @@ {{ syncVal?.label }} + + + option.label + (option.attrType ? ` (${option.attrType})` : ''); + // eslint-disable-next-line no-useless-constructor constructor(private _formBuilder: UntypedFormBuilder) {} From 11007bf04cc88b2e6fe8b315e8e5b203bf63bf85 Mon Sep 17 00:00:00 2001 From: Lukas Matta Date: Fri, 15 May 2026 09:45:27 +0200 Subject: [PATCH 3/3] Add unit test coverage --- .../cps-autocomplete.component.spec.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts index 5aa23e3c..456674d0 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts @@ -441,6 +441,88 @@ describe('CpsAutocompleteComponent', () => { expect(component.filteredOptions.length).toBe(3); }); + describe('function-based option keys', () => { + const nestedOptions = [ + { meta: { title: 'Alpha', id: 1 } }, + { meta: { title: 'Beta', id: 2 } }, + { meta: { title: 'Gamma', id: 3 } } + ]; + + it('getProp should return property value when key is a string', () => { + expect(component.getProp({ label: 'hello' }, 'label')).toBe('hello'); + }); + + it('getProp should invoke the function and return its result when key is a function', () => { + const fn = (o: any) => o.meta.title; + expect(component.getProp({ meta: { title: 'hello' } }, fn)).toBe('hello'); + }); + + it('should display selected label using function optionLabel', () => { + fixture.componentRef.setInput('options', nestedOptions); + fixture.componentRef.setInput('optionLabel', (o: any) => o.meta.title); + component.value = nestedOptions[0]; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + const selectedLabel = fixture.debugElement.query( + By.css('.single-item-selection span') + ); + expect(selectedLabel.nativeElement.textContent.trim()).toBe('Alpha'); + }); + + it('should filter options using function optionLabel', fakeAsync(() => { + fixture.componentRef.setInput('options', nestedOptions); + fixture.componentRef.setInput('optionLabel', (o: any) => o.meta.title); + fixture.detectChanges(); + const inputElement = fixture.debugElement.query( + By.css('.cps-autocomplete-box-input') + ); + inputElement.nativeElement.value = 'bet'; + inputElement.nativeElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + tick(component.inputChangeDebounceTime); + fixture.detectChanges(); + expect(component.filteredOptions.length).toBe(1); + expect(component.filteredOptions[0]).toBe(nestedOptions[1]); + })); + + it('should emit the function-derived value when optionValue is a function and returnObject is false', () => { + fixture.componentRef.setInput('options', nestedOptions); + fixture.componentRef.setInput('optionLabel', (o: any) => o.meta.title); + fixture.componentRef.setInput('optionValue', (o: any) => o.meta.id); + fixture.componentRef.setInput('returnObject', false); + fixture.detectChanges(); + jest.spyOn(component.valueChanged, 'emit'); + component.select(nestedOptions[1], false); + expect(component.valueChanged.emit).toHaveBeenCalledWith(2); + }); + + it('should collect all function-derived values when toggleAll is called with returnObject false', () => { + fixture.componentRef.setInput('options', nestedOptions); + fixture.componentRef.setInput('optionLabel', (o: any) => o.meta.title); + fixture.componentRef.setInput('optionValue', (o: any) => o.meta.id); + fixture.componentRef.setInput('returnObject', false); + fixture.componentRef.setInput('multiple', true); + component.value = []; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + component.toggleAll(); + expect(component.value).toEqual([1, 2, 3]); + }); + + it('should display chips with labels from function optionLabel', () => { + fixture.componentRef.setInput('options', nestedOptions); + fixture.componentRef.setInput('optionLabel', (o: any) => o.meta.title); + fixture.componentRef.setInput('multiple', true); + component.value = [nestedOptions[0], nestedOptions[2]]; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + const chipElements = fixture.debugElement.queryAll(By.css('cps-chip')); + expect(chipElements.length).toBe(2); + expect(chipElements[0].nativeElement.textContent.trim()).toBe('Alpha'); + expect(chipElements[1].nativeElement.textContent.trim()).toBe('Gamma'); + }); + }); + describe('aria-label', () => { it('should set aria-label from ariaLabel input', () => { fixture.componentRef.setInput('ariaLabel', 'Search options');