Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

<input
#chipInput
autocomplete="chrome-off"
[placeholder]="placeholder()"
[ixTest]="controlDirective.name"
[disabled]="isDisabled"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,21 +182,41 @@ export class IxChipsComponent implements OnChanges, ControlValueAccessor {

onInputBlur(): void {
const trigger = this.autocompleteTrigger();
const inputValue = this.chipInput().nativeElement.value;

// If autocomplete panel is open, an option selection is in progress
// Skip blur processing to avoid adding search text as a chip
// If autocomplete panel is open, wait for it to close before processing blur
if (trigger?.panelOpen) {
// If there's a typed value, process it after the panel closes
if (inputValue.trim() && this.allowNewEntries() && !this.resolveValue()) {
trigger.panelClosingActions.pipe(take(1), untilDestroyed(this)).subscribe(() => {
// Re-check the input value after panel closes, in case user selected an option
const currentValue = this.chipInput().nativeElement.value;
if (currentValue.trim()) {
this.onAdd(currentValue);
} else {
// Call onTouch even if no value was added to trigger validation
this.onTouch();
}
});
} else {
// No value to process, but still need to trigger validation
trigger.panelClosingActions.pipe(take(1), untilDestroyed(this)).subscribe(() => {
this.onTouch();
});
}
return;
}

if (!this.allowNewEntries() || this.resolveValue()) {
this.chipInput().nativeElement.value = '';
this.onTouch();
return;
}

const inputValue = this.chipInput().nativeElement.value;
if (inputValue.trim()) {
this.onAdd(inputValue);
} else {
this.onTouch();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<input
#ixInput
matInput
autocomplete="chrome-off"
[value]="selectedOption?.label || textContent"
[placeholder]="allowCustomValue() ? ('Search or enter value' | translate) : ('Search' | translate)"
[disabled]="isDisabled"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ export class IxComboboxComponent implements ControlValueAccessor, OnInit {

if (!this.allowCustomValue() && !this.selectedOption) {
this.resetInput();
return;
}

// Commit custom value on blur if one is typed
if (this.allowCustomValue() && this.textContent && !this.selectedOption) {
this.value = this.textContent;
this.onChange(this.textContent);
}
}

Expand Down Expand Up @@ -224,8 +231,14 @@ export class IxComboboxComponent implements ControlValueAccessor, OnInit {
}

onChanged(changedValue: string): void {
// Clear selected option when user starts typing something new
if (this.selectedOption?.value || this.value) {
this.resetInput();
this.selectedOption = null;
this.value = null;
// If custom values aren't allowed, immediately clear the form control
if (!this.allowCustomValue()) {
this.onChange(null);
}
}
this.textContent = changedValue;
this.filterChanged$.next(changedValue);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<ix-chips
[label]="label()"
[placeholder]="placeholder()"
[hint]="hint()"
[tooltip]="tooltip()"
[required]="required()"
[autocompleteProvider]="groupProvider"
></ix-chips>
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { createComponentFactory, Spectator, mockProvider } from '@ngneat/spectator/jest';
import { of } from 'rxjs';
import { mockApi } from 'app/core/testing/utils/mock-api.utils';
import { Group } from 'app/interfaces/group.interface';
import { IxGroupChipsComponent } from 'app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component';
import { TranslatedString } from 'app/modules/translate/translate.helper';
import { UserService } from 'app/services/user.service';

@Component({
selector: 'ix-test-host',
template: '<ix-group-chips [formControl]="control" [label]="label" />',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [IxGroupChipsComponent, ReactiveFormsModule],
})
class TestHostComponent {
control = new FormControl<string[]>([]);
label = 'Test Groups' as TranslatedString;
}

describe('IxGroupChipsComponent', () => {
let spectator: Spectator<TestHostComponent>;

const createComponent = createComponentFactory({
component: TestHostComponent,
providers: [
mockApi([]),
mockProvider(UserService, {
groupQueryDsCache: jest.fn(() => of([
{ group: 'wheel' },
{ group: 'users' },
] as Group[])),
getGroupByName: jest.fn((groupName: string) => of({ group: groupName })),
}),
],
});

beforeEach(() => {
spectator = createComponent();
});

it('shows label', () => {
expect(spectator.query('label')).toHaveText('Test Groups');
});

it('renders chip grid input', () => {
const input = spectator.query('input');
expect(input).toExist();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
AfterViewInit, ChangeDetectionStrategy, Component, input, viewChild, inject,
} from '@angular/core';
import { ControlValueAccessor, NgControl } from '@angular/forms';
import { UntilDestroy } from '@ngneat/until-destroy';
import { map } from 'rxjs/operators';
import { ChipsProvider } from 'app/modules/forms/ix-forms/components/ix-chips/chips-provider';
import { IxChipsComponent } from 'app/modules/forms/ix-forms/components/ix-chips/ix-chips.component';
import { registeredDirectiveConfig } from 'app/modules/forms/ix-forms/directives/registered-control.directive';
import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service';
import { TranslatedString } from 'app/modules/translate/translate.helper';
import { UserService } from 'app/services/user.service';

/**
* Specialized chips component for group input with built-in validation.
* Automatically validates that entered groups exist in the system (local or directory services).
* Provides autocomplete suggestions from the group cache.
*
* @example
* ```html
* <ix-group-chips
* formControlName="groups"
* [label]="'Groups' | translate"
* [tooltip]="'Select groups' | translate"
* ></ix-group-chips>
* ```
*/
@UntilDestroy()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If think we are moving away from UntilDestroy

@Component({
selector: 'ix-group-chips',
templateUrl: './ix-group-chips.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
IxChipsComponent,
],
hostDirectives: [
{ ...registeredDirectiveConfig },
],
})
export class IxGroupChipsComponent implements AfterViewInit, ControlValueAccessor {
private controlDirective = inject(NgControl);
private userService = inject(UserService);
private existenceValidator = inject(UserGroupExistenceValidationService);

readonly label = input<TranslatedString>();
readonly placeholder = input<TranslatedString>('');
readonly hint = input<TranslatedString>();
readonly tooltip = input<TranslatedString>();
readonly required = input<boolean>(false);

private readonly ixChips = viewChild.required(IxChipsComponent);

protected readonly groupProvider: ChipsProvider = (query) => {
return this.userService.groupQueryDsCache(query).pipe(
map((groups) => groups.map((group) => group.group)),
);
};

constructor() {
this.controlDirective.valueAccessor = this;
}

ngAfterViewInit(): void {
// Add async validator to check group existence.
// The base ix-chips component allows new entries by default,
// so validation is always needed to ensure typed groups exist.
const control = this.controlDirective.control;
if (control) {
control.addAsyncValidators([
this.existenceValidator.validateGroupsExist(),
]);
// Don't call updateValueAndValidity() here to avoid showing validation errors
// immediately on form load. Validation will run automatically when the user
// interacts with the field or when the form is submitted.
}
}

writeValue(value: string[]): void {
this.ixChips().writeValue(value);
}

registerOnChange(onChange: (value: string[]) => void): void {
this.ixChips().registerOnChange(onChange);
}

registerOnTouched(onTouched: () => void): void {
this.ixChips().registerOnTouched(onTouched);
}

setDisabledState?(isDisabled: boolean): void {
this.ixChips().setDisabledState?.(isDisabled);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<ix-combobox
[label]="label()"
[hint]="hint()"
[tooltip]="tooltip()"
[required]="required()"
[allowCustomValue]="allowCustomValue()"
[provider]="groupProvider"
></ix-combobox>
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { createComponentFactory, Spectator, mockProvider } from '@ngneat/spectator/jest';
import { of } from 'rxjs';
import { mockApi } from 'app/core/testing/utils/mock-api.utils';
import { Group } from 'app/interfaces/group.interface';
import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component';
import { TranslatedString } from 'app/modules/translate/translate.helper';
import { UserService } from 'app/services/user.service';

@Component({
selector: 'ix-test-host',
template: '<ix-group-combobox [formControl]="control" [label]="label" />',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [IxGroupComboboxComponent, ReactiveFormsModule],
})
class TestHostComponent {
control = new FormControl<string>('');
label = 'Test Group' as TranslatedString;
}

describe('IxGroupComboboxComponent', () => {
let spectator: Spectator<TestHostComponent>;

const createComponent = createComponentFactory({
component: TestHostComponent,
providers: [
mockApi([]),
mockProvider(UserService, {
groupQueryDsCache: jest.fn(() => of([
{ group: 'wheel' },
{ group: 'users' },
] as Group[])),
getGroupByName: jest.fn((groupName: string) => of({ group: groupName })),
}),
],
});

beforeEach(() => {
spectator = createComponent();
});

it('shows label', () => {
expect(spectator.query('label')).toHaveText('Test Group');
});

it('renders combobox input', () => {
const input = spectator.query('input');
expect(input).toExist();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
AfterViewInit, ChangeDetectionStrategy, Component, input, viewChild, inject,
} from '@angular/core';
import { ControlValueAccessor, NgControl } from '@angular/forms';
import { UntilDestroy } from '@ngneat/until-destroy';
import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider';
import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component';
import { registeredDirectiveConfig } from 'app/modules/forms/ix-forms/directives/registered-control.directive';
import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service';
import { TranslatedString } from 'app/modules/translate/translate.helper';
import { UserService } from 'app/services/user.service';

/**
* Specialized combobox component for group input with built-in validation.
* Automatically validates that entered groups exist in the system (local or directory services).
* Provides autocomplete suggestions from the group cache.
*
* @example
* ```html
* <ix-group-combobox
* formControlName="ownerGroup"
* [label]="'Group' | translate"
* [tooltip]="'Select a group' | translate"
* [required]="true"
* ></ix-group-combobox>
* ```
*/
@UntilDestroy()
@Component({
selector: 'ix-group-combobox',
templateUrl: './ix-group-combobox.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
IxComboboxComponent,
],
hostDirectives: [
{ ...registeredDirectiveConfig },
],
})
export class IxGroupComboboxComponent implements AfterViewInit, ControlValueAccessor {
private controlDirective = inject(NgControl);
private userService = inject(UserService);
private existenceValidator = inject(UserGroupExistenceValidationService);

readonly label = input<TranslatedString>();
readonly hint = input<TranslatedString>();
readonly tooltip = input<TranslatedString>();
readonly required = input<boolean>(false);
readonly allowCustomValue = input<boolean>(true);

private readonly ixCombobox = viewChild.required(IxComboboxComponent);

protected readonly groupProvider = new GroupComboboxProvider(this.userService);

constructor() {
this.controlDirective.valueAccessor = this;
}

ngAfterViewInit(): void {
// Add validation to check group existence when custom values are allowed (default).
// When allowCustomValue is false, users can only select from autocomplete
// suggestions which are guaranteed to exist, making validation redundant.
const control = this.controlDirective.control;
if (control && this.allowCustomValue()) {
control.addAsyncValidators([
this.existenceValidator.validateGroupExists(),
]);
// Don't call updateValueAndValidity() here to avoid showing validation errors
// immediately on form load. Validation will run automatically when the user
// interacts with the field or when the form is submitted.
}
}

writeValue(value: string | number): void {
this.ixCombobox().writeValue(value);
}

registerOnChange(onChange: (value: string | number | null) => void): void {
this.ixCombobox().registerOnChange(onChange);
}

registerOnTouched(onTouched: () => void): void {
this.ixCombobox().registerOnTouched(onTouched);
}

setDisabledState?(isDisabled: boolean): void {
this.ixCombobox().setDisabledState?.(isDisabled);
}
}
Loading
Loading