Unverified Commit 9fda8d2e authored by Denys Butenko's avatar Denys Butenko
Browse files

NAS-125536 / 24.04 / Replace Auxiliary Groups with chips input (#9296)

* NAS-125536: WIP

* NAS-125536: Replace Auxiliary Groups with chips input

* NAS-125536: Replace Auxiliary Groups with chips input

* NAS-125536: Replace Auxiliary Groups with chips input

* NAS-125536: Replace Auxiliary Groups with chips input

* NAS-125536: Replace Auxiliary Groups with chips input
No related merge requests found
Showing with 74 additions and 23 deletions
+74 -23
......@@ -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>
......
......@@ -3,7 +3,7 @@
display: block;
margin-bottom: 12px;
margin-top: 18px;
margin-top: 12px;
padding: 8px 0;
.mat-mdc-chip-grid {
......
......@@ -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();
}
......
......@@ -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"
......
......@@ -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,
......
......@@ -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),
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment