diff --git a/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.html b/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.html index 87007068641..1ad3f0934ca 100644 --- a/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.html +++ b/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.html @@ -32,6 +32,7 @@ { + // 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(); } } diff --git a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.html b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.html index 80187af42ca..4a910bb2571 100644 --- a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.html +++ b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.html @@ -12,6 +12,7 @@ { it('announces validation errors', () => { control.setValidators([Validators.required]); + control.markAsTouched(); control.updateValueAndValidity(); spectator.detectComponentChanges(); @@ -36,6 +37,7 @@ describe('IxErrorsComponent', () => { manualValidateError: true, manualValidateErrorMsg: 'Custom error', }); + control.markAsTouched(); spectator.detectComponentChanges(); expect(spectator.inject(LiveAnnouncer).announce).toHaveBeenCalledWith('Errors in Name: Custom error'); diff --git a/src/app/modules/forms/ix-forms/components/ix-errors/ix-errors.component.ts b/src/app/modules/forms/ix-forms/components/ix-errors/ix-errors.component.ts index 5f92eff6395..41be1d0d50f 100644 --- a/src/app/modules/forms/ix-forms/components/ix-errors/ix-errors.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-errors/ix-errors.component.ts @@ -120,12 +120,12 @@ export class IxErrorsComponent implements OnChanges, OnDestroy { this.messages = newErrors.filter((message) => !!message) as string[]; - if (this.control().errors) { - this.control().markAllAsTouched(); - } - this.cdr.markForCheck(); - this.announceErrors(); + + // Only announce errors if the control has been touched or is dirty + if (this.control().touched || this.control().dirty) { + this.announceErrors(); + } } /** diff --git a/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.html b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.html new file mode 100644 index 00000000000..21686fd12f5 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.spec.ts b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.spec.ts new file mode 100644 index 00000000000..dca0ae78689 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.spec.ts @@ -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: '', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [IxGroupChipsComponent, ReactiveFormsModule], +}) +class TestHostComponent { + control = new FormControl([]); + label = 'Test Groups' as TranslatedString; +} + +describe('IxGroupChipsComponent', () => { + let spectator: Spectator; + + 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(); + }); +}); diff --git a/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts new file mode 100644 index 00000000000..bb3d2acfc42 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts @@ -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 + * + * ``` + */ +@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(); + readonly placeholder = input(''); + readonly hint = input(); + readonly tooltip = input(); + readonly required = input(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); + } +} diff --git a/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.html b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.html new file mode 100644 index 00000000000..84a93e6b929 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.spec.ts b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.spec.ts new file mode 100644 index 00000000000..4b530036330 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.spec.ts @@ -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: '', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [IxGroupComboboxComponent, ReactiveFormsModule], +}) +class TestHostComponent { + control = new FormControl(''); + label = 'Test Group' as TranslatedString; +} + +describe('IxGroupComboboxComponent', () => { + let spectator: Spectator; + + 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(); + }); +}); diff --git a/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts new file mode 100644 index 00000000000..35be524979b --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts @@ -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 + * + * ``` + */ +@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(); + readonly hint = input(); + readonly tooltip = input(); + readonly required = input(false); + readonly allowCustomValue = input(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); + } +} diff --git a/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.html b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.html new file mode 100644 index 00000000000..7fdc6b6e362 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.spec.ts b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.spec.ts new file mode 100644 index 00000000000..842ddf736d2 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.spec.ts @@ -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 { User } from 'app/interfaces/user.interface'; +import { IxUserChipsComponent } from 'app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component'; +import { TranslatedString } from 'app/modules/translate/translate.helper'; +import { UserService } from 'app/services/user.service'; + +@Component({ + selector: 'ix-test-host', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [IxUserChipsComponent, ReactiveFormsModule], +}) +class TestHostComponent { + control = new FormControl([]); + label = 'Test Users' as TranslatedString; +} + +describe('IxUserChipsComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: TestHostComponent, + providers: [ + mockApi([]), + mockProvider(UserService, { + userQueryDsCache: jest.fn(() => of([ + { username: 'root' }, + { username: 'admin' }, + ] as User[])), + getUserByName: jest.fn((username: string) => of({ username } as User)), + }), + ], + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('shows label', () => { + expect(spectator.query('label')).toHaveText('Test Users'); + }); + + it('renders chip grid input', () => { + const input = spectator.query('input'); + expect(input).toExist(); + }); +}); diff --git a/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts new file mode 100644 index 00000000000..b69a31e09c8 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts @@ -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 user input with built-in validation. + * Automatically validates that entered users exist in the system (local or directory services). + * Provides autocomplete suggestions from the user cache. + * + * @example + * ```html + * + * ``` + */ +@UntilDestroy() +@Component({ + selector: 'ix-user-chips', + templateUrl: './ix-user-chips.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + IxChipsComponent, + ], + hostDirectives: [ + { ...registeredDirectiveConfig }, + ], +}) +export class IxUserChipsComponent implements AfterViewInit, ControlValueAccessor { + private controlDirective = inject(NgControl); + private userService = inject(UserService); + private existenceValidator = inject(UserGroupExistenceValidationService); + + readonly label = input(); + readonly placeholder = input(''); + readonly hint = input(); + readonly tooltip = input(); + readonly required = input(false); + + private readonly ixChips = viewChild.required(IxChipsComponent); + + protected readonly userProvider: ChipsProvider = (query) => { + return this.userService.userQueryDsCache(query).pipe( + map((users) => users.map((user) => user.username)), + ); + }; + + constructor() { + this.controlDirective.valueAccessor = this; + } + + ngAfterViewInit(): void { + // Add async validator to check user existence. + // The base ix-chips component allows new entries by default, + // so validation is always needed to ensure typed users exist. + const control = this.controlDirective.control; + if (control) { + control.addAsyncValidators([ + this.existenceValidator.validateUsersExist(), + ]); + // 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); + } +} diff --git a/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.html b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.html new file mode 100644 index 00000000000..7d6be48c59a --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.spec.ts b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.spec.ts new file mode 100644 index 00000000000..af7fe9b38c3 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.spec.ts @@ -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 { User } from 'app/interfaces/user.interface'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; +import { TranslatedString } from 'app/modules/translate/translate.helper'; +import { UserService } from 'app/services/user.service'; + +@Component({ + selector: 'ix-test-host', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [IxUserComboboxComponent, ReactiveFormsModule], +}) +class TestHostComponent { + control = new FormControl(''); + label = 'Test User' as TranslatedString; +} + +describe('IxUserComboboxComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: TestHostComponent, + providers: [ + mockApi([]), + mockProvider(UserService, { + userQueryDsCache: jest.fn(() => of([ + { username: 'root' }, + { username: 'admin' }, + ] as User[])), + getUserByName: jest.fn((username: string) => of({ username } as User)), + }), + ], + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('shows label', () => { + expect(spectator.query('label')).toHaveText('Test User'); + }); + + it('renders combobox input', () => { + const input = spectator.query('input'); + expect(input).toExist(); + }); +}); diff --git a/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts new file mode 100644 index 00000000000..b12e045a5b3 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts @@ -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 { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-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 user input with built-in validation. + * Automatically validates that entered users exist in the system (local or directory services). + * Provides autocomplete suggestions from the user cache. + * + * @example + * ```html + * + * ``` + */ +@UntilDestroy() +@Component({ + selector: 'ix-user-combobox', + templateUrl: './ix-user-combobox.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + IxComboboxComponent, + ], + hostDirectives: [ + { ...registeredDirectiveConfig }, + ], +}) +export class IxUserComboboxComponent implements AfterViewInit, ControlValueAccessor { + private controlDirective = inject(NgControl); + private userService = inject(UserService); + private existenceValidator = inject(UserGroupExistenceValidationService); + + readonly label = input(); + readonly hint = input(); + readonly tooltip = input(); + readonly required = input(false); + readonly allowCustomValue = input(true); + + private readonly ixCombobox = viewChild.required(IxComboboxComponent); + + protected readonly userProvider = new UserComboboxProvider(this.userService); + + constructor() { + this.controlDirective.valueAccessor = this; + } + + ngAfterViewInit(): void { + // Add validation to check user 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.validateUserExists(), + ]); + // 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); + } +} diff --git a/src/app/modules/forms/ix-forms/components/ix-user-picker/ix-user-picker.component.ts b/src/app/modules/forms/ix-forms/components/ix-user-picker/ix-user-picker.component.ts index bc874919ab7..18dc2e5ff2a 100644 --- a/src/app/modules/forms/ix-forms/components/ix-user-picker/ix-user-picker.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-user-picker/ix-user-picker.component.ts @@ -360,11 +360,8 @@ export class IxUserPickerComponent implements ControlValueAccessor, OnInit { catchError((error: unknown) => { // Handle slide-in errors gracefully this.errorHandler.handleError(error); - // Clear selection to allow "Add New" to be clicked again - this.selectedOption.set(null); - if (this.inputElementRef()?.nativeElement) { - this.inputElementRef().nativeElement.value = ''; - } + // Clear selection and reset form control value to allow "Add New" to be clicked again + this.resetInput(); this.autocompleteTrigger()?.closePanel(); return of(null); }), @@ -395,11 +392,8 @@ export class IxUserPickerComponent implements ControlValueAccessor, OnInit { this.cdr.markForCheck(); } else { - // User cancelled - clear selection to allow "Add New" to be clicked again - this.selectedOption.set(null); - if (this.inputElementRef()?.nativeElement) { - this.inputElementRef().nativeElement.value = ''; - } + // User cancelled - clear selection and reset form control value to allow "Add New" to be clicked again + this.resetInput(); } // Close panel immediately - the selection is already set diff --git a/src/app/modules/forms/ix-forms/validators/user-group-existence-validation.service.ts b/src/app/modules/forms/ix-forms/validators/user-group-existence-validation.service.ts new file mode 100644 index 00000000000..488db37887a --- /dev/null +++ b/src/app/modules/forms/ix-forms/validators/user-group-existence-validation.service.ts @@ -0,0 +1,212 @@ +import { Injectable, inject } from '@angular/core'; +import { AsyncValidatorFn, ValidationErrors } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { + Observable, catchError, debounceTime, forkJoin, map, of, switchMap, +} from 'rxjs'; +import { UserService } from 'app/services/user.service'; + +/** + * Service providing async validators for user and group existence checks. + * Used in forms where users can input user/group names that need to be validated + * against the system (local or directory services). + */ +@Injectable({ providedIn: 'root' }) +export class UserGroupExistenceValidationService { + private userService = inject(UserService); + private translate = inject(TranslateService); + + /** + * Creates an async validator that checks if all specified groups exist in the system. + * Provides real-time feedback with debouncing to prevent excessive API calls. + * + * @param debounceMs - Debounce time in milliseconds. Defaults to 500ms. + * @returns AsyncValidatorFn that validates group existence + * + * @example + * ```typescript + * this.form.controls.groups.addAsyncValidators([ + * this.validationService.validateGroupsExist() + * ]); + * ``` + */ + validateGroupsExist(debounceMs = 500): AsyncValidatorFn { + return (control): Observable => { + const groups = control.value as string[]; + + if (!groups || groups.length === 0) { + return of(null); + } + + // Debounce BEFORE API calls to prevent firing them on every keystroke + return of(groups).pipe( + debounceTime(debounceMs), + switchMap((debouncedGroups) => { + const groupChecks = debouncedGroups.map((groupName: string) => { + return this.userService.getGroupByName(groupName).pipe( + map(() => ({ name: groupName, exists: true })), + catchError(() => of({ name: groupName, exists: false })), + ); + }); + return forkJoin(groupChecks); + }), + map((results) => { + const nonExistent = results + .filter((result) => !result.exists) + .map((result) => result.name); + + if (nonExistent.length > 0) { + return { + groupsDoNotExist: { + message: this.translate.instant( + 'The following groups do not exist: {groups}', + { groups: nonExistent.join(', ') }, + ), + }, + }; + } + + return null; + }), + ); + }; + } + + /** + * Creates an async validator that checks if all specified users exist in the system. + * Provides real-time feedback with debouncing to prevent excessive API calls. + * + * @param debounceMs - Debounce time in milliseconds. Defaults to 500ms. + * @returns AsyncValidatorFn that validates user existence + * + * @example + * ```typescript + * this.form.controls.users.addAsyncValidators([ + * this.validationService.validateUsersExist() + * ]); + * ``` + */ + validateUsersExist(debounceMs = 500): AsyncValidatorFn { + return (control): Observable => { + const users = control.value as string[]; + + if (!users || users.length === 0) { + return of(null); + } + + // Debounce BEFORE API calls to prevent firing them on every keystroke + return of(users).pipe( + debounceTime(debounceMs), + switchMap((debouncedUsers) => { + const userChecks = debouncedUsers.map((username: string) => { + return this.userService.getUserByName(username).pipe( + map(() => ({ name: username, exists: true })), + catchError(() => of({ name: username, exists: false })), + ); + }); + return forkJoin(userChecks); + }), + map((results) => { + const nonExistent = results + .filter((result) => !result.exists) + .map((result) => result.name); + + if (nonExistent.length > 0) { + return { + usersDoNotExist: { + message: this.translate.instant( + 'The following users do not exist: {users}', + { users: nonExistent.join(', ') }, + ), + }, + }; + } + + return null; + }), + ); + }; + } + + /** + * Creates an async validator that checks if a single user exists in the system. + * Used for combobox components where only one user can be selected. + * + * @param debounceMs - Debounce time in milliseconds. Defaults to 500ms. + * @returns AsyncValidatorFn that validates single user existence + * + * @example + * ```typescript + * this.form.controls.owner.addAsyncValidators([ + * this.validationService.validateUserExists() + * ]); + * ``` + */ + validateUserExists(debounceMs = 500): AsyncValidatorFn { + return (control): Observable => { + const username = control.value as string; + + if (!username || username.trim() === '') { + return of(null); + } + + return of(username).pipe( + debounceTime(debounceMs), + switchMap((debouncedUsername): Observable => { + return this.userService.getUserByName(debouncedUsername).pipe( + map((): null => null), + catchError((): Observable => of({ + userDoesNotExist: { + message: this.translate.instant( + 'User "{username}" does not exist', + { username: debouncedUsername }, + ), + }, + })), + ); + }), + ); + }; + } + + /** + * Creates an async validator that checks if a single group exists in the system. + * Used for combobox components where only one group can be selected. + * + * @param debounceMs - Debounce time in milliseconds. Defaults to 500ms. + * @returns AsyncValidatorFn that validates single group existence + * + * @example + * ```typescript + * this.form.controls.ownerGroup.addAsyncValidators([ + * this.validationService.validateGroupExists() + * ]); + * ``` + */ + validateGroupExists(debounceMs = 500): AsyncValidatorFn { + return (control): Observable => { + const groupName = control.value as string; + + if (!groupName || groupName.trim() === '') { + return of(null); + } + + return of(groupName).pipe( + debounceTime(debounceMs), + switchMap((debouncedGroupName): Observable => { + return this.userService.getGroupByName(debouncedGroupName).pipe( + map((): null => null), + catchError((): Observable => of({ + groupDoesNotExist: { + message: this.translate.instant( + 'Group "{groupName}" does not exist', + { groupName: debouncedGroupName }, + ), + }, + })), + ); + }), + ); + }; + } +} diff --git a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.html b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.html index 2cecfd6d4bf..f06c2427a06 100644 --- a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.html +++ b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.html @@ -22,12 +22,10 @@ > @if (isEnterprise()) { - + > } { }), mockProvider(UserService, { groupQueryDsCache: jest.fn(() => of([])), + getGroupByName: jest.fn((groupName: string) => { + // Return existing groups, error for non-existent ones + const existingGroup = testGroups.find((group) => group.group === groupName); + if (existingGroup) { + return of(existingGroup); + } + return of(null); + }), + getUserByName: jest.fn(() => { + // Mock user validation - all users are considered non-existent for testing + return of(null); + }), }), provideMockStore({ selectors: [ @@ -372,41 +384,15 @@ describe('PrivilegeFormComponent', () => { expect(callArgs[1][1]).toEqual({ limit: 50, order_by: ['group'] }); }); - it('should use UserService.groupQueryDsCache for DS groups', async () => { - const provider = spectator.component.dsGroupsProvider; - const userService = spectator.inject(UserService); - - jest.spyOn(userService, 'groupQueryDsCache').mockReturnValue(of([ - { id: 1, group: 'domain-test', gid: 1001 } as Group, - { id: 2, group: 'test-domain', gid: 1002 } as Group, - ])); - - const result = await lastValueFrom(provider('test')); - - // Should call UserService.groupQueryDsCache with the query - expect(userService.groupQueryDsCache).toHaveBeenCalledWith('test', false, 0); - - // Should return group names - expect(result).toEqual(['domain-test', 'test-domain']); + it('uses ix-group-chips component for DS groups with automatic validation', () => { + const groupChipsComponent = spectator.query('ix-group-chips[formControlName="ds_groups"]'); + expect(groupChipsComponent).toExist(); }); - it('should limit DS groups results to 50', async () => { - const provider = spectator.component.dsGroupsProvider; + it('dS groups field uses UserService for group queries', () => { const userService = spectator.inject(UserService); - - // Mock more than 50 groups - const manyGroups = Array.from({ length: 100 }, (_, i) => ({ - id: i, - group: `group${i}`, - gid: 1000 + i, - } as Group)); - - jest.spyOn(userService, 'groupQueryDsCache').mockReturnValue(of(manyGroups)); - - const result = await lastValueFrom(provider('test')); - - // Should limit to 50 results - expect(result).toHaveLength(50); + // ix-group-chips component automatically uses UserService.groupQueryDsCache + expect(userService).toBeTruthy(); }); it('should handle empty query for local groups', async () => { diff --git a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts index ff468253f0d..7cc3b1ed812 100644 --- a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts +++ b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts @@ -1,6 +1,8 @@ -import { Component, ChangeDetectionStrategy, OnInit, signal, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, Component, OnInit, signal, inject, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { Validators, ReactiveFormsModule } from '@angular/forms'; +import { ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { FormBuilder } from '@ngneat/reactive-forms'; @@ -20,6 +22,7 @@ import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-ch 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 { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupChipsComponent } from 'app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; @@ -27,7 +30,6 @@ import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-hea import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; import { TestDirective } from 'app/modules/test-id/test.directive'; import { ApiService } from 'app/modules/websocket/api.service'; -import { UserService } from 'app/services/user.service'; import { AppState } from 'app/store'; import { generalConfigUpdated } from 'app/store/system-config/system-config.actions'; import { waitForGeneralConfig } from 'app/store/system-config/system-config.selectors'; @@ -46,6 +48,7 @@ import { selectIsEnterprise } from 'app/store/system-info/system-info.selectors' IxFieldsetComponent, IxInputComponent, IxChipsComponent, + IxGroupChipsComponent, IxSelectComponent, IxCheckboxComponent, FormActionsComponent, @@ -62,7 +65,6 @@ export class PrivilegeFormComponent implements OnInit { private errorHandler = inject(FormErrorHandlerService); private store$ = inject>(Store); private dialog = inject(DialogService); - private userService = inject(UserService); slideInRef = inject>(SlideInRef); protected readonly requiredRoles = [Role.PrivilegeWrite]; @@ -152,27 +154,6 @@ export class PrivilegeFormComponent implements OnInit { ); }; - /** - * Provider for directory service groups autocomplete. - * - * Uses ChipsProvider instead of GroupComboboxProvider for consistency with localGroupsProvider. - * See localGroupsProvider documentation for rationale. - * - * Uses UserService.groupQueryDsCache for proper handling of: - * - Domain-prefixed group names (e.g., "ACME\admin") - * - Case-insensitive regex search - * - Exact name match fallback - * - Proper backslash escaping - * - * Limited to 50 results for performance. - */ - readonly dsGroupsProvider: ChipsProvider = (query: string) => { - return this.userService.groupQueryDsCache(query || '', false, 0).pipe( - map((groups) => groups.slice(0, this.GROUP_QUERY_LIMIT)), - map((groups) => groups.map((group) => group.group)), - ); - }; - constructor() { this.slideInRef.requireConfirmationWhen(() => { return of(this.form.dirty); diff --git a/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.html b/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.html index 45082893fbd..0aafea9f845 100644 --- a/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.html +++ b/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.html @@ -13,13 +13,12 @@ [nodeProvider]="treeNodeProvider" > - + > { { username: 'root' }, { username: 'steven' }, ] as User[]), + getUserByName: (username: string) => of({ username } as User), }), mockProvider(DialogService), provideMockStore({ diff --git a/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.ts b/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.ts index bfb6c4bfc1d..9a22fbed7f2 100644 --- a/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.ts +++ b/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.ts @@ -15,15 +15,14 @@ import { helptextRsyncForm } from 'app/helptext/data-protection/rsync/rsync-form import { newOption } from 'app/interfaces/option.interface'; import { RsyncTask, RsyncTaskUpdate } from 'app/interfaces/rsync-task.interface'; import { SshCredentialsSelectComponent } from 'app/modules/forms/custom-selects/ssh-credentials-select/ssh-credentials-select.component'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; import { IxChipsComponent } from 'app/modules/forms/ix-forms/components/ix-chips/ix-chips.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxExplorerComponent } from 'app/modules/forms/ix-forms/components/ix-explorer/ix-explorer.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; import { IxSlideToggleComponent } from 'app/modules/forms/ix-forms/components/ix-slide-toggle/ix-slide-toggle.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxValidatorsService } from 'app/modules/forms/ix-forms/services/ix-validators.service'; import { portRangeValidator } from 'app/modules/forms/ix-forms/validators/range-validation/range-validation'; @@ -37,7 +36,6 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; import { ignoreTranslation } from 'app/modules/translate/translate.helper'; import { ApiService } from 'app/modules/websocket/api.service'; import { FilesystemService } from 'app/services/filesystem.service'; -import { UserService } from 'app/services/user.service'; @UntilDestroy() @Component({ @@ -52,7 +50,7 @@ import { UserService } from 'app/services/user.service'; ReactiveFormsModule, IxFieldsetComponent, IxExplorerComponent, - IxComboboxComponent, + IxUserComboboxComponent, IxSelectComponent, IxInputComponent, IxSlideToggleComponent, @@ -72,7 +70,6 @@ export class RsyncTaskFormComponent implements OnInit { private api = inject(ApiService); private cdr = inject(ChangeDetectorRef); private errorHandler = inject(FormErrorHandlerService); - private userService = inject(UserService); private filesystemService = inject(FilesystemService); private snackbar = inject(SnackbarService); private validatorsService = inject(IxValidatorsService); @@ -143,7 +140,6 @@ export class RsyncTaskFormComponent implements OnInit { { label: this.translate.instant('SSH connection from the keychain'), value: RsyncSshConnectMode.KeyChain }, ]); - readonly userProvider = new UserComboboxProvider(this.userService); readonly treeNodeProvider = this.filesystemService.getFilesystemNodeProvider({ directoriesOnly: true }); private editingTask: RsyncTask | undefined; diff --git a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.html b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.html index 7b85f3a5477..b85ff4544a9 100644 --- a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.html +++ b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.html @@ -28,13 +28,11 @@ - + > } @@ -42,13 +40,12 @@ - + > } diff --git a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.spec.ts b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.spec.ts index 6a4ce265564..f27a574958d 100644 --- a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.spec.ts +++ b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.spec.ts @@ -41,7 +41,21 @@ describe('DatasetQuotaAddFormComponent', () => { { username: 'john', roles: [] }, { username: 'jill', roles: [] }, ]), - groupQueryDsCache: () => of(), + groupQueryDsCache: () => of([ + { group: 'test-group', gid: 1000 }, + ]), + getGroupByName: jest.fn((groupName: string) => { + if (groupName === 'test-group') { + return of({ group: 'test-group', gid: 1000 }); + } + return of(null); + }), + getUserByName: jest.fn((username: string) => { + if (username === 'john' || username === 'jill') { + return of({ username, uid: username === 'john' ? 1001 : 1002 }); + } + return of(null); + }), }), mockProvider(SlideIn), mockProvider(DialogService), diff --git a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.ts b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.ts index e8100d35c20..4f497e94656 100644 --- a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.ts +++ b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.ts @@ -1,23 +1,24 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, signal, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, Component, signal, inject, +} from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { FormBuilder } from '@ngneat/reactive-forms'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { combineLatest, map, of } from 'rxjs'; +import { of } from 'rxjs'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { DatasetQuotaType } from 'app/enums/dataset.enum'; import { Role } from 'app/enums/role.enum'; import { helptextGlobal } from 'app/helptext/global-helptext'; import { helptextQuotas } from 'app/helptext/storage/volumes/datasets/dataset-quotas'; import { SetDatasetQuota } from 'app/interfaces/dataset-quota.interface'; -import { AuthService } from 'app/modules/auth/auth.service'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; -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 { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupChipsComponent } from 'app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; +import { IxUserChipsComponent } from 'app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxFormatterService } from 'app/modules/forms/ix-forms/services/ix-formatter.service'; import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; @@ -25,7 +26,6 @@ import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; import { TestDirective } from 'app/modules/test-id/test.directive'; import { ApiService } from 'app/modules/websocket/api.service'; -import { UserService } from 'app/services/user.service'; @UntilDestroy() @Component({ @@ -41,22 +41,20 @@ import { UserService } from 'app/services/user.service'; IxFieldsetComponent, IxInputComponent, TranslateModule, - IxChipsComponent, + IxGroupChipsComponent, + IxUserChipsComponent, FormActionsComponent, MatButton, TestDirective, ], }) export class DatasetQuotaAddFormComponent { - private authService = inject(AuthService); private formBuilder = inject(FormBuilder); private api = inject(ApiService); private snackbar = inject(SnackbarService); private translate = inject(TranslateService); formatter = inject(IxFormatterService); - private cdr = inject(ChangeDetectorRef); private errorHandler = inject(FormErrorHandlerService); - private userService = inject(UserService); slideInRef = inject { - return combineLatest([ - this.userService.userQueryDsCache(query), - this.authService.user$.pipe(map((user) => user?.privilege?.roles?.$set || [])), - ]).pipe( - map(([users, currentRoles]) => { - return users - .filter((user) => user.roles.every((role) => currentRoles.includes(role))) - .map((user) => user.username); - }), - ); - }; - - groupProvider: ChipsProvider = (query) => { - return this.userService.groupQueryDsCache(query).pipe( - map((groups) => groups.map((group) => group.group)), - ); - }; - private datasetId: string; constructor() { diff --git a/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.html b/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.html index f5ee45d4f49..0ffce35d2cd 100644 --- a/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.html +++ b/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.html @@ -12,25 +12,21 @@ > @if (isUserTag) { - + > } @if (isGroupTag) { - + > } { { group: 'wheel' }, { group: 'vip' }, ]), + getUserByName: (username: string) => of({ username } as User), + getGroupByName: (groupName: string) => of({ group: groupName }), }), ], }); diff --git a/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.ts b/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.ts index 59a836dfb5c..6d7d9789371 100644 --- a/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.ts +++ b/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.ts @@ -21,16 +21,14 @@ import { BasicNfsPermissions, NfsAclItem, } from 'app/interfaces/acl.interface'; -import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { IxCheckboxListComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox-list/ix-checkbox-list.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; import { IxRadioGroupComponent } from 'app/modules/forms/ix-forms/components/ix-radio-group/ix-radio-group.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { DatasetAclEditorStore } from 'app/pages/datasets/modules/permissions/stores/dataset-acl-editor.store'; import { newNfsAce } from 'app/pages/datasets/modules/permissions/utils/new-ace.utils'; -import { UserService } from 'app/services/user.service'; import { NfsFormFlagsType, nfsFormFlagsTypeLabels, @@ -48,7 +46,8 @@ import { ReactiveFormsModule, IxFieldsetComponent, IxSelectComponent, - IxComboboxComponent, + IxUserComboboxComponent, + IxGroupComboboxComponent, IxRadioGroupComponent, IxCheckboxListComponent, TranslateModule, @@ -57,7 +56,6 @@ import { export class EditNfsAceComponent implements OnChanges, OnInit { private formBuilder = inject(FormBuilder); private store = inject(DatasetAclEditorStore); - private userService = inject(UserService); private translate = inject(TranslateService); readonly ace = input.required(); @@ -97,9 +95,6 @@ export class EditNfsAceComponent implements OnChanges, OnInit { advancedFlags: helptextAcl.flagsTooltip, }; - readonly userProvider = new UserComboboxProvider(this.userService); - readonly groupProvider = new GroupComboboxProvider(this.userService); - get isUserTag(): boolean { return this.form.value.tag === NfsAclTag.User; } diff --git a/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.html b/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.html index ca2f5a081fe..c6206899227 100644 --- a/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.html +++ b/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.html @@ -8,25 +8,21 @@ > @if (isUserTag) { - + > } @if (isGroupTag) { - + > } { { group: 'wheel' }, { group: 'vip' }, ]), + getUserByName: (username: string) => of({ username } as User), + getGroupByName: (groupName: string) => of({ group: groupName }), }), ], }); diff --git a/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.ts b/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.ts index eff161e93d7..58f7ccc64ff 100644 --- a/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.ts +++ b/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.ts @@ -9,15 +9,13 @@ import { import { mapToOptions } from 'app/helpers/options.helper'; import { helptextAcl } from 'app/helptext/storage/volumes/datasets/dataset-acl'; import { PosixAclItem } from 'app/interfaces/acl.interface'; -import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; import { IxCheckboxListComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox-list/ix-checkbox-list.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { DatasetAclEditorStore } from 'app/pages/datasets/modules/permissions/stores/dataset-acl-editor.store'; -import { UserService } from 'app/services/user.service'; @UntilDestroy() @Component({ @@ -29,14 +27,14 @@ import { UserService } from 'app/services/user.service'; ReactiveFormsModule, IxFieldsetComponent, IxSelectComponent, - IxComboboxComponent, + IxUserComboboxComponent, + IxGroupComboboxComponent, IxCheckboxListComponent, IxCheckboxComponent, TranslateModule, ], }) export class EditPosixAceComponent implements OnInit, OnChanges { - private userService = inject(UserService); private store = inject(DatasetAclEditorStore); private formBuilder = inject(FormBuilder); private translate = inject(TranslateService); @@ -59,9 +57,6 @@ export class EditPosixAceComponent implements OnInit, OnChanges { group: helptextAcl.groupTooltip, }; - readonly userProvider = new UserComboboxProvider(this.userService); - readonly groupProvider = new GroupComboboxProvider(this.userService); - get isUserTag(): boolean { return this.form.value.tag === PosixAclTag.User; } diff --git a/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.html b/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.html index d1ecfa9f8cf..986532f55fb 100644 --- a/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.html +++ b/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.html @@ -17,11 +17,10 @@
- + > - + > { imports: [ CastPipe, ReactiveFormsModule, + IxUserComboboxComponent, + IxGroupComboboxComponent, ], declarations: [ MockComponent(EditPosixAceComponent), @@ -109,6 +113,8 @@ describe('DatasetAclEditorComponent', () => { mockProvider(UserService, { userQueryDsCache: () => of(), groupQueryDsCache: () => of(), + getUserByName: (username: string) => of({ username } as { username: string }), + getGroupByName: (groupName: string) => of({ group: groupName }), }), mockProvider(MatDialog, { open: jest.fn(() => ({ diff --git a/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.ts b/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.ts index 29b09d5a703..f0e28470e4b 100644 --- a/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.ts +++ b/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.ts @@ -12,10 +12,9 @@ import { AclType } from 'app/enums/acl-type.enum'; import { Role } from 'app/enums/role.enum'; import { helptextAcl } from 'app/helptext/storage/volumes/datasets/dataset-acl'; import { Acl } from 'app/interfaces/acl.interface'; -import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component'; import { CastPipe } from 'app/modules/pipes/cast/cast.pipe'; import { TestDirective } from 'app/modules/test-id/test.directive'; @@ -36,7 +35,6 @@ import { SelectPresetModalConfig, } from 'app/pages/datasets/modules/permissions/interfaces/select-preset-modal-config.interface'; import { DatasetAclEditorStore } from 'app/pages/datasets/modules/permissions/stores/dataset-acl-editor.store'; -import { UserService } from 'app/services/user.service'; import { AclEditorSaveControlsComponent } from './acl-editor-save-controls/acl-editor-save-controls.component'; @UntilDestroy() @@ -51,7 +49,8 @@ import { AclEditorSaveControlsComponent } from './acl-editor-save-controls/acl-e MatCardHeader, MatCardTitle, ReactiveFormsModule, - IxComboboxComponent, + IxUserComboboxComponent, + IxGroupComboboxComponent, IxCheckboxComponent, AclEditorListComponent, MatButton, @@ -73,7 +72,6 @@ export class DatasetAclEditorComponent implements OnInit { private route = inject(ActivatedRoute); private cdr = inject(ChangeDetectorRef); private matDialog = inject(MatDialog); - private userService = inject(UserService); private formBuilder = inject(NonNullableFormBuilder); datasetPath: string; @@ -97,8 +95,6 @@ export class DatasetAclEditorComponent implements OnInit { return Boolean(this.route.snapshot.queryParams['homeShare']); } - readonly userProvider = new UserComboboxProvider(this.userService); - readonly groupProvider = new GroupComboboxProvider(this.userService); readonly helptext = helptextAcl; protected readonly Role = Role; diff --git a/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.html b/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.html index 5f6b28888ed..4ca80bb9042 100644 --- a/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.html +++ b/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.html @@ -16,13 +16,12 @@
- + > - + > { { username: 'root' }, { username: 'games' }, ]), + getUserByName: (username: string) => of({ username } as { username: string }), + getGroupByName: (groupName: string) => of({ group: groupName }), }), mockProvider(DialogService, { confirm: jest.fn(() => of(true)), diff --git a/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.ts b/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.ts index c3e9b88e0d8..7aad4fae668 100644 --- a/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.ts +++ b/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.ts @@ -18,12 +18,11 @@ import { Role } from 'app/enums/role.enum'; import { helptextPermissions } from 'app/helptext/storage/volumes/datasets/dataset-permissions'; import { FilesystemSetPermParams } from 'app/interfaces/filesystem-stat.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; -import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; import { IxPermissionsComponent } from 'app/modules/forms/ix-forms/components/ix-permissions/ix-permissions.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxValidatorsService } from 'app/modules/forms/ix-forms/services/ix-validators.service'; import { FakeProgressBarComponent } from 'app/modules/loader/components/fake-progress-bar/fake-progress-bar.component'; @@ -32,7 +31,6 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; import { ApiService } from 'app/modules/websocket/api.service'; import { ErrorHandlerService } from 'app/services/errors/error-handler.service'; import { StorageService } from 'app/services/storage.service'; -import { UserService } from 'app/services/user.service'; @UntilDestroy() @Component({ @@ -48,7 +46,8 @@ import { UserService } from 'app/services/user.service'; MatCardContent, ReactiveFormsModule, IxFieldsetComponent, - IxComboboxComponent, + IxUserComboboxComponent, + IxGroupComboboxComponent, IxCheckboxComponent, IxPermissionsComponent, RequiresRolesDirective, @@ -70,7 +69,6 @@ export class DatasetTrivialPermissionsComponent implements OnInit { private storageService = inject(StorageService); private translate = inject(TranslateService); private dialog = inject(DialogService); - private userService = inject(UserService); private validatorService = inject(IxValidatorsService); private snackbar = inject(SnackbarService); @@ -99,9 +97,6 @@ export class DatasetTrivialPermissionsComponent implements OnInit { datasetPath: string; datasetId: string; - readonly userProvider = new UserComboboxProvider(this.userService); - readonly groupProvider = new GroupComboboxProvider(this.userService); - readonly tooltips = { user: helptextPermissions.userTooltip, applyUser: helptextPermissions.applyUser.tooltip, diff --git a/src/app/pages/services/components/service-smb/service-smb.component.html b/src/app/pages/services/components/service-smb/service-smb.component.html index a91e11ea1ea..6b0de2a96a0 100644 --- a/src/app/pages/services/components/service-smb/service-smb.component.html +++ b/src/app/pages/services/components/service-smb/service-smb.component.html @@ -92,19 +92,17 @@ [tooltip]="tooltips.multichannel | translate" > - + > - + > { userQueryDsCache: jest.fn(() => of([{ username: 'test-username', }])), + getUserByName: (username: string) => of({ username } as User), + getGroupByName: (groupName: string) => of({ group: groupName }), }), mockProvider(SlideInRef, slideInRef), mockAuth(), diff --git a/src/app/pages/services/components/service-smb/service-smb.component.ts b/src/app/pages/services/components/service-smb/service-smb.component.ts index 870c3f66d88..4c7c3ed1af3 100644 --- a/src/app/pages/services/components/service-smb/service-smb.component.ts +++ b/src/app/pages/services/components/service-smb/service-smb.component.ts @@ -14,17 +14,16 @@ import { choicesToOptions } from 'app/helpers/operators/options.operators'; import { mapToOptions } from 'app/helpers/options.helper'; import { helptextServiceSmb } from 'app/helptext/services/components/service-smb'; import { SmbConfigUpdate } from 'app/interfaces/smb-config.interface'; -import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; import { IxChipsComponent } from 'app/modules/forms/ix-forms/components/ix-chips/ix-chips.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxListItemComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list-item/ix-list-item.component'; import { IxListComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxValidatorsService } from 'app/modules/forms/ix-forms/services/ix-validators.service'; import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; @@ -33,7 +32,6 @@ import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service' import { TestDirective } from 'app/modules/test-id/test.directive'; import { ApiService } from 'app/modules/websocket/api.service'; import { ErrorHandlerService } from 'app/services/errors/error-handler.service'; -import { UserService } from 'app/services/user.service'; interface BindIp { bindIp: string; @@ -55,7 +53,8 @@ interface BindIp { IxChipsComponent, IxCheckboxComponent, IxSelectComponent, - IxComboboxComponent, + IxUserComboboxComponent, + IxGroupComboboxComponent, IxListComponent, IxListItemComponent, FormActionsComponent, @@ -71,7 +70,6 @@ export class ServiceSmbComponent implements OnInit { private errorHandler = inject(ErrorHandlerService); private fb = inject(FormBuilder); private translate = inject(TranslateService); - private userService = inject(UserService); private validatorsService = inject(IxValidatorsService); private snackbar = inject(SnackbarService); slideInRef = inject>(SlideInRef); @@ -131,8 +129,6 @@ export class ServiceSmbComponent implements OnInit { }; readonly unixCharsetOptions$ = this.api.call('smb.unixcharset_choices').pipe(choicesToOptions()); - readonly guestAccountProvider = new UserComboboxProvider(this.userService); - readonly adminGroupProvider = new GroupComboboxProvider(this.userService); readonly bindIpAddressOptions$ = combineLatest([ this.api.call('smb.bindip_choices').pipe(choicesToOptions()), diff --git a/src/app/pages/services/components/service-ssh/service-ssh.component.html b/src/app/pages/services/components/service-ssh/service-ssh.component.html index 87bd80b37e4..766522270c3 100644 --- a/src/app/pages/services/components/service-ssh/service-ssh.component.html +++ b/src/app/pages/services/components/service-ssh/service-ssh.component.html @@ -15,12 +15,11 @@ [tooltip]="tooltips.tcpport | translate" > - + > { mockProvider(FormErrorHandlerService), mockProvider(DialogService), mockProvider(SlideInRef, slideInRef), + mockProvider(UserService, { + groupQueryDsCache: jest.fn(() => of(fakeGroupDataSource)), + getGroupByName: jest.fn((groupName: string) => { + const existingGroup = fakeGroupDataSource.find((group) => group.group === groupName); + if (existingGroup) { + return of(existingGroup); + } + return of(null); + }), + getUserByName: jest.fn(() => of(null)), + }), mockAuth(), ], }); diff --git a/src/app/pages/services/components/service-ssh/service-ssh.component.ts b/src/app/pages/services/components/service-ssh/service-ssh.component.ts index c506a2c4f7d..dfda9bcf39b 100644 --- a/src/app/pages/services/components/service-ssh/service-ssh.component.ts +++ b/src/app/pages/services/components/service-ssh/service-ssh.component.ts @@ -1,10 +1,12 @@ -import { ChangeDetectionStrategy, Component, OnInit, signal, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, Component, OnInit, signal, inject, +} from '@angular/core'; import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService, TranslateModule } from '@ngx-translate/core'; -import { map, of } from 'rxjs'; +import { of } from 'rxjs'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { Role } from 'app/enums/role.enum'; import { SshSftpLogFacility, SshSftpLogLevel, SshWeakCipher } from 'app/enums/ssh.enum'; @@ -13,9 +15,8 @@ import { helptextServiceSsh } from 'app/helptext/services/components/service-ssh import { SshConfigUpdate } from 'app/interfaces/ssh-config.interface'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; -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 { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupChipsComponent } from 'app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; import { IxTextareaComponent } from 'app/modules/forms/ix-forms/components/ix-textarea/ix-textarea.component'; @@ -26,7 +27,6 @@ import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service' import { TestDirective } from 'app/modules/test-id/test.directive'; import { ApiService } from 'app/modules/websocket/api.service'; import { ErrorHandlerService } from 'app/services/errors/error-handler.service'; -import { UserService } from 'app/services/user.service'; @UntilDestroy() @Component({ @@ -41,7 +41,7 @@ import { UserService } from 'app/services/user.service'; ReactiveFormsModule, IxFieldsetComponent, IxInputComponent, - IxChipsComponent, + IxGroupChipsComponent, IxCheckboxComponent, IxSelectComponent, IxTextareaComponent, @@ -57,7 +57,6 @@ export class ServiceSshComponent implements OnInit { private errorHandler = inject(ErrorHandlerService); private formErrorHandler = inject(FormErrorHandlerService); private fb = inject(NonNullableFormBuilder); - private userService = inject(UserService); private translate = inject(TranslateService); private snackbar = inject(SnackbarService); slideInRef = inject>(SlideInRef); @@ -67,12 +66,6 @@ export class ServiceSshComponent implements OnInit { protected isFormLoading = signal(false); isBasicMode = true; - groupProvider: ChipsProvider = (query) => { - return this.userService.groupQueryDsCache(query).pipe( - map((groups) => groups.map((group) => group.group)), - ); - }; - form = this.fb.group({ tcpport: [null as number | null], password_login_groups: [[] as string[]], diff --git a/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.html b/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.html index ab6ac3f61d2..86d4f0cedc8 100644 --- a/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.html +++ b/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.html @@ -48,33 +48,29 @@ [label]="'Read Only' | translate" > - + > - + > - + > - + > { { group: 'sys' }, { group: 'operator' }, ]), + getUserByName: (username: string) => of({ username } as { username: string }), + getGroupByName: (groupName: string) => of({ group: groupName }), }), mockProvider(DialogService, { confirm: jest.fn(() => of(true)), diff --git a/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.ts b/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.ts index 1026a14e667..4bbf39fcc9d 100644 --- a/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.ts +++ b/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.ts @@ -20,19 +20,18 @@ import { helptextSharingNfs } from 'app/helptext/sharing'; import { DatasetCreate } from 'app/interfaces/dataset.interface'; import { NfsShare, NfsShareUpdate } from 'app/interfaces/nfs-share.interface'; import { Option } from 'app/interfaces/option.interface'; -import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { ExplorerCreateDatasetComponent } from 'app/modules/forms/ix-forms/components/ix-explorer/explorer-create-dataset/explorer-create-dataset.component'; import { IxExplorerComponent } from 'app/modules/forms/ix-forms/components/ix-explorer/ix-explorer.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxIpInputWithNetmaskComponent } from 'app/modules/forms/ix-forms/components/ix-ip-input-with-netmask/ix-ip-input-with-netmask.component'; import { IxListItemComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list-item/ix-list-item.component'; import { IxListComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxValidatorsService } from 'app/modules/forms/ix-forms/services/ix-validators.service'; import { ipv4or6cidrValidator } from 'app/modules/forms/ix-forms/validators/ip-validation'; @@ -44,7 +43,6 @@ import { ApiService } from 'app/modules/websocket/api.service'; import { getRootDatasetsValidator } from 'app/pages/sharing/utils/root-datasets-validator'; import { DatasetService } from 'app/services/dataset/dataset.service'; import { FilesystemService } from 'app/services/filesystem.service'; -import { UserService } from 'app/services/user.service'; import { checkIfServiceIsEnabled } from 'app/store/services/services.actions'; import { ServicesState } from 'app/store/services/services.reducer'; import { selectIsEnterprise } from 'app/store/system-info/system-info.selectors'; @@ -65,7 +63,8 @@ import { selectIsEnterprise } from 'app/store/system-info/system-info.selectors' ExplorerCreateDatasetComponent, IxInputComponent, IxCheckboxComponent, - IxComboboxComponent, + IxUserComboboxComponent, + IxGroupComboboxComponent, IxSelectComponent, IxListComponent, IxListItemComponent, @@ -80,7 +79,6 @@ import { selectIsEnterprise } from 'app/store/system-info/system-info.selectors' export class NfsFormComponent implements OnInit { private api = inject(ApiService); private formBuilder = inject(FormBuilder); - private userService = inject(UserService); private translate = inject(TranslateService); private filesystemService = inject(FilesystemService); private formErrorHandler = inject(FormErrorHandlerService); @@ -131,8 +129,6 @@ export class NfsFormComponent implements OnInit { protected readonly requiredRoles = [Role.SharingNfsWrite, Role.SharingWrite]; readonly helptext = helptextSharingNfs; - readonly userProvider = new UserComboboxProvider(this.userService); - readonly groupProvider = new GroupComboboxProvider(this.userService); readonly treeNodeProvider = this.filesystemService.getFilesystemNodeProvider({ directoriesOnly: true }); readonly isEnterprise = toSignal(this.store$.select(selectIsEnterprise)); diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.html b/src/app/pages/sharing/smb/smb-form/smb-form.component.html index 61e9634dd23..5b6bc4613c6 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.html +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.html @@ -137,21 +137,17 @@ @if (form.value.audit.enable) { - + > - + > } diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts b/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts index 1a483e5ca55..4b1ae8b4613 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts @@ -9,7 +9,7 @@ import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectat import { Store } from '@ngrx/store'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { MockComponent } from 'ng-mocks'; -import { of, throwError } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; import { GiB } from 'app/constants/bytes.constant'; import { fakeSuccessfulJob } from 'app/core/testing/utils/fake-job.utils'; import { mockApi, mockCall, mockJob } from 'app/core/testing/utils/mock-api.utils'; @@ -18,6 +18,7 @@ import { ServiceName } from 'app/enums/service-name.enum'; import { ServiceStatus } from 'app/enums/service-status.enum'; import { helptextSharingSmb } from 'app/helptext/sharing'; import { JsonRpcError } from 'app/interfaces/api-message.interface'; +import { DsUncachedGroup } from 'app/interfaces/ds-cache.interface'; import { FileSystemStat } from 'app/interfaces/filesystem-stat.interface'; import { Group } from 'app/interfaces/group.interface'; import { Service } from 'app/interfaces/service.interface'; @@ -47,6 +48,7 @@ import { RestartSmbDialog } from 'app/pages/sharing/smb/smb-form/restart-smb-dia import { SmbUsersWarningComponent } from 'app/pages/sharing/smb/smb-form/smb-users-warning/smb-users-warning.component'; import { ApiCallError } from 'app/services/errors/error.classes'; import { FilesystemService } from 'app/services/filesystem.service'; +import { UserService } from 'app/services/user.service'; import { AppState } from 'app/store'; import { checkIfServiceIsEnabled } from 'app/store/services/services.actions'; import { selectServices } from 'app/store/services/services.selectors'; @@ -113,6 +115,7 @@ describe('SmbFormComponent', () => { mockAuth(), mockApi([ mockCall('group.query', [{ id: 1, group: 'test', builtin: false }] as Group[]), + mockCall('group.get_group_obj', { gr_gid: 1000, gr_name: 'test', gr_mem: [] }), mockCall('sharing.smb.create', { ...existingShare }), mockCall('sharing.smb.update', { ...existingShare }), mockCall('sharing.smb.share_precheck', null), @@ -163,6 +166,16 @@ describe('SmbFormComponent', () => { mockProvider(FormErrorHandlerService, { handleValidationErrors: jest.fn(), }), + mockProvider(UserService, { + groupQueryDsCache: jest.fn(() => of([{ group: 'test', gid: 1 }])), + getGroupByName: jest.fn((groupName: string) => { + if (groupName === 'test') { + return of({ group: 'test', gid: 1 }); + } + return of(null); + }), + getUserByName: jest.fn(() => of(null)), + }), ], }); @@ -1184,6 +1197,164 @@ describe('SmbFormComponent', () => { expect(errorElement).toBeTruthy(); expect(errorElement?.textContent).toContain('At least one group must be specified'); }); + + it('should show error when non-existent group is entered in watch list', fakeAsync(async () => { + // Mock API to return error for non-existent group + const userService = spectator.inject(UserService); + jest.spyOn(userService, 'getGroupByName').mockReturnValue(throwError(() => new Error('Group not found'))); + + // Fill in required fields and enable audit logging + await form.fillForm({ + Path: '/mnt/pool123/test', + Name: 'TestShare', + Purpose: 'Default Share', + 'Enable Logging': true, + }); + + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Manually add a non-existent group using the form control + const auditGroup = (spectator.component as unknown as { form: FormGroup }).form.controls.audit as FormGroup; + auditGroup.controls.watch_list.setValue(['nonexistent']); + auditGroup.controls.watch_list.markAsTouched(); + auditGroup.controls.watch_list.updateValueAndValidity(); + + // Wait for async validation and debounce + spectator.tick(600); + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Verify error message is displayed + const errorElement = spectator.query('ix-errors mat-error'); + expect(errorElement?.textContent).toContain('The following groups do not exist: nonexistent'); + + // Verify save button is disabled + const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' })); + expect(await saveButton.isDisabled()).toBe(true); + })); + + it('should show error when non-existent group is entered in ignore list', fakeAsync(async () => { + // Mock API to return error for non-existent group + const userService = spectator.inject(UserService); + jest.spyOn(userService, 'getGroupByName').mockReturnValue(throwError(() => new Error('Group not found'))); + + // Fill in required fields and enable audit logging + await form.fillForm({ + Path: '/mnt/pool123/test', + Name: 'TestShare', + Purpose: 'Default Share', + 'Enable Logging': true, + }); + + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Manually add a non-existent group using the form control + const auditGroup = (spectator.component as unknown as { form: FormGroup }).form.controls.audit as FormGroup; + auditGroup.controls.ignore_list.setValue(['nonexistent']); + auditGroup.controls.ignore_list.markAsTouched(); + auditGroup.controls.ignore_list.updateValueAndValidity(); + + // Wait for async validation and debounce + spectator.tick(600); + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Verify error message is displayed + const errorElement = spectator.query('ix-errors mat-error'); + expect(errorElement?.textContent).toContain('The following groups do not exist: nonexistent'); + + // Verify save button is disabled + const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' })); + expect(await saveButton.isDisabled()).toBe(true); + })); + + it('should pass validation when all entered groups exist', fakeAsync(async () => { + // Mock API to return success for existing groups + const userService = spectator.inject(UserService); + jest.spyOn(userService, 'getGroupByName').mockReturnValue(of({ + gr_gid: 1000, + gr_name: 'test', + gr_mem: [], + } as DsUncachedGroup)); + + // Fill in required fields and enable audit logging + await form.fillForm({ + Path: '/mnt/pool123/test', + Name: 'TestShare', + Purpose: 'Default Share', + 'Enable Logging': true, + }); + + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Add an existing group + const auditGroup = (spectator.component as unknown as { form: FormGroup }).form.controls.audit as FormGroup; + auditGroup.controls.watch_list.setValue(['test']); + auditGroup.controls.watch_list.markAsTouched(); + auditGroup.controls.watch_list.updateValueAndValidity(); + + // Wait for async validation and debounce + spectator.tick(600); + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Verify no error message is displayed + const errorElement = spectator.query('ix-errors mat-error'); + expect(errorElement).toBeFalsy(); + + // Verify save button is enabled + const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' })); + expect(await saveButton.isDisabled()).toBe(false); + })); + + it('should disable save button during async validation', fakeAsync(async () => { + // Mock API with a delayed response to catch the PENDING state + const userService = spectator.inject(UserService); + const delayedObservable$ = new Subject(); + jest.spyOn(userService, 'getGroupByName').mockReturnValue(delayedObservable$.asObservable()); + + // Fill in required fields and enable audit logging + await form.fillForm({ + Path: '/mnt/pool123/test', + Name: 'TestShare', + Purpose: 'Default Share', + 'Enable Logging': true, + }); + + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Add a group to trigger async validation + const auditGroup = (spectator.component as unknown as { form: FormGroup }).form.controls.audit as FormGroup; + auditGroup.controls.watch_list.setValue(['test']); + auditGroup.controls.watch_list.markAsTouched(); + auditGroup.controls.watch_list.updateValueAndValidity(); + + // Wait for debounce + spectator.tick(600); + spectator.detectChanges(); + + // Verify save button is disabled while validation is pending + const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' })); + expect(await saveButton.isDisabled()).toBe(true); + + // Complete the async validation + delayedObservable$.next({ + gr_gid: 1000, + gr_name: 'test', + gr_mem: [], + } as DsUncachedGroup); + delayedObservable$.complete(); + + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Verify save button is now enabled + expect(await saveButton.isDisabled()).toBe(false); + })); }); describe('Dataset Naming Schema null value', () => { diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts index 6c5aa42b269..b0bbe31b57e 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts @@ -13,9 +13,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { isEqual } from 'lodash-es'; -import { - endWith, Observable, of, -} from 'rxjs'; +import { endWith, Observable, of } from 'rxjs'; import { debounceTime, filter, map, switchMap, take, tap, } from 'rxjs/operators'; @@ -41,12 +39,12 @@ import { ExplorerNodeData } from 'app/interfaces/tree-node.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; -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 { IxErrorsComponent } from 'app/modules/forms/ix-forms/components/ix-errors/ix-errors.component'; import { ExplorerCreateDatasetComponent } from 'app/modules/forms/ix-forms/components/ix-explorer/explorer-create-dataset/explorer-create-dataset.component'; import { IxExplorerComponent } from 'app/modules/forms/ix-forms/components/ix-explorer/ix-explorer.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupChipsComponent } from 'app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; import { WarningComponent } from 'app/modules/forms/ix-forms/components/warning/warning.component'; @@ -68,7 +66,6 @@ import { getRootDatasetsValidator } from 'app/pages/sharing/utils/root-datasets- import { DatasetService } from 'app/services/dataset/dataset.service'; import { ErrorHandlerService } from 'app/services/errors/error-handler.service'; import { FilesystemService } from 'app/services/filesystem.service'; -import { UserService } from 'app/services/user.service'; import { checkIfServiceIsEnabled } from 'app/store/services/services.actions'; import { ServicesState } from 'app/store/services/services.reducer'; import { selectService } from 'app/store/services/services.selectors'; @@ -91,6 +88,7 @@ import { selectIsEnterprise } from 'app/store/system-info/system-info.selectors' IxSelectComponent, IxCheckboxComponent, IxChipsComponent, + IxGroupChipsComponent, IxErrorsComponent, FormActionsComponent, RequiresRolesDirective, @@ -111,7 +109,6 @@ export class SmbFormComponent implements OnInit, AfterViewInit { private datasetService = inject(DatasetService); private translate = inject(TranslateService); private router = inject(Router); - private userService = inject(UserService); protected loader = inject(LoaderService); private errorHandler = inject(ErrorHandlerService); private formErrorHandler = inject(FormErrorHandlerService); @@ -146,12 +143,6 @@ export class SmbFormComponent implements OnInit, AfterViewInit { private wasStripAclWarningShown = false; private smbConfig = signal(null); - protected groupProvider: ChipsProvider = (query) => { - return this.userService.groupQueryDsCache(query).pipe( - map((groups) => groups.map((group) => group.group)), - ); - }; - title: string = helptextSharingSmb.formTitleAdd; createDatasetProps: Omit = { @@ -169,7 +160,14 @@ export class SmbFormComponent implements OnInit, AfterViewInit { } get isAsyncValidatorPending(): boolean { - return this.form.controls.name.status === 'PENDING' && this.form.controls.name.touched; + const nameControl = this.form.controls.name; + const auditGroup = this.form.controls.audit; + const watchListControl = auditGroup.controls.watch_list; + const ignoreListControl = auditGroup.controls.ignore_list; + + return (nameControl.status === 'PENDING' && nameControl.touched) + || (watchListControl.status === 'PENDING' && watchListControl.touched) + || (ignoreListControl.status === 'PENDING' && ignoreListControl.touched); } readonly treeNodeProvider = this.filesystemService.getFilesystemNodeProvider({ diff --git a/src/app/pages/system/advanced/cron/cron-form/cron-form.component.html b/src/app/pages/system/advanced/cron/cron-form/cron-form.component.html index c6553d24b28..1162c1ff23e 100644 --- a/src/app/pages/system/advanced/cron/cron-form/cron-form.component.html +++ b/src/app/pages/system/advanced/cron/cron-form/cron-form.component.html @@ -16,13 +16,12 @@ [tooltip]="tooltips.command | translate" > - + > { { username: 'root' }, { username: 'steven' }, ] as User[]), + getUserByName: (username: string) => of({ username } as User), }), mockProvider(SlideInRef, componentRef), mockAuth(), diff --git a/src/app/pages/system/advanced/cron/cron-form/cron-form.component.ts b/src/app/pages/system/advanced/cron/cron-form/cron-form.component.ts index d85f2c1a211..5582098817b 100644 --- a/src/app/pages/system/advanced/cron/cron-form/cron-form.component.ts +++ b/src/app/pages/system/advanced/cron/cron-form/cron-form.component.ts @@ -9,12 +9,11 @@ import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-r import { Role } from 'app/enums/role.enum'; import { helptextCron } from 'app/helptext/system/cron-form'; import { Cronjob, CronjobUpdate } from 'app/interfaces/cronjob.interface'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { SchedulerComponent } from 'app/modules/scheduler/components/scheduler/scheduler.component'; import { crontabToSchedule } from 'app/modules/scheduler/utils/crontab-to-schedule.utils'; @@ -25,7 +24,6 @@ import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; import { TestDirective } from 'app/modules/test-id/test.directive'; import { ApiService } from 'app/modules/websocket/api.service'; -import { UserService } from 'app/services/user.service'; @UntilDestroy() @Component({ @@ -39,7 +37,7 @@ import { UserService } from 'app/services/user.service'; ReactiveFormsModule, IxFieldsetComponent, IxInputComponent, - IxComboboxComponent, + IxUserComboboxComponent, SchedulerComponent, IxCheckboxComponent, FormActionsComponent, @@ -55,7 +53,6 @@ export class CronFormComponent implements OnInit { private translate = inject(TranslateService); private errorHandler = inject(FormErrorHandlerService); private snackbar = inject(SnackbarService); - private userService = inject(UserService); slideInRef = inject>(SlideInRef); protected readonly requiredRoles = [Role.SystemCronWrite]; @@ -90,8 +87,6 @@ export class CronFormComponent implements OnInit { stderr: helptextCron.stderrTooltip, }; - readonly userProvider = new UserComboboxProvider(this.userService); - private editingCron: Cronjob | undefined; constructor() { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index e9ff432c97c..82668984e9d 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -646,6 +646,7 @@ "At least 1 GPU is required by the host for its functions.": "", "At least 1 vdev is required to make an update to the pool.": "", "At least one module must be defined in rsyncd.conf(5) of the rsync server or in the Rsync Modules of another system.": "", + "The following groups do not exist: {groups}": "", "At least one pool must be available to use apps": "", "At least one spare is recommended for dRAID. Spares cannot be added later.": "", "Atleast {min} disk(s) are required for {vdevType} vdevs": "",