From d246e7ec15d50355d9c219dfbcf5e4630fe57b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Gerrit=20G=C3=B6bel?= <86782124+jggoebel@users.noreply.github.com> Date: Tue, 20 Jun 2023 17:03:27 +0200 Subject: [PATCH] Typed forms for settings (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add typed forms * Get the form running * Comments * Spacing * Formatting * Current state: Multiple FormGroupTypes * Add vertical tabs * Add type float, general improvements * support multiple categories * Remove multiple categories again, introduce weight * introduce representation * Apply new structure * Form validation * Display detailed error messages * Retreive settings * Retreiving settings for given scope * list scopes and fetch settings based on them * Load single settings + motd for admin ui * Better fetching of single settings * Remove old code * rename setting to motd-admin-ui --------- Co-authored-by: Jan-Gerrit Göbel --- src/app/app-routing.module.ts | 5 + src/app/app.module.ts | 13 +- .../configuration.component.html | 4 +- .../settings/settings.component.html | 65 +++++ .../settings/settings.component.scss | 6 + .../settings/settings.component.ts | 91 ++++++ .../vm-dashboard/vm-dashboard.component.ts | 119 ++++---- src/app/data/settings.service.ts | 12 +- src/app/data/typedSettings.service.ts | 202 ++++++++++++++ .../new-scheduled-event.component.ts | 2 - src/app/home/home.component.html | 4 +- src/app/home/home.component.scss | 16 +- src/app/home/home.component.ts | 32 ++- src/app/typed-form/TypedInput.ts | 260 ++++++++++++++++++ src/app/typed-form/typed-form.component.html | 46 ++++ src/app/typed-form/typed-form.component.scss | 5 + src/app/typed-form/typed-form.component.ts | 213 ++++++++++++++ .../typed-input-field.component.html | 157 +++++++++++ .../typed-input-field.component.scss | 8 + .../typed-form/typed-input-field.component.ts | 25 ++ src/app/typed-form/typed-input.component.html | 124 +++++++++ src/app/typed-form/typed-input.component.scss | 13 + src/app/typed-form/typed-input.component.ts | 104 +++++++ 23 files changed, 1447 insertions(+), 79 deletions(-) create mode 100644 src/app/configuration/settings/settings.component.html create mode 100644 src/app/configuration/settings/settings.component.scss create mode 100644 src/app/configuration/settings/settings.component.ts create mode 100644 src/app/data/typedSettings.service.ts create mode 100644 src/app/typed-form/TypedInput.ts create mode 100644 src/app/typed-form/typed-form.component.html create mode 100644 src/app/typed-form/typed-form.component.scss create mode 100644 src/app/typed-form/typed-form.component.ts create mode 100644 src/app/typed-form/typed-input-field.component.html create mode 100644 src/app/typed-form/typed-input-field.component.scss create mode 100644 src/app/typed-form/typed-input-field.component.ts create mode 100644 src/app/typed-form/typed-input.component.html create mode 100644 src/app/typed-form/typed-input.component.scss create mode 100644 src/app/typed-form/typed-input.component.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index da4a836d..9c4d0605 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -17,6 +17,7 @@ import { StepComponent } from './step/step-component/step.component'; import { TerminalComponent } from './step/terminal/terminal.component'; import { RolesComponent } from './configuration/roles/roles/roles.component'; import { SessionStatisticsComponent } from './session-statistics/session-statistics.component'; +import {SettingsComponent} from './configuration/settings/settings.component'; const routes: Routes = [ {path: '', redirectTo: '/home', pathMatch: 'full'}, @@ -79,6 +80,10 @@ const routes: Routes = [ AuthGuard ], children: [ + { + path: 'settings', + component: SettingsComponent + }, { path: 'environments', component: EnvironmentsComponent diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 8882aaef..6e38756f 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -89,7 +89,11 @@ import { FilterScenariosComponent } from './filter-scenarios/filter-scenarios.co import { MDEditorComponent } from './scenario/md-editor/md-editor.component'; import { CodeWithSyntaxHighlightingComponent } from './configuration/code-with-syntax-highlighting/code-with-syntax-highlighting.component'; import { ResizableTextAreaDirective } from './directives/resizable-text-area.directive'; - +import { TypedFormComponent } from './typed-form/typed-form.component'; +import { SettingsComponent } from './configuration/settings/settings.component'; +import { TypedInputComponent } from './typed-form/typed-input.component'; +import { TypedInputFieldComponent } from './typed-form/typed-input-field.component'; +import { TypedSettingsService } from './data/typedSettings.service'; const appInitializerFn = (appConfig: AppConfigService) => { return () => { @@ -164,7 +168,11 @@ export function jwtOptionsFactory() { VMTemplateServiceFormComponent, FilterScenariosComponent, CodeWithSyntaxHighlightingComponent, - ResizableTextAreaDirective + ResizableTextAreaDirective, + TypedFormComponent, + TypedInputComponent, + TypedInputFieldComponent, + SettingsComponent, ], imports: [ BrowserModule, @@ -209,6 +217,7 @@ export function jwtOptionsFactory() { AppConfigService, ProgressService, PredefinedServiceService, + TypedSettingsService, { provide: APP_INITIALIZER, useFactory: appInitializerFn, diff --git a/src/app/configuration/configuration.component.html b/src/app/configuration/configuration.component.html index 964f1358..a79d9007 100644 --- a/src/app/configuration/configuration.component.html +++ b/src/app/configuration/configuration.component.html @@ -3,6 +3,8 @@
- \ No newline at end of file + diff --git a/src/app/configuration/settings/settings.component.html b/src/app/configuration/settings/settings.component.html new file mode 100644 index 00000000..c0365d2c --- /dev/null +++ b/src/app/configuration/settings/settings.component.html @@ -0,0 +1,65 @@ +
+
+

+ Settings + {{ this.selectedScope?.displayName ?? "scope" }} +

+
+
+ + +
+ Please wait... + Scopes are being loaded... +
+
+ + + + + + + + {{ sc.displayName }} + + + + + +
+ Please wait... + Settings are being loaded... +
+
+ + +
+ No settings available for scope + {{ this.selectedScope.displayName }}. +
+
+
diff --git a/src/app/configuration/settings/settings.component.scss b/src/app/configuration/settings/settings.component.scss new file mode 100644 index 00000000..47fba5e2 --- /dev/null +++ b/src/app/configuration/settings/settings.component.scss @@ -0,0 +1,6 @@ +.scope { + padding: 5px 10px 5px 10px; + color: white; + background-color: #3fc5f0; + border-radius: 20px; +} diff --git a/src/app/configuration/settings/settings.component.ts b/src/app/configuration/settings/settings.component.ts new file mode 100644 index 00000000..0f96d731 --- /dev/null +++ b/src/app/configuration/settings/settings.component.ts @@ -0,0 +1,91 @@ +import { Component, ViewChild } from '@angular/core'; +import { TypedInput, FormGroupType } from '../../typed-form/TypedInput'; +import { + PreparedScope, + TypedSettingsService, +} from 'src/app/data/typedSettings.service'; +import { AlertComponent } from 'src/app/alert/alert.component'; + +@Component({ + selector: 'app-settings', + templateUrl: './settings.component.html', + styleUrls: ['./settings.component.scss'], +}) +export class SettingsComponent { + public settings: TypedInput[] = []; + public updatedSettings: TypedInput[] = []; + public hasChanges: boolean = false; + public valid: boolean = true; + public scopes: PreparedScope[] = []; + public selectedScope: PreparedScope; + public loading: boolean = true; + public scopesLoading: boolean = true; + readonly FormGroupType = FormGroupType; // Reference to TypedInputTypes enum for template use + + @ViewChild('alert') alert: AlertComponent; + + private alertTime = 2000; + private alertErrorTime = 10000; + + constructor(public typedSettingsService: TypedSettingsService) { + this.getScopes(); + } + + onFormChange(data: TypedInput[]) { + this.updatedSettings = data; + this.hasChanges = true; + } + + changeFormValidity(valid: boolean) { + this.valid = valid; + } + + onSubmit() { + if (!this.updatedSettings) { + return; + } + console.log(this.updatedSettings); + this.typedSettingsService.updateCollection(this.updatedSettings).subscribe( + (resp) => { + console.log(resp); + this.hasChanges = false; + this.alert.success( + 'Settings successfully saved', + false, + this.alertTime + ); + }, + (err) => { + this.alert.danger(err.error.message, true, this.alertErrorTime); + } + ); + } + + setScope(scope: PreparedScope) { + this.loading = true; + this.selectedScope = scope; + this.typedSettingsService.list(this.selectedScope.name).subscribe( + (typedSettings) => { + this.settings = typedSettings; + this.loading = false; + }, + (err) => { + this.alert.danger(err.error.message, true, this.alertErrorTime); + } + ); + } + + getScopes() { + this.scopes = []; + this.typedSettingsService.listScopes().subscribe( + (scopes) => { + this.scopes = scopes; + this.scopesLoading = false; + this.setScope(this.scopes[0]); + }, + (err) => { + this.alert.danger(err.error.message, true, this.alertErrorTime); + } + ); + } +} diff --git a/src/app/dashboards/vm-dashboard/vm-dashboard.component.ts b/src/app/dashboards/vm-dashboard/vm-dashboard.component.ts index 5f491ed6..00a90272 100644 --- a/src/app/dashboards/vm-dashboard/vm-dashboard.component.ts +++ b/src/app/dashboards/vm-dashboard/vm-dashboard.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, Input, OnInit, ViewChild } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { combineLatest } from 'rxjs'; import { Progress } from 'src/app/data/progress'; @@ -11,24 +11,20 @@ import { VmSet } from 'src/app/data/vmset'; import { VmSetService } from 'src/app/data/vmset.service'; interface dashboardVmSet extends VmSet { - setVMs?: VirtualMachine[], - stepOpen?: boolean - dynamic: boolean + setVMs?: VirtualMachine[]; + stepOpen?: boolean; + dynamic: boolean; } @Component({ selector: 'vm-dashboard', templateUrl: './vm-dashboard.component.html', - styleUrls: ['./vm-dashboard.component.scss'] + styleUrls: ['./vm-dashboard.component.scss'], }) - - export class VmDashboardComponent implements OnInit { - @Input() selectedEvent: ScheduledEvent; - constructor( public vmService: VmService, public vmSetService: VmSetService, @@ -36,100 +32,111 @@ export class VmDashboardComponent implements OnInit { public progressService: ProgressService, private router: Router, private cd: ChangeDetectorRef - ) { } + ) {} public vms: VirtualMachine[] = []; public vmSets: dashboardVmSet[] = []; public selectedVM: VirtualMachine = new VirtualMachine(); - public openPanels: Set = new Set() + public openPanels: Set = new Set(); - ngOnInit(): void { - this.getVmList() + ngOnInit(): void { + this.getVmList(); } ngOnChanges() { - this.getVmList() + this.getVmList(); } setStepOpen(set) { - this.openPanels.has(set.base_name) ? this.openPanels.delete(set.base_name) : this.openPanels.add(set.base_name) + this.openPanels.has(set.base_name) + ? this.openPanels.delete(set.base_name) + : this.openPanels.add(set.base_name); } getVmList() { combineLatest([ this.vmService.listByScheduledEvent(this.selectedEvent.id), this.vmSetService.getVMSetByScheduledEvent(this.selectedEvent.id), - this.userService.getUsers() + this.userService.getUsers(), ]).subscribe(([vmList, vmSet, users]) => { - const userMap = new Map(users.map(u => [u.id, u.email])); + const userMap = new Map(users.map((u) => [u.id, u.email])); this.vms = vmList.map((vm) => ({ ...vm, user: userMap.get(vm.user) ?? '-', - })) + })); this.vmSets = vmSet.map((set) => ({ ...set, - setVMs: this.vms.filter(vm => vm.vm_set_id === set.id), + setVMs: this.vms.filter((vm) => vm.vm_set_id === set.id), stepOpen: this.openPanels.has(set.base_name), dynamic: false, - available: this.vms.filter(vm => vm.vm_set_id === set.id && vm.status == "running").length - }) - ) + available: this.vms.filter( + (vm) => vm.vm_set_id === set.id && vm.status == 'running' + ).length, + })); // dynamic machines have no associated vmSet - if (this.vms.filter(vm => vm.vm_set_id == "").length > 0) { - let groupedVms: Map = this.groupByEnvironment(this.vms.filter(vm => vm.vm_set_id == "")); + if (this.vms.filter((vm) => vm.vm_set_id == '').length > 0) { + let groupedVms: Map = this.groupByEnvironment( + this.vms.filter((vm) => vm.vm_set_id == '') + ); groupedVms.forEach((element, environment) => { let vmSet: dashboardVmSet = { ...new VmSet(), base_name: environment, stepOpen: this.openPanels.has(environment), - dynamic: true - } - vmSet.setVMs = element - vmSet.count = element.length - vmSet.available = element.filter(vm => vm.status == "running").length - vmSet.environment = environment - this.vmSets.push(vmSet) - }); + dynamic: true, + }; + vmSet.setVMs = element; + vmSet.count = element.length; + vmSet.available = element.filter( + (vm) => vm.status == 'running' + ).length; + vmSet.environment = environment; + this.vmSets.push(vmSet); + }); } - this.cd.detectChanges() //The async Code above updates values after Angulars usual change-detection so we call this Method to prevent Errors - }); + this.cd.detectChanges(); //The async Code above updates values after Angulars usual change-detection so we call this Method to prevent Errors + }); } - openUsersTerminal(vm: VirtualMachine) { - if (!vm.user) return; + openUsersTerminal(vm: VirtualMachine) { + if (!vm.user) return; var userId: string; //get the Users ID who has the VM allocated to him this.userService.getUsers().subscribe((users) => { - userId = users.filter((user) => user.email === vm.user)[0]?.id - }) + userId = users.filter((user) => user.email === vm.user)[0]?.id; + }); if (!userId) return; - var progress: Progress //If there is a valid User ID, get all active Progresses of that user. - this.progressService.listByScheduledEvent(this.selectedEvent.id, false).subscribe((progressList) => { - progress = progressList.filter((p) => - p.user === userId, - )[0] - if(!progress) return; //Since a User can only have one active Session, navigate to the corresponding Step the User is currently at. - const url = this.router.serializeUrl( - this.router.createUrlTree(['/session', progress.session, 'steps', Math.max(progress.current_step - 1, 0),]) - ); - window.open(url, '_blank'); - }) + var progress: Progress; //If there is a valid User ID, get all active Progresses of that user. + this.progressService + .listByScheduledEvent(this.selectedEvent.id, false) + .subscribe((progressList) => { + progress = progressList.filter((p) => p.user === userId)[0]; + if (!progress) return; //Since a User can only have one active Session, navigate to the corresponding Step the User is currently at. + const url = this.router.serializeUrl( + this.router.createUrlTree([ + '/session', + progress.session, + 'steps', + Math.max(progress.current_step - 1, 0), + ]) + ); + window.open(url, '_blank'); + }); } groupByEnvironment(vms: VirtualMachine[]) { let envMap = new Map(); - vms.forEach(element => { - if(envMap.has(element.environment_id)){ - let envVms = envMap.get(element.environment_id); - envVms.push(element); - envMap.set(element.environment_id, envVms); - }else{ + vms.forEach((element) => { + if (envMap.has(element.environment_id)) { + let envVms = envMap.get(element.environment_id); + envVms.push(element); + envMap.set(element.environment_id, envVms); + } else { let envVms: VirtualMachine[] = [element]; envMap.set(element.environment_id, envVms); } }); return envMap; } - } diff --git a/src/app/data/settings.service.ts b/src/app/data/settings.service.ts index 795c595d..e5d81b9c 100644 --- a/src/app/data/settings.service.ts +++ b/src/app/data/settings.service.ts @@ -16,9 +16,13 @@ import { } from '../data/gargantua.service'; export interface Settings { - terminal_theme: typeof themes[number]['id']; + terminal_theme: (typeof themes)[number]['id']; } +/** + * SettingsService is used to handle Settings saved by a user. + * For Global Settings see TypedSettingsService + */ @Injectable() export class SettingsService { constructor(private gcf: GargantuaClientFactory) {} @@ -32,7 +36,7 @@ export class SettingsService { map(extractResponseContent), tap((s: Readonly) => { this.subject.next(s); - }), + }) ); } @@ -42,7 +46,7 @@ export class SettingsService { catchError((e: HttpErrorResponse) => { return throwError(e.error); }), - tap(() => this.subject.next(newSettings)), + tap(() => this.subject.next(newSettings)) ); } @@ -51,7 +55,7 @@ export class SettingsService { first(), switchMap((currentSettings) => { return this.set({ ...currentSettings, ...update }); - }), + }) ); } } diff --git a/src/app/data/typedSettings.service.ts b/src/app/data/typedSettings.service.ts new file mode 100644 index 00000000..ae34f30a --- /dev/null +++ b/src/app/data/typedSettings.service.ts @@ -0,0 +1,202 @@ +import { Injectable } from '@angular/core'; +import { map, tap } from 'rxjs/operators'; +import { + extractResponseContent, + GargantuaClientFactory, +} from '../data/gargantua.service'; +import { InputValidation, TypedInput } from '../typed-form/TypedInput'; +import { TypedInputRepresentation } from '../typed-form/TypedInput'; +import { TypedInputType } from '../typed-form/TypedInput'; +import { of } from 'rxjs'; + +export class PreparedSettings { + name: string; + value: any; + scope: string; + weight: number; + group: string; + // Following is corresponding to property.Property + dataType: 'string' | 'integer' | 'float' | 'boolean'; + valueType: 'scalar' | 'array' | 'map'; + displayName?: string; + // Following represents SettingValidation + required?: boolean; + maximum?: number; + minimum?: number; + maxLength?: number; + minLength?: number; + format?: string; + pattern?: string; + enum?: string[]; + default?: string; + uniqueItems?: boolean; +} + +export class PreparedScope { + name: string; + displayName: string; +} + +@Injectable() +export class TypedSettingsService { + constructor(private gcf: GargantuaClientFactory) {} + private garg = this.gcf.scopedClient('/setting'); + private scopeGarg = this.gcf.scopedClient('/scope'); + + private cachedTypedInputList: Map> = + new Map(); + + // Maps TypedInput representation to corresponding string + private typedInputRepresentationList: TypedInputRepresentation[] = [ + TypedInputRepresentation.ARRAY, + TypedInputRepresentation.MAP, + TypedInputRepresentation.SCALAR, + ]; + private typedInputRepresentationStringList: string[] = [ + 'array', + 'map', + 'scalar', + ]; + + // Maps TypedInput type to corresponding string + private typedInputDataTypeList: TypedInputType[] = [ + TypedInputType.STRING, + TypedInputType.BOOLEAN, + TypedInputType.FLOAT, + TypedInputType.INTEGER, + ]; + private typedInputDataTypeStringList: string[] = [ + 'string', + 'boolean', + 'float', + 'integer', + ]; + + public get(scope: string, setting: string) { + if (this.cachedTypedInputList && this.cachedTypedInputList.has(scope)) { + let scopedSettings = this.cachedTypedInputList.get(scope)!; + if (scopedSettings.has(setting)) { + return of(scopedSettings.get(setting) ?? ({} as TypedInput)); + } else { + return of({} as TypedInput); + } + } else { + return this.list(scope).pipe( + tap((typedInputs: TypedInput[]) => { + let m: Map = new Map(); + typedInputs.forEach((typedSetting) => { + m.set(typedSetting.id, typedSetting); + }); + this.cachedTypedInputList.set(scope, m); + }), + map((typedInputs) => { + return ( + typedInputs.find((typedInput) => { + return typedInput.id === setting; + }) ?? ({} as TypedInput) + ); + }) + ); + } + } + + public list(scope: string) { + return this.garg.get('/list/' + scope).pipe( + map(extractResponseContent), + map((pList: PreparedSettings[]) => { + if (!pList) { + return []; + } + return this.buildTypedInputList(pList); + }) + ); + } + + public updateCollection(settings: TypedInput[]) { + const preparedSettings = this.buildPreparedSettingsList(settings); + return this.garg.put('/updatecollection', JSON.stringify(preparedSettings)); + } + + public listScopes() { + return this.scopeGarg.get('/list').pipe( + map(extractResponseContent), + map((pList: PreparedScope[]) => { + return pList ?? []; + }) + ); + } + + private buildTypedInputList(pList: PreparedSettings[]) { + let settings: TypedInput[] = []; + + pList.forEach((preparedSetting: PreparedSettings) => { + const typedInputRepresentationIndex = + this.typedInputRepresentationStringList.indexOf( + preparedSetting.valueType + ); + const representation: TypedInputRepresentation = + this.typedInputRepresentationList[ + typedInputRepresentationIndex == -1 + ? 0 + : typedInputRepresentationIndex + ]; + + const typedInputTypeIndex = this.typedInputDataTypeStringList.indexOf( + preparedSetting.dataType + ); + const inputType: TypedInputType = + this.typedInputDataTypeList[ + typedInputTypeIndex == -1 ? 0 : typedInputTypeIndex + ]; + + const setting = new TypedInput({ + id: preparedSetting.name, + name: + preparedSetting.displayName == '' + ? preparedSetting.name + : preparedSetting.displayName, + category: preparedSetting.group, + representation: representation, + type: inputType, + validation: { + required: preparedSetting.required, + maximum: preparedSetting.maximum, + minimum: preparedSetting.minimum, + maxLength: preparedSetting.maxLength, + minLength: preparedSetting.minLength, + format: preparedSetting.format, + pattern: preparedSetting.pattern, + enum: preparedSetting.enum, + default: preparedSetting.default, + uniqueItems: preparedSetting.uniqueItems, + } as InputValidation, + value: preparedSetting.value, + weight: preparedSetting.weight, + }); + + settings.push(setting); + }); + return settings; + } + + private buildPreparedSettingsList(inputs: TypedInput[]) { + let preparedSettings: Partial[] = []; + inputs.forEach((input: TypedInput) => { + // Maps will not be converted correctly with JSON.stringify, we have to convert them to an Object. + if (input.representation == TypedInputRepresentation.MAP) { + let jsonObject = {}; + input.value.forEach((value, key) => { + jsonObject[key] = value; + }); + input.value = jsonObject; + } + + const setting = { + name: input.id, + value: input.value, + } as Partial; + preparedSettings.push(setting); + }); + return preparedSettings; + } +} diff --git a/src/app/event/new-scheduled-event/new-scheduled-event.component.ts b/src/app/event/new-scheduled-event/new-scheduled-event.component.ts index 0b5e4d06..1ca4bac2 100644 --- a/src/app/event/new-scheduled-event/new-scheduled-event.component.ts +++ b/src/app/event/new-scheduled-event/new-scheduled-event.component.ts @@ -34,7 +34,6 @@ import { Validators, FormArray, ValidatorFn, - ValidationErrors, FormBuilder, AbstractControl, } from '@angular/forms'; @@ -42,7 +41,6 @@ import { DlDateTimePickerChange } from 'angular-bootstrap-datetimepicker'; import { QuicksetValidator } from 'src/app/validators/quickset.validator'; import { RbacService } from 'src/app/data/rbac.service'; import { of } from 'rxjs'; -import { arrayHead } from '@cds/core/internal'; @Component({ selector: 'new-scheduled-event', diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index eb03495f..856dabf2 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -1,6 +1,8 @@
- +
+

{{motd}}

+