diff --git a/src/app/modules/ix-forms/components/ix-chips/ix-chips.component.html b/src/app/modules/ix-forms/components/ix-chips/ix-chips.component.html index 6328ed8479811f8ffa119cf816d30738667d2571..7d0d26539bba344983747bc5bb2eac33716123eb 100644 --- a/src/app/modules/ix-forms/components/ix-chips/ix-chips.component.html +++ b/src/app/modules/ix-forms/components/ix-chips/ix-chips.component.html @@ -14,13 +14,13 @@ [required]="required" > <mat-chip-row - *ngFor="let item of values" + *ngFor="let label of labels" class="chip" [disabled]="false" [removable]="!isDisabled" - (removed)="onRemove(item)" + (removed)="onRemove(label)" > - {{ item }} + {{ label }} <ix-icon *ngIf="!isDisabled" name="cancel" matChipRemove></ix-icon> </mat-chip-row> diff --git a/src/app/modules/ix-forms/components/ix-chips/ix-chips.component.scss b/src/app/modules/ix-forms/components/ix-chips/ix-chips.component.scss index e07d50cbb7ade9e1587b834093d053cbcba933ca..cdda15130cff727650022dc3d4261dd51e001bc1 100644 --- a/src/app/modules/ix-forms/components/ix-chips/ix-chips.component.scss +++ b/src/app/modules/ix-forms/components/ix-chips/ix-chips.component.scss @@ -3,7 +3,7 @@ display: block; margin-bottom: 12px; - margin-top: 18px; + margin-top: 12px; padding: 8px 0; .mat-mdc-chip-grid { diff --git a/src/app/modules/ix-forms/components/ix-chips/ix-chips.component.ts b/src/app/modules/ix-forms/components/ix-chips/ix-chips.component.ts index e3b955f38d10a0e977f96d24b02f90167f61470f..c81fc7543a5968867bbb004bf123dcd6d45fe1f6 100644 --- a/src/app/modules/ix-forms/components/ix-chips/ix-chips.component.ts +++ b/src/app/modules/ix-forms/components/ix-chips/ix-chips.component.ts @@ -9,13 +9,14 @@ import { ViewChild, } from '@angular/core'; import { ControlValueAccessor, NgControl } from '@angular/forms'; -import { UntilDestroy } from '@ngneat/until-destroy'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { fromEvent, merge, Observable, Subject, } from 'rxjs'; import { debounceTime, distinctUntilChanged, startWith, switchMap, } from 'rxjs/operators'; +import { Option } from 'app/interfaces/option.interface'; import { ChipsProvider } from 'app/modules/ix-forms/components/ix-chips/chips-provider'; @UntilDestroy() @@ -33,12 +34,28 @@ export class IxChipsComponent implements OnChanges, ControlValueAccessor { @Input() required: boolean; @Input() allowNewEntries = true; @Input() autocompleteProvider: ChipsProvider; + @Input() options: Observable<Option[]>; + @Input() resolveValue = false; @ViewChild('chipInput', { static: true }) chipInput: ElementRef<HTMLInputElement>; suggestions$: Observable<string[]>; values: string[] = []; isDisabled = false; + private _options: Option[] = []; + + get labels(): string[] { + if (!this.resolveValue) { + return this.values; + } + + return this.values?.map((value) => { + if (this.resolveValue && this._options?.length) { + return this._options.find((option) => option.value === parseInt(value))?.label; + } + return value; + }).filter(Boolean); + } inputReset$ = new Subject<void>(); @@ -56,6 +73,7 @@ export class IxChipsComponent implements OnChanges, ControlValueAccessor { ngOnChanges(): void { this.setAutocomplete(); + this.setOptions(); } writeValue(value: string[]): void { @@ -77,16 +95,26 @@ export class IxChipsComponent implements OnChanges, ControlValueAccessor { } onRemove(itemToRemove: string): void { - const updatedValues = this.values.filter((value) => value !== itemToRemove); + if (this.resolveValue && this._options?.length) { + itemToRemove = this._options.find((option) => option.label === itemToRemove)?.value.toString(); + } + const updatedValues = this.values.filter((value) => String(value) !== String(itemToRemove)); this.updateValues(updatedValues); } onAdd(value: string): void { - const newValue = (value || '').trim(); + let newValue = (value || '')?.trim(); if (!newValue || this.values.includes(newValue)) { return; } + if (this.resolveValue && this._options?.length) { + const newOption = this._options.find((option) => option.label === newValue); + if (newOption) { + newValue = newOption.value as string; + } + } + this.clearInput(); this.updateValues([...this.values, newValue]); } @@ -99,6 +127,17 @@ export class IxChipsComponent implements OnChanges, ControlValueAccessor { this.onAdd(this.chipInput.nativeElement.value); } + private setOptions(): void { + if (!this.resolveValue) { + this.options = null; + return; + } + + this.options?.pipe(untilDestroyed(this)).subscribe((options) => { + this._options = options; + }); + } + private setAutocomplete(): void { if (!this.autocompleteProvider) { this.suggestions$ = null; @@ -122,7 +161,7 @@ export class IxChipsComponent implements OnChanges, ControlValueAccessor { private updateValues(updatedValues: string[]): void { this.values = updatedValues; - this.onChange(updatedValues); + this.onChange(this.values); this.onTouch(); } diff --git a/src/app/pages/account/users/user-form/user-form.component.html b/src/app/pages/account/users/user-form/user-form.component.html index 25c63fda2ae107422721acbaebcc86e4c0dfe53b..f737b06d498105710898837ff553bf11338fede8 100644 --- a/src/app/pages/account/users/user-form/user-form.component.html +++ b/src/app/pages/account/users/user-form/user-form.component.html @@ -72,13 +72,14 @@ </div> <div fxFlex="50%"> - <ix-select + <ix-chips formControlName="groups" [label]="'Auxiliary Groups' | translate" [tooltip]="tooltips.groups | translate" [options]="groupOptions$" - [multiple]="true" - ></ix-select> + [resolveValue]="true" + [autocompleteProvider]="autocompleteProvider" + ></ix-chips> <ix-combobox formControlName="group" diff --git a/src/app/pages/account/users/user-form/user-form.component.spec.ts b/src/app/pages/account/users/user-form/user-form.component.spec.ts index cc439e7abf236988fdc08c65b4418e2c2a3802e3..20be52f7a477b8cb324f16a02c5e8bf287ee3c80 100644 --- a/src/app/pages/account/users/user-form/user-form.component.spec.ts +++ b/src/app/pages/account/users/user-form/user-form.component.spec.ts @@ -28,6 +28,14 @@ import { WebSocketService } from 'app/services/ws.service'; import { UserFormComponent } from './user-form.component'; describe('UserFormComponent', () => { + const mockGroups = [{ + id: 101, + group: 'test-group', + }, { + id: 102, + group: 'mock-group', + }] as Group[]; + const mockUser = { id: 69, uid: 1004, @@ -70,13 +78,7 @@ describe('UserFormComponent', () => { '/usr/bin/zsh': 'zsh', } as Choices), mockCall('user.get_next_uid', 1234), - mockCall('group.query', [{ - id: 101, - group: 'test-group', - }, { - id: 102, - group: 'mock-group', - }] as Group[]), + mockCall('group.query', mockGroups), mockCall('sharing.smb.query', [{ path: '/mnt/users' }] as SmbShare[]), ]), mockProvider(DialogService, { @@ -88,7 +90,9 @@ describe('UserFormComponent', () => { downloadBlob: jest.fn(), }), mockProvider(FormErrorHandlerService), - mockProvider(UserService), + mockProvider(UserService, { + groupQueryDsCache: jest.fn(() => of(mockGroups)), + }), mockProvider(FilesystemService, { getFilesystemNodeProvider: jest.fn(() => of()), }), @@ -247,7 +251,7 @@ describe('UserFormComponent', () => { it('sends an update payload to websocket and closes modal when save is pressed', async () => { const form = await loader.getHarness(IxFormHarness); await form.fillForm({ - 'Auxiliary Groups': ['mock-group'], + 'Auxiliary Groups': ['mock-group', 'test-group'], 'Full Name': 'updated', 'Home Directory': '/home/updated', 'Primary Group': 'mock-group', @@ -273,13 +277,13 @@ describe('UserFormComponent', () => { 69, { home: '/home/updated', home_create: true }, ]); - expect(ws.call).toHaveBeenCalledWith('user.update', [ + expect(ws.call).toHaveBeenLastCalledWith('user.update', [ 69, { email: null, full_name: 'updated', group: 102, - groups: [102], + groups: [102, 101], home_mode: '755', locked: true, password_disabled: false, diff --git a/src/app/pages/account/users/user-form/user-form.component.ts b/src/app/pages/account/users/user-form/user-form.component.ts index 524b8ed8976ec8a62a34083d9eb95fd89e497c87..cb0a9ee676ee2d08a620a11b27c357417efb9020 100644 --- a/src/app/pages/account/users/user-form/user-form.component.ts +++ b/src/app/pages/account/users/user-form/user-form.component.ts @@ -19,6 +19,7 @@ import helptext from 'app/helptext/account/user-form'; import { Option } from 'app/interfaces/option.interface'; import { User, UserUpdate } from 'app/interfaces/user.interface'; import { SimpleAsyncComboboxProvider } from 'app/modules/ix-forms/classes/simple-async-combobox-provider'; +import { ChipsProvider } from 'app/modules/ix-forms/components/ix-chips/chips-provider'; import { IxSlideInRef } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in-ref'; import { SLIDE_IN_DATA } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in.token'; import { FormErrorHandlerService } from 'app/modules/ix-forms/services/form-error-handler.service'; @@ -47,7 +48,6 @@ const defaultHomePath = '/nonexistent'; export class UserFormComponent implements OnInit { isFormLoading = false; subscriptions: Subscription[] = []; - homeModeOldValue = ''; get isNewUser(): boolean { @@ -125,6 +125,11 @@ export class UserFormComponent implements OnInit { shellOptions$: Observable<Option[]>; readonly treeNodeProvider = this.filesystemService.getFilesystemNodeProvider({ directoriesOnly: true }); readonly groupProvider = new SimpleAsyncComboboxProvider(this.groupOptions$); + autocompleteProvider: ChipsProvider = (query) => { + return this.userService.groupQueryDsCache(query).pipe( + map((groups) => groups.map((group) => group.group)), + ); + }; get homeCreateWarning(): string { const homeCreate = this.form.value.home_create; @@ -170,6 +175,7 @@ export class UserFormComponent implements OnInit { private storageService: StorageService, private store$: Store<AppState>, private dialog: DialogService, + private userService: UserService, @Inject(SLIDE_IN_DATA) private editingUser: User, ) { this.form.controls.smb.errors$.pipe( @@ -315,6 +321,7 @@ export class UserFormComponent implements OnInit { request$.pipe( switchMap((id) => nextRequest$ || of(id)), + filter(Boolean), switchMap((id) => this.ws.call('user.query', [[['id', '=', id]]])), map((users) => users[0]), untilDestroyed(this),