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 @@
+
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 @@
-->
@@ -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))