diff --git a/modules/ui/src/app/components/dynamic-form/dynamic-form.component.html b/modules/ui/src/app/components/dynamic-form/dynamic-form.component.html new file mode 100644 index 000000000..e433d9cd7 --- /dev/null +++ b/modules/ui/src/app/components/dynamic-form/dynamic-form.component.html @@ -0,0 +1,266 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ description }} + + Please, check. “ and \ are not allowed. + + + The field is required + + + The field must be a maximum of + {{ getControl(formControlName).getError('maxlength').requiredLength }} + characters. + + + + + + + + {{ description }} + + Please, check. “ and \ are not allowed. + + + The field is required + + + The field must be a maximum of + {{ getControl(formControlName).getError('maxlength').requiredLength }} + characters. + + + + + + + + {{ description }} + + The field is required + + + Please, check the email address. Valid e-mail can contain only latin + letters, numbers, @ and . (dot). + + + The field must be a maximum of + {{ getControl(formControlName).getError('maxlength').requiredLength }} + characters. + + + + + +
+

+ + {{ getOptionValue(option) }} + +

+ {{ + description + }} +
+
+ + + + + + {{ getOptionValue(option) }} + + + {{ + description + }} + + The field is required + + + diff --git a/modules/ui/src/app/components/dynamic-form/dynamic-form.component.scss b/modules/ui/src/app/components/dynamic-form/dynamic-form.component.scss new file mode 100644 index 000000000..d250c9dde --- /dev/null +++ b/modules/ui/src/app/components/dynamic-form/dynamic-form.component.scss @@ -0,0 +1,67 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@use '@angular/material' as mat; +@import 'src/theming/colors'; +@import 'src/theming/variables'; + +::ng-deep .field-label { + margin: 0; + color: $grey-800; + font-size: 18px; + line-height: 24px; + padding-top: 24px; + padding-bottom: 16px; + display: inline-block; + &:has(+ .field-select-multiple.ng-invalid.ng-dirty) { + color: mat.get-color-from-palette($color-warn, 700); + } +} +mat-form-field { + width: 100%; +} +.field-hint { + font-family: $font-secondary; + font-size: 12px; + font-weight: 400; + line-height: 16px; + text-align: left; + padding-top: 8px; +} + +.form-field { + width: 100%; +} + +.form-field ::ng-deep .mat-mdc-form-field-textarea-control { + display: inherit; +} + +.field-select-multiple { + .field-select-checkbox { + &:has(::ng-deep .mat-mdc-checkbox-checked) { + background: mat.get-color-from-palette($color-primary, 50); + } + ::ng-deep .mdc-checkbox__ripple { + display: none; + } + &:first-of-type { + margin-top: 0; + } + &:last-of-type { + margin-bottom: 8px; + } + } +} diff --git a/modules/ui/src/app/components/dynamic-form/dynamic-form.component.spec.ts b/modules/ui/src/app/components/dynamic-form/dynamic-form.component.spec.ts new file mode 100644 index 000000000..5254b7230 --- /dev/null +++ b/modules/ui/src/app/components/dynamic-form/dynamic-form.component.spec.ts @@ -0,0 +1,237 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DynamicFormComponent } from './dynamic-form.component'; +import { Component, ViewChild, ViewEncapsulation } from '@angular/core'; +import { + FormBuilder, + FormGroup, + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { PROFILE_FORM } from '../../mocks/profile.mock'; +import { FormControlType } from '../../model/question'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +@Component({ + template: + '
', +}) +class DummyComponent { + @ViewChild('dynamicForm') public dynamicForm!: DynamicFormComponent; + public testForm!: FormGroup; + public format = PROFILE_FORM; + constructor(private readonly fb: FormBuilder) { + this.testForm = this.fb.group({ + test: [''], + }); + } +} + +describe('DynamicFormComponent', () => { + let dummy: DummyComponent; + let fixture: ComponentFixture; + let compiled: HTMLElement; + let component: DynamicFormComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [DummyComponent], + imports: [ + DynamicFormComponent, + ReactiveFormsModule, + FormsModule, + NoopAnimationsModule, + ], + }) + .overrideComponent(DummyComponent, { + set: { encapsulation: ViewEncapsulation.None }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(DummyComponent); + + dummy = fixture.componentInstance; + compiled = fixture.nativeElement as HTMLElement; + fixture.detectChanges(); + component = dummy.dynamicForm; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + PROFILE_FORM.forEach((item, index) => { + it(`should have form field with specific type"`, () => { + const fields = compiled.querySelectorAll('.form-field'); + + if (item.type === FormControlType.SELECT) { + const select = fields[index].querySelector('mat-select'); + expect(select).toBeTruthy(); + } else if (item.type === FormControlType.SELECT_MULTIPLE) { + const select = fields[index].querySelector('mat-checkbox'); + expect(select).toBeTruthy(); + } else if (item.type === FormControlType.TEXTAREA) { + const input = fields[index]?.querySelector('textarea'); + expect(input).toBeTruthy(); + } else { + const input = fields[index]?.querySelector('input'); + expect(input).toBeTruthy(); + } + }); + + it('should have label', () => { + const labels = compiled.querySelectorAll('.field-label'); + + const label = item?.validation?.required + ? item.question + ' *' + : item.question; + expect(labels[index].textContent?.trim()).toEqual(label); + }); + + it('should have hint', () => { + const fields = compiled.querySelectorAll('.form-field'); + const hint = fields[index].querySelector('mat-hint'); + + if (item.description) { + expect(hint?.textContent?.trim()).toEqual(item.description); + } else { + expect(hint).toBeNull(); + } + }); + + if (item.type === FormControlType.SELECT) { + describe('select', () => { + it(`should have default value if provided`, () => { + const fields = compiled.querySelectorAll('.form-field'); + const select = fields[index].querySelector('mat-select'); + expect(select?.textContent?.trim()).toEqual(item.default || ''); + }); + + it('should have "required" error when field is not filled', () => { + const fields = compiled.querySelectorAll('.form-field'); + + component.getControl(index).setValue(''); + component.getControl(index).markAsTouched(); + + fixture.detectChanges(); + + const error = fields[index].querySelector('mat-error')?.innerHTML; + + expect(error).toContain('The field is required'); + }); + }); + } + + if (item.type === FormControlType.SELECT_MULTIPLE) { + describe('select multiple', () => { + it(`should mark form group as dirty while tab navigation`, () => { + const fields = compiled.querySelectorAll('.form-field'); + const checkbox = fields[index].querySelector( + '.field-select-checkbox:last-of-type mat-checkbox' + ); + checkbox?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + fixture.detectChanges(); + + expect(component.getControl(index).dirty).toBeTrue(); + }); + }); + } + + if ( + item.type === FormControlType.TEXT || + item.type === FormControlType.TEXTAREA || + item.type === FormControlType.EMAIL_MULTIPLE + ) { + describe('text or text-long or email-multiple', () => { + if (item.validation?.required) { + it('should have "required" error when field is not filled', () => { + const fields = compiled.querySelectorAll('.form-field'); + const input = fields[index].querySelector( + '.mat-mdc-input-element' + ) as HTMLInputElement; + ['', ' '].forEach(value => { + input.value = value; + input.dispatchEvent(new Event('input')); + component.getControl(index).markAsTouched(); + fixture.detectChanges(); + const errors = fields[index].querySelectorAll('mat-error'); + let hasError = false; + errors.forEach(error => { + if (error.textContent === 'The field is required') { + hasError = true; + } + }); + + expect(hasError).toBeTrue(); + }); + }); + } + + it('should have "invalid_format" error when field does not satisfy validation rules', () => { + const fields = compiled.querySelectorAll('.form-field'); + const input: HTMLInputElement = fields[index].querySelector( + '.mat-mdc-input-element' + ) as HTMLInputElement; + input.value = 'as\\\\\\\\\\""""""""'; + input.dispatchEvent(new Event('input')); + component.getControl(index).markAsTouched(); + fixture.detectChanges(); + const result = + item.type === FormControlType.EMAIL_MULTIPLE + ? 'Please, check the email address. Valid e-mail can contain only latin letters, numbers, @ and . (dot).' + : 'Please, check. “ and \\ are not allowed.'; + const errors = fields[index].querySelectorAll('mat-error'); + let hasError = false; + errors.forEach(error => { + if (error.textContent === result) { + hasError = true; + } + }); + + expect(hasError).toBeTrue(); + }); + + if (item.validation?.max) { + it('should have "maxlength" error when field is exceeding max length', () => { + const fields = compiled.querySelectorAll('.form-field'); + const input: HTMLInputElement = fields[index].querySelector( + '.mat-mdc-input-element' + ) as HTMLInputElement; + input.value = + 'very long value very long value very long value very long value very long value very long value very long value very long value very long value very long value'; + input.dispatchEvent(new Event('input')); + component.getControl(index).markAsTouched(); + fixture.detectChanges(); + + const errors = fields[index].querySelectorAll('mat-error'); + let hasError = false; + errors.forEach(error => { + if ( + error.textContent === + `The field must be a maximum of ${item.validation?.max} characters.` + ) { + hasError = true; + } + }); + expect(hasError).toBeTrue(); + }); + } + }); + } + }); +}); diff --git a/modules/ui/src/app/components/dynamic-form/dynamic-form.component.ts b/modules/ui/src/app/components/dynamic-form/dynamic-form.component.ts new file mode 100644 index 000000000..bdf03a194 --- /dev/null +++ b/modules/ui/src/app/components/dynamic-form/dynamic-form.component.ts @@ -0,0 +1,169 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Component, inject, Input, OnInit } from '@angular/core'; +import { + FormControlType, + OptionType, + QuestionFormat, + Validation, +} from '../../model/question'; +import { + AbstractControl, + ControlContainer, + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, + ValidatorFn, + Validators, +} from '@angular/forms'; +import { + MatError, + MatFormField, + MatOption, + MatSelectModule, +} from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { CommonModule } from '@angular/common'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { TextFieldModule } from '@angular/cdk/text-field'; +import { DeviceValidators } from '../../pages/devices/components/device-form/device.validators'; +import { ProfileValidators } from '../../pages/risk-assessment/profile-form/profile.validators'; +@Component({ + selector: 'app-dynamic-form', + standalone: true, + imports: [ + MatFormField, + MatOption, + MatButtonModule, + CommonModule, + ReactiveFormsModule, + MatInputModule, + MatError, + MatFormFieldModule, + MatSelectModule, + MatCheckboxModule, + TextFieldModule, + ], + viewProviders: [ + { + provide: ControlContainer, + useFactory: () => inject(ControlContainer, { skipSelf: true }), + }, + ], + templateUrl: './dynamic-form.component.html', + styleUrl: './dynamic-form.component.scss', +}) +export class DynamicFormComponent implements OnInit { + public readonly FormControlType = FormControlType; + + @Input() format: QuestionFormat[] = []; + @Input() optionKey: string | undefined; + + parentContainer = inject(ControlContainer); + get formGroup() { + return this.parentContainer.control as FormGroup; + } + + constructor( + private fb: FormBuilder, + private deviceValidators: DeviceValidators, + private profileValidators: ProfileValidators + ) {} + getControl(name: string | number) { + return this.formGroup.get(name.toString()) as AbstractControl; + } + + getFormGroup(name: string | number): FormGroup { + return this.formGroup?.controls[name] as FormGroup; + } + + public markSectionAsDirty( + optionIndex: number, + optionLength: number, + formControlName: string + ) { + if (optionIndex === optionLength - 1) { + this.getControl(formControlName).markAsDirty(); + } + } + + ngOnInit() { + this.createProfileForm(this.format); + } + + createProfileForm(questions: QuestionFormat[]) { + questions.forEach((question, index) => { + if (question.type === FormControlType.SELECT_MULTIPLE) { + this.formGroup.addControl( + index.toString(), + this.getMultiSelectGroup(question) + ); + } else { + const validators = this.getValidators( + question.type, + question.validation + ); + this.formGroup.addControl( + index.toString(), + new FormControl(question.default || '', validators) + ); + } + }); + } + + getValidators(type: FormControlType, validation?: Validation): ValidatorFn[] { + const validators: ValidatorFn[] = []; + if (validation) { + if (validation.required) { + validators.push(this.profileValidators.textRequired()); + } + if (validation.max) { + validators.push(Validators.maxLength(Number(validation.max))); + } + if (type === FormControlType.EMAIL_MULTIPLE) { + validators.push(this.profileValidators.emailStringFormat()); + } + if (type === FormControlType.TEXT || type === FormControlType.TEXTAREA) { + validators.push(this.profileValidators.textFormat()); + } + } + return validators; + } + + getMultiSelectGroup(question: QuestionFormat): FormGroup { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const group: any = {}; + question.options?.forEach((option, index) => { + group[index] = false; + }); + return this.fb.group(group, { + validators: question.validation?.required + ? [this.profileValidators.multiSelectRequired] + : [], + }); + } + + getOptionValue(option: OptionType) { + if (this.optionKey && typeof option === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (option as any)[this.optionKey]; + } + return option; + } +} diff --git a/modules/ui/src/app/mocks/device.mock.ts b/modules/ui/src/app/mocks/device.mock.ts index 8bbfb56ea..c8b12c523 100644 --- a/modules/ui/src/app/mocks/device.mock.ts +++ b/modules/ui/src/app/mocks/device.mock.ts @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Device } from '../model/device'; +import { Device, DeviceQuestionnaireSection } from '../model/device'; +import { ProfileRisk } from '../model/profile'; +import { FormControlType } from '../model/question'; export const device = { manufacturer: 'Delta', @@ -50,3 +52,62 @@ export const MOCK_TEST_MODULES = [ ]; export const MOCK_MODULES = ['Connection', 'Udmi']; + +export const DEVICES_FORM: DeviceQuestionnaireSection[] = [ + { + step: 1, + title: 'Step 1 title', + description: 'Step 1 description', + questions: [ + { + id: 1, + question: 'What type of device is this?', + validation: { + required: true, + }, + type: FormControlType.SELECT, + options: [ + { + text: 'Building Automation Gateway', + risk: ProfileRisk.HIGH, + id: 1, + }, + { + text: 'IoT Gateway', + risk: ProfileRisk.LIMITED, + id: 2, + }, + ], + }, + { + id: 2, + question: 'Does your device process any sensitive information? ', + validation: { + required: true, + }, + type: FormControlType.SELECT, + options: [ + { + id: 1, + text: 'Yes', + risk: ProfileRisk.LIMITED, + }, + { + id: 2, + text: 'No', + risk: ProfileRisk.HIGH, + }, + ], + }, + { + id: 3, + question: 'Please select the technology this device falls into', + validation: { + required: true, + }, + type: FormControlType.SELECT, + options: ['Hardware - Access Control', 'Hardware - Air quality'], + }, + ], + }, +]; diff --git a/modules/ui/src/app/mocks/profile.mock.ts b/modules/ui/src/app/mocks/profile.mock.ts index f2dbe82c8..f6cfaef49 100644 --- a/modules/ui/src/app/mocks/profile.mock.ts +++ b/modules/ui/src/app/mocks/profile.mock.ts @@ -14,12 +14,8 @@ * limitations under the License. */ -import { - FormControlType, - Profile, - ProfileFormat, - ProfileStatus, -} from '../model/profile'; +import { Profile, ProfileFormat, ProfileStatus } from '../model/profile'; +import { FormControlType } from '../model/question'; export const PROFILE_MOCK: Profile = { name: 'Primary profile', diff --git a/modules/ui/src/app/model/device.ts b/modules/ui/src/app/model/device.ts index ba526e661..1fa187838 100644 --- a/modules/ui/src/app/model/device.ts +++ b/modules/ui/src/app/model/device.ts @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { QuestionFormat } from './question'; + export interface Device { manufacturer: string; model: string; @@ -43,3 +45,14 @@ export enum DeviceView { Basic = 'basic', WithActions = 'with actions', } + +export interface DeviceQuestionnaireSection { + step: number; + title?: string; + description?: string; + questions: QuestionnaireFormat[]; +} + +export interface QuestionnaireFormat extends QuestionFormat { + id: number; +} diff --git a/modules/ui/src/app/model/profile.ts b/modules/ui/src/app/model/profile.ts index 059b3cafe..81f37b7a3 100644 --- a/modules/ui/src/app/model/profile.ts +++ b/modules/ui/src/app/model/profile.ts @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { QuestionFormat } from './question'; export interface Profile { name: string; risk?: string; @@ -22,27 +24,7 @@ export interface Profile { created?: string; } -export enum FormControlType { - SELECT = 'select', - TEXTAREA = 'text-long', - EMAIL_MULTIPLE = 'email-multiple', - SELECT_MULTIPLE = 'select-multiple', - TEXT = 'text', -} - -export interface Validation { - required?: boolean; - max?: string; -} - -export interface ProfileFormat { - question: string; - type: FormControlType; - description?: string; - options?: string[]; - default?: string; - validation?: Validation; -} +export interface ProfileFormat extends QuestionFormat {} export interface Question { question?: string; diff --git a/modules/ui/src/app/model/question.ts b/modules/ui/src/app/model/question.ts new file mode 100644 index 000000000..284fcdd3d --- /dev/null +++ b/modules/ui/src/app/model/question.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export interface Validation { + required: boolean | undefined; + max?: string; +} + +export enum FormControlType { + SELECT = 'select', + TEXTAREA = 'text-long', + EMAIL_MULTIPLE = 'email-multiple', + SELECT_MULTIPLE = 'select-multiple', + TEXT = 'text', +} + +export interface QuestionFormat { + question: string; + type: FormControlType; + description?: string; + options?: OptionType[]; + default?: string; + validation?: Validation; +} + +export type OptionType = string | object; diff --git a/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.html b/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.html index b0253ebc8..f00f025d7 100644 --- a/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.html +++ b/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.html @@ -1,3 +1,18 @@ +
{{ data.title }} formArrayName="steps" [linear]="true" [header]="header"> - + Device Manufacturer {{ data.title }} - - Second step + + +

+ {{ step.title }} +

+

+ {{ step.description }} +

+ +
+
+ + + Third step diff --git a/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.scss b/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.scss index cd5ce35b7..f4c06d5e6 100644 --- a/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.scss +++ b/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.scss @@ -31,30 +31,6 @@ $form-height: 993px; overflow: hidden; } -.device-form { - display: grid; - padding: 24px; - max-width: $form-max-width; - min-width: $form-min-width; - - gap: 10px; - overflow: auto; -} - -.manufacturer-field, -.model-field { - &::ng-deep.mat-mdc-form-field-subscript-wrapper:has(mat-error) { - height: 40px; - } -} - -.device-form-title { - color: $grey-800; - font-size: 22px; - line-height: 28px; - padding-bottom: 14px; -} - ::ng-deep .device-form-test-modules { overflow: auto; min-height: 78px; @@ -68,11 +44,6 @@ $form-height: 993px; } } -.device-form-actions { - padding: 0; - min-height: 30px; -} - .close-button { color: $primary; } @@ -95,10 +66,10 @@ $form-height: 993px; padding-top: 24px; &-title { margin: 0; - font-size: 22px; + font-size: 32px; font-style: normal; font-weight: 400; - line-height: 28px; + line-height: 40px; color: $dark-grey; text-align: center; padding: 38px 0; @@ -157,3 +128,20 @@ $form-height: 993px; .device-qualification-form-test-modules-container { padding: 0 24px; } + +.device-qualification-form-step-title { + margin: 0; + font-style: normal; + font-weight: 400; + font-size: 22px; + line-height: 28px; + text-align: center; + color: $grey-900; +} + +.device-qualification-form-step-description { + text-align: center; + color: $grey-800; + margin: 0; + padding-top: 8px; +} diff --git a/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.spec.ts b/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.spec.ts index 7269cc539..cc320e03f 100644 --- a/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.spec.ts +++ b/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.spec.ts @@ -1,3 +1,18 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DeviceQualificationFromComponent } from './device-qualification-from.component'; @@ -9,18 +24,27 @@ import { import { of } from 'rxjs'; import { NgxMaskDirective, NgxMaskPipe, provideNgxMask } from 'ngx-mask'; import { MatButtonModule } from '@angular/material/button'; -import { ReactiveFormsModule } from '@angular/forms'; +import { FormArray, ReactiveFormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatInputModule } from '@angular/material/input'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { DeviceTestsComponent } from '../../../../components/device-tests/device-tests.component'; import { SpinnerComponent } from '../../../../components/spinner/spinner.component'; -import { device, MOCK_TEST_MODULES } from '../../../../mocks/device.mock'; +import { + device, + DEVICES_FORM, + MOCK_TEST_MODULES, +} from '../../../../mocks/device.mock'; import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { TestRunService } from '../../../../services/test-run.service'; +import { DevicesStore } from '../../devices.store'; +import { provideMockStore } from '@ngrx/store/testing'; describe('DeviceQualificationFromComponent', () => { let component: DeviceQualificationFromComponent; let fixture: ComponentFixture; let compiled: HTMLElement; + const testrunServiceMock: jasmine.SpyObj = + jasmine.createSpyObj('testrunServiceMock', ['fetchQuestionnaireFormat']); beforeEach(async () => { await TestBed.configureTestingModule({ @@ -39,6 +63,7 @@ describe('DeviceQualificationFromComponent', () => { MatIconTestingModule, ], providers: [ + DevicesStore, { provide: MatDialogRef, useValue: { @@ -47,17 +72,24 @@ describe('DeviceQualificationFromComponent', () => { }, }, { provide: MAT_DIALOG_DATA, useValue: {} }, + { provide: TestRunService, useValue: testrunServiceMock }, provideNgxMask(), + provideMockStore({}), ], }).compileComponents(); fixture = TestBed.createComponent(DeviceQualificationFromComponent); component = fixture.componentInstance; compiled = fixture.nativeElement as HTMLElement; + component.data = { testModules: MOCK_TEST_MODULES, devices: [], }; + testrunServiceMock.fetchQuestionnaireFormat.and.returnValue( + of(DEVICES_FORM) + ); + fixture.detectChanges(); }); @@ -65,12 +97,23 @@ describe('DeviceQualificationFromComponent', () => { expect(component).toBeTruthy(); }); - it('should contain device form', () => { + it('should fetch devices format', () => { const form = compiled.querySelector('.device-qualification-form'); expect(form).toBeTruthy(); }); + it('should contain device form', () => { + const getQuestionnaireFormatSpy = spyOn( + component.devicesStore, + 'getQuestionnaireFormat' + ); + component.ngOnInit(); + fixture.detectChanges(); + + expect(getQuestionnaireFormatSpy).toHaveBeenCalled(); + }); + it('should close dialog on "cancel" click', () => { const closeSpy = spyOn(component.dialogRef, 'close'); const closeButton = compiled.querySelector( @@ -299,6 +342,7 @@ describe('DeviceQualificationFromComponent', () => { }, }, }; + component.ngOnInit(); fixture.detectChanges(); }); @@ -315,4 +359,13 @@ describe('DeviceQualificationFromComponent', () => { expect(manufacturer.value).toEqual('Delta'); }); }); + + describe('with questioner', () => { + it('should have steps', () => { + expect( + (component.deviceQualificationForm.get('steps') as FormArray).controls + .length + ).toEqual(3); + }); + }); }); diff --git a/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.ts b/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.ts index cd42d4b2a..bdb8dc92d 100644 --- a/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.ts +++ b/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.ts @@ -1,4 +1,25 @@ -import { Component, Inject, OnInit } from '@angular/core'; +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + AfterViewInit, + Component, + ElementRef, + Inject, + OnInit, +} from '@angular/core'; import { AbstractControl, FormArray, @@ -8,7 +29,11 @@ import { Validators, } from '@angular/forms'; import { DeviceValidators } from '../device-form/device.validators'; -import { Device, TestModule } from '../../../../model/device'; +import { + Device, + DeviceQuestionnaireSection, + TestModule, +} from '../../../../model/device'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { EscapableDialogComponent } from '../../../../components/escapable-dialog/escapable-dialog.component'; import { CommonModule } from '@angular/common'; @@ -29,6 +54,9 @@ import { NgxMaskDirective, NgxMaskPipe, provideNgxMask } from 'ngx-mask'; import { MatIcon } from '@angular/material/icon'; import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; import { ProfileValidators } from '../../../risk-assessment/profile-form/profile.validators'; +import { DevicesStore } from '../../devices.store'; +import { DynamicFormComponent } from '../../../../components/dynamic-form/dynamic-form.component'; +import { skip } from 'rxjs'; const MAC_ADDRESS_PATTERN = '^[\\s]*[a-fA-F0-9]{2}(?:[:][a-fA-F0-9]{2}){5}[\\s]*$'; @@ -62,45 +90,35 @@ interface DialogData { MatIcon, MatRadioGroup, MatRadioButton, + DynamicFormComponent, ], - providers: [provideNgxMask()], + providers: [provideNgxMask(), DevicesStore], templateUrl: './device-qualification-from.component.html', styleUrl: './device-qualification-from.component.scss', }) export class DeviceQualificationFromComponent extends EscapableDialogComponent - implements OnInit + implements OnInit, AfterViewInit { testModules: TestModule[] = []; - deviceQualificationForm!: FormGroup; - firstStep!: FormGroup; + deviceQualificationForm: FormGroup = this.fb.group({}); device: Device | undefined; + format: DeviceQuestionnaireSection[] = []; get model() { - return this.firstStep.get('model') as AbstractControl; + return this.getStep(0).get('model') as AbstractControl; } get manufacturer() { - return this.firstStep.get('manufacturer') as AbstractControl; + return this.getStep(0).get('manufacturer') as AbstractControl; } get mac_addr() { - return this.firstStep.get('mac_addr') as AbstractControl; + return this.getStep(0).get('mac_addr') as AbstractControl; } get test_modules() { - return this.firstStep.controls['test_modules'] as FormArray; - } - - get first_step() { - return this.firstStep; - } - - // TODO dummy step to show next button; should be removed after next step is created - get second_step() { - return (this.deviceQualificationForm.get('steps') as FormArray).controls[ - '1' - ] as FormGroup; + return this.getStep(0).controls['test_modules'] as FormArray; } constructor( @@ -108,30 +126,51 @@ export class DeviceQualificationFromComponent private deviceValidators: DeviceValidators, private profileValidators: ProfileValidators, public override dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: DialogData + @Inject(MAT_DIALOG_DATA) public data: DialogData, + public devicesStore: DevicesStore, + private element: ElementRef ) { super(dialogRef); this.device = data.device; } ngOnInit(): void { - this.createDeviceForm(); - this.testModules = this.data.testModules; + this.createBasicStep(); if (this.data.device) { this.model.setValue(this.data.device.model); this.manufacturer.setValue(this.data.device.manufacturer); this.mac_addr.setValue(this.data.device.mac_addr); } + this.testModules = this.data.testModules; + + this.devicesStore.questionnaireFormat$.pipe(skip(1)).subscribe(format => { + this.createDeviceForm(format); + this.format = format; + }); + + this.devicesStore.getQuestionnaireFormat(); + } + + ngAfterViewInit() { + //set static height for better UX + this.element.nativeElement.style.height = + this.element.nativeElement.offsetHeight + 'px'; } submit(): void { - this.device = this.createDeviceFromForm(this.first_step); + this.device = this.createDeviceFromForm(this.getStep(0)); } closeForm(): void { this.dialogRef.close(); } + getStep(step: number) { + return (this.deviceQualificationForm.get('steps') as FormArray).controls[ + step + ] as FormGroup; + } + private createDeviceFromForm(formGroup: FormGroup): Device { const testModules: { [key: string]: { enabled: boolean } } = {}; formGroup.value.test_modules.forEach((enabled: boolean, i: number) => { @@ -147,8 +186,8 @@ export class DeviceQualificationFromComponent } as Device; } - private createDeviceForm() { - this.firstStep = this.fb.group({ + private createBasicStep() { + const firstStep = this.fb.group({ model: [ '', [ @@ -177,8 +216,26 @@ export class DeviceQualificationFromComponent test_modules: new FormArray([]), testing_journey: [0], }); + this.deviceQualificationForm = this.fb.group({ - steps: this.fb.array([this.firstStep, this.fb.group({})]), + steps: this.fb.array([firstStep]), }); } + + private createDeviceForm(format: DeviceQuestionnaireSection[]) { + format.forEach(() => { + (this.deviceQualificationForm.get('steps') as FormArray).controls.push( + this.createStep() + ); + }); + + // TODO dummy step + (this.deviceQualificationForm.get('steps') as FormArray).controls.push( + this.fb.group({}) + ); + } + + private createStep() { + return new FormGroup({}); + } } diff --git a/modules/ui/src/app/pages/devices/devices.store.ts b/modules/ui/src/app/pages/devices/devices.store.ts index 19586a1c4..c9d5e9523 100644 --- a/modules/ui/src/app/pages/devices/devices.store.ts +++ b/modules/ui/src/app/pages/devices/devices.store.ts @@ -34,11 +34,13 @@ import { setIsOpenAddDevice, } from '../../store/actions'; import { TestrunStatus } from '../../model/testrun-status'; +import { DeviceQuestionnaireSection } from '../../model/device'; export interface DevicesComponentState { devices: Device[]; selectedDevice: Device | null; testModules: TestModule[]; + questionnaireFormat: DeviceQuestionnaireSection[]; } @Injectable() @@ -46,10 +48,10 @@ export class DevicesStore extends ComponentStore { devices$ = this.store.select(selectDevices); isOpenAddDevice$ = this.store.select(selectIsOpenAddDevice); testModules$ = this.store.select(selectTestModules); + questionnaireFormat$ = this.select(state => state.questionnaireFormat); private deviceInProgress$ = this.store.select(selectDeviceInProgress); private selectedDevice$ = this.select(state => state.selectedDevice); - //testModules = this.testRunService.getTestModules(); viewModel$ = this.select({ devices: this.devices$, selectedDevice: this.selectedDevice$, @@ -62,6 +64,12 @@ export class DevicesStore extends ComponentStore { selectedDevice: device, })); + updateQuestionnaireFormat = this.updater( + (state, questionnaireFormat: DeviceQuestionnaireSection[]) => ({ + ...state, + questionnaireFormat, + }) + ); deleteDevice = this.effect<{ device: Device; onDelete: () => void; @@ -139,6 +147,18 @@ export class DevicesStore extends ComponentStore { ); }); + getQuestionnaireFormat = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap(() => { + return this.testRunService.fetchQuestionnaireFormat().pipe( + tap((questionnaireFormat: DeviceQuestionnaireSection[]) => { + this.updateQuestionnaireFormat(questionnaireFormat); + }) + ); + }) + ); + }); + private addDevice(device: Device, devices: Device[]): void { this.updateDevices(devices.concat([device])); } @@ -179,6 +199,7 @@ export class DevicesStore extends ComponentStore { devices: [], selectedDevice: null, testModules: [], + questionnaireFormat: [], }); } } diff --git a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.html b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.html index 50f0bf3c7..dc1b0a330 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.html +++ b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.html @@ -15,11 +15,11 @@ -->
-

Profile name *

+

Profile name *

+ class="profile-form-field"> Specify risk assessment profile name Required for saving a profile @@ -43,21 +43,7 @@ - - - +
@@ -92,239 +78,3 @@ Close
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ description }} - - Please, check. “ and \ are not allowed. - - - The field is required - - - The field must be a maximum of - {{ getControl(formControlName).getError('maxlength').requiredLength }} - characters. - - - - - - - - {{ description }} - - Please, check. “ and \ are not allowed. - - - The field is required - - - The field must be a maximum of - {{ getControl(formControlName).getError('maxlength').requiredLength }} - characters. - - - - - - - - {{ description }} - - The field is required - - - Please, check the email address. Valid e-mail can contain only latin - letters, numbers, @ and . (dot). - - - The field must be a maximum of - {{ getControl(formControlName).getError('maxlength').requiredLength }} - characters. - - - - - -
-

- - {{ option }} - -

- {{ - description - }} -
-
- - - - - - {{ option }} - - - {{ - description - }} - - The field is required - - - diff --git a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.scss b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.scss index 4fed0e420..0ee606615 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.scss +++ b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.scss @@ -25,45 +25,20 @@ .profile-form { overflow: scroll; + + .name-field-label { + padding-top: 0; + } .field-container { display: flex; flex-direction: column; align-items: flex-start; padding: 8px 16px 8px 24px; } - .field-label { - margin: 0; - color: $grey-800; - font-size: 18px; - line-height: 24px; - padding-top: 24px; - padding-bottom: 16px; - &:first-child { - padding-top: 0; - } - &:has(+ .field-select-multiple.ng-invalid.ng-dirty) { - color: mat.get-color-from-palette($color-warn, 700); - } - } - mat-form-field { + + .profile-form-field { width: 100%; } - .field-hint { - font-family: $font-secondary; - font-size: 12px; - font-weight: 400; - line-height: 16px; - text-align: left; - padding-top: 8px; - } -} - -.profile-form-field { - width: 100%; -} - -.profile-form-field ::ng-deep .mat-mdc-form-field-textarea-control { - display: inherit; } .form-actions { @@ -76,20 +51,3 @@ .discard-button:not(.mat-mdc-button-disabled) { color: $primary; } - -.field-select-multiple { - .field-select-checkbox { - &:has(::ng-deep .mat-mdc-checkbox-checked) { - background: mat.get-color-from-palette($color-primary, 50); - } - ::ng-deep .mdc-checkbox__ripple { - display: none; - } - &:first-of-type { - margin-top: 0; - } - &:last-of-type { - margin-bottom: 8px; - } - } -} diff --git a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.spec.ts b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.spec.ts index 4a64d2b2c..033a3654d 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.spec.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.spec.ts @@ -27,7 +27,7 @@ import { PROFILE_MOCK_3, RENAME_PROFILE_MOCK, } from '../../../mocks/profile.mock'; -import { FormControlType, ProfileStatus } from '../../../model/profile'; +import { ProfileStatus } from '../../../model/profile'; describe('ProfileFormComponent', () => { let component: ProfileFormComponent; @@ -145,175 +145,6 @@ describe('ProfileFormComponent', () => { }); }); - PROFILE_FORM.forEach((item, index) => { - const uiIndex = index + 1; // as Profile name is at 0 position, the json items start from 1 i - - it(`should have form field with specific type"`, () => { - const fields = compiled.querySelectorAll('.profile-form-field'); - - if (item.type === FormControlType.SELECT) { - const select = fields[uiIndex].querySelector('mat-select'); - expect(select).toBeTruthy(); - } else if (item.type === FormControlType.SELECT_MULTIPLE) { - const select = fields[uiIndex].querySelector('mat-checkbox'); - expect(select).toBeTruthy(); - } else if (item.type === FormControlType.TEXTAREA) { - const input = fields[uiIndex]?.querySelector('textarea'); - expect(input).toBeTruthy(); - } else { - const input = fields[uiIndex]?.querySelector('input'); - expect(input).toBeTruthy(); - } - }); - - it('should have label', () => { - const labels = compiled.querySelectorAll('.field-label'); - const uiIndex = index + 1; // as Profile name is at 0 position, the json items start from 1 i - - const label = item?.validation?.required - ? item.question + ' *' - : item.question; - expect(labels[uiIndex].textContent?.trim()).toEqual(label); - }); - - it('should have hint', () => { - const fields = compiled.querySelectorAll('.profile-form-field'); - const uiIndex = index + 1; // as Profile name is at 0 position, the json items start from 1 i - const hint = fields[uiIndex].querySelector('mat-hint'); - - if (item.description) { - expect(hint?.textContent?.trim()).toEqual(item.description); - } else { - expect(hint).toBeNull(); - } - }); - - if (item.type === FormControlType.SELECT) { - describe('select', () => { - it(`should have default value if provided`, () => { - const fields = compiled.querySelectorAll('.profile-form-field'); - const select = fields[uiIndex].querySelector('mat-select'); - expect(select?.textContent?.trim()).toEqual(item.default || ''); - }); - - it('should have "required" error when field is not filled', () => { - const fields = compiled.querySelectorAll('.profile-form-field'); - - component.getControl(index).setValue(''); - component.getControl(index).markAsTouched(); - - fixture.detectChanges(); - - const error = fields[uiIndex].querySelector('mat-error')?.innerHTML; - - expect(error).toContain('The field is required'); - }); - }); - } - - if (item.type === FormControlType.SELECT_MULTIPLE) { - describe('select multiple', () => { - it(`should mark form group as dirty while tab navigation`, () => { - const fields = compiled.querySelectorAll('.profile-form-field'); - const checkbox = fields[uiIndex].querySelector( - '.field-select-checkbox:last-of-type mat-checkbox' - ); - checkbox?.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab' }) - ); - fixture.detectChanges(); - - expect(component.getControl(index).dirty).toBeTrue(); - }); - }); - } - - if ( - item.type === FormControlType.TEXT || - item.type === FormControlType.TEXTAREA || - item.type === FormControlType.EMAIL_MULTIPLE - ) { - describe('text or text-long or email-multiple', () => { - if (item.validation?.required) { - it('should have "required" error when field is not filled', () => { - const fields = compiled.querySelectorAll('.profile-form-field'); - const uiIndex = index + 1; // as Profile name is at 0 position, the json items start from 1 i - const input = fields[uiIndex].querySelector( - '.mat-mdc-input-element' - ) as HTMLInputElement; - ['', ' '].forEach(value => { - input.value = value; - input.dispatchEvent(new Event('input')); - component.getControl(index).markAsTouched(); - fixture.detectChanges(); - const errors = fields[uiIndex].querySelectorAll('mat-error'); - let hasError = false; - errors.forEach(error => { - if (error.textContent === 'The field is required') { - hasError = true; - } - }); - - expect(hasError).toBeTrue(); - }); - }); - } - - it('should have "invalid_format" error when field does not satisfy validation rules', () => { - const fields = compiled.querySelectorAll('.profile-form-field'); - const uiIndex = index + 1; // as Profile name is at 0 position, the json items start from 1 i - const input: HTMLInputElement = fields[uiIndex].querySelector( - '.mat-mdc-input-element' - ) as HTMLInputElement; - input.value = 'as\\\\\\\\\\""""""""'; - input.dispatchEvent(new Event('input')); - component.getControl(index).markAsTouched(); - fixture.detectChanges(); - const result = - item.type === FormControlType.EMAIL_MULTIPLE - ? 'Please, check the email address. Valid e-mail can contain only latin letters, numbers, @ and . (dot).' - : 'Please, check. “ and \\ are not allowed.'; - const errors = fields[uiIndex].querySelectorAll('mat-error'); - let hasError = false; - errors.forEach(error => { - if (error.textContent === result) { - hasError = true; - } - }); - - expect(hasError).toBeTrue(); - }); - - if (item.validation?.max) { - it('should have "maxlength" error when field is exceeding max length', () => { - const fields = compiled.querySelectorAll('.profile-form-field'); - const uiIndex = index + 1; // as Profile name is at 0 position, the json items start from 1 i - const input: HTMLInputElement = fields[uiIndex].querySelector( - '.mat-mdc-input-element' - ) as HTMLInputElement; - input.value = - 'very long value very long value very long value very long value very long value very long value very long value very long value very long value very long value'; - input.dispatchEvent(new Event('input')); - component.getControl(index).markAsTouched(); - fixture.detectChanges(); - - const errors = fields[uiIndex].querySelectorAll('mat-error'); - let hasError = false; - errors.forEach(error => { - if ( - error.textContent === - `The field must be a maximum of ${item.validation?.max} characters.` - ) { - hasError = true; - } - }); - expect(hasError).toBeTrue(); - }); - } - }); - } - }); - describe('Draft button', () => { it('should be disabled when profile name is empty', () => { component.nameControl.setValue(''); diff --git a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts index 684bc67da..6b0b760eb 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts @@ -39,19 +39,18 @@ import { FormGroup, ReactiveFormsModule, ValidatorFn, - Validators, } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; import { DeviceValidators } from '../../devices/components/device-form/device.validators'; import { - FormControlType, Profile, ProfileFormat, ProfileStatus, Question, - Validation, } from '../../../model/profile'; +import { FormControlType } from '../../../model/question'; import { ProfileValidators } from './profile.validators'; +import { DynamicFormComponent } from '../../../components/dynamic-form/dynamic-form.component'; @Component({ selector: 'app-profile-form', @@ -66,6 +65,7 @@ import { ProfileValidators } from './profile.validators'; MatSelectModule, MatCheckboxModule, TextFieldModule, + DynamicFormComponent, ], templateUrl: './profile-form.component.html', styleUrl: './profile-form.component.scss', @@ -76,7 +76,6 @@ export class ProfileFormComponent implements OnInit { private profileList!: Profile[]; private injector = inject(Injector); private nameValidator!: ValidatorFn; - public readonly FormControlType = FormControlType; public readonly ProfileStatus = ProfileStatus; profileForm: FormGroup = this.fb.group({}); @ViewChildren(CdkTextareaAutosize) @@ -112,7 +111,7 @@ export class ProfileFormComponent implements OnInit { private fb: FormBuilder ) {} ngOnInit() { - this.profileForm = this.createProfileForm(this.profileFormat); + this.profileForm = this.createProfileForm(); if (this.selectedProfile) { this.fillProfileForm(this.profileFormat, this.selectedProfile); } @@ -139,7 +138,7 @@ export class ProfileFormComponent implements OnInit { return this.profileForm.get(name.toString()) as AbstractControl; } - createProfileForm(questions: ProfileFormat[]): FormGroup { + createProfileForm(): FormGroup { // eslint-disable-next-line @typescript-eslint/no-explicit-any const group: any = {}; @@ -154,52 +153,9 @@ export class ProfileFormComponent implements OnInit { this.nameValidator, ]); - questions.forEach((question, index) => { - if (question.type === FormControlType.SELECT_MULTIPLE) { - group[index] = this.getMultiSelectGroup(question); - } else { - const validators = this.getValidators( - question.type, - question.validation - ); - group[index] = new FormControl(question.default || '', validators); - } - }); return new FormGroup(group); } - getValidators(type: FormControlType, validation?: Validation): ValidatorFn[] { - const validators: ValidatorFn[] = []; - if (validation) { - if (validation.required) { - validators.push(this.profileValidators.textRequired()); - } - if (validation.max) { - validators.push(Validators.maxLength(Number(validation.max))); - } - if (type === FormControlType.EMAIL_MULTIPLE) { - validators.push(this.profileValidators.emailStringFormat()); - } - if (type === FormControlType.TEXT || type === FormControlType.TEXTAREA) { - validators.push(this.profileValidators.textFormat()); - } - } - return validators; - } - - getMultiSelectGroup(question: ProfileFormat): FormGroup { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const group: any = {}; - question.options?.forEach((option, index) => { - group[index] = false; - }); - return this.fb.group(group, { - validators: question.validation?.required - ? [this.profileValidators.multiSelectRequired] - : [], - }); - } - getFormGroup(name: string | number): FormGroup { return this.profileForm?.controls[name] as FormGroup; } @@ -233,16 +189,6 @@ export class ProfileFormComponent implements OnInit { this.saveProfile.emit(response); } - public markSectionAsDirty( - optionIndex: number, - optionLength: number, - formControlName: string - ) { - if (optionIndex === optionLength - 1) { - this.getControl(formControlName).markAsDirty(); - } - } - onDiscardClick() { this.discard.emit(); } diff --git a/modules/ui/src/app/services/test-run.service.spec.ts b/modules/ui/src/app/services/test-run.service.spec.ts index 9fec4ffea..42d90f2e2 100644 --- a/modules/ui/src/app/services/test-run.service.spec.ts +++ b/modules/ui/src/app/services/test-run.service.spec.ts @@ -28,7 +28,7 @@ import { StatusOfTestrun, TestrunStatus, } from '../model/testrun-status'; -import { device, MOCK_MODULES } from '../mocks/device.mock'; +import { device, DEVICES_FORM, MOCK_MODULES } from '../mocks/device.mock'; import { NEW_VERSION, VERSION } from '../mocks/version.mock'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { AppState } from '../store/state'; @@ -659,4 +659,20 @@ describe('TestRunService', () => { req.flush(data, mockErrorResponse); }); }); + + describe('fetchQuestionnaireFormat', () => { + it('should get system status data with no changes', () => { + const result = { ...DEVICES_FORM }; + + service.fetchQuestionnaireFormat().subscribe(res => { + expect(res).toEqual(result); + }); + + const req = httpTestingController.expectOne( + 'http://localhost:8000/devices/format' + ); + expect(req.request.method).toBe('GET'); + req.flush(result); + }); + }); }); diff --git a/modules/ui/src/app/services/test-run.service.ts b/modules/ui/src/app/services/test-run.service.ts index 0ac2b1510..37597efd0 100644 --- a/modules/ui/src/app/services/test-run.service.ts +++ b/modules/ui/src/app/services/test-run.service.ts @@ -17,7 +17,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { Observable } from 'rxjs/internal/Observable'; -import { Device, TestModule } from '../model/device'; +import { Device, DeviceQuestionnaireSection } from '../model/device'; import { catchError, map, of, retry } from 'rxjs'; import { SystemConfig, SystemInterfaces } from '../model/setting'; import { @@ -49,39 +49,6 @@ export const UNAVAILABLE_VERSION = { providedIn: 'root', }) export class TestRunService { - private readonly testModules: TestModule[] = [ - { - displayName: 'Connection', - name: 'connection', - enabled: true, - }, - { - displayName: 'NTP', - name: 'ntp', - enabled: true, - }, - { - displayName: 'DNS', - name: 'dns', - enabled: true, - }, - { - displayName: 'Services', - name: 'services', - enabled: true, - }, - { - displayName: 'TLS', - name: 'tls', - enabled: true, - }, - { - displayName: 'Protocol', - name: 'protocol', - enabled: true, - }, - ]; - private version = new BehaviorSubject(null); constructor(private http: HttpClient) {} @@ -307,6 +274,12 @@ export class TestRunService { return this.http.get(`${API_URL}/profiles/format`); } + fetchQuestionnaireFormat(): Observable { + return this.http.get( + `${API_URL}/devices/format` + ); + } + saveProfile(profile: Profile): Observable { return this.http .post(`${API_URL}/profiles`, JSON.stringify(profile))