diff --git a/modules/ui/src/app/components/stepper/stepper.component.html b/modules/ui/src/app/components/stepper/stepper.component.html
new file mode 100644
index 000000000..e2a9d1adb
--- /dev/null
+++ b/modules/ui/src/app/components/stepper/stepper.component.html
@@ -0,0 +1,35 @@
+
diff --git a/modules/ui/src/app/components/stepper/stepper.component.scss b/modules/ui/src/app/components/stepper/stepper.component.scss
new file mode 100644
index 000000000..87c7ffecd
--- /dev/null
+++ b/modules/ui/src/app/components/stepper/stepper.component.scss
@@ -0,0 +1,83 @@
+/**
+ * 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 '../../../theming/colors';
+
+.form-container {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.form-header {
+ padding: 24px;
+}
+
+.form-content {
+ display: grid;
+ padding: 24px;
+ gap: 10px;
+ overflow: auto;
+}
+
+.form-footer {
+ margin-top: auto;
+ display: inline-block;
+ text-align: center;
+ padding-bottom: 24px;
+ width: 100%;
+}
+
+.form-steps {
+ display: inline-flex;
+ text-align: center;
+ align-items: center;
+ height: 100%;
+}
+
+.form-step {
+ border: 2px solid $lighter-grey;
+ width: 4px;
+ height: 4px;
+ display: inline-block;
+ border-radius: 50%;
+ margin: 0 8px;
+ &.step-active {
+ border-color: $secondary;
+ background: $secondary;
+ }
+}
+
+.form-button-back {
+ float: left;
+}
+
+.form-button-forward {
+ float: right;
+}
+
+.form-button-back,
+.form-button-forward {
+ & mat-icon {
+ color: $secondary;
+ width: 24px;
+ height: 24px;
+ font-size: 24px;
+ }
+
+ &.hidden {
+ display: none;
+ }
+}
diff --git a/modules/ui/src/app/components/stepper/stepper.component.spec.ts b/modules/ui/src/app/components/stepper/stepper.component.spec.ts
new file mode 100644
index 000000000..aa3fad17b
--- /dev/null
+++ b/modules/ui/src/app/components/stepper/stepper.component.spec.ts
@@ -0,0 +1,36 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { StepperComponent } from './stepper.component';
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'app-stepper-bypass',
+ template:
+ '' +
+ ' Header
',
+})
+class TestStepperComponent {}
+
+describe('StepperComponent', () => {
+ let component: TestStepperComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [StepperComponent],
+ declarations: [TestStepperComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestStepperComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should have title', () => {
+ expect(fixture.nativeElement.querySelector('.form-header')).toBeTruthy();
+ });
+});
diff --git a/modules/ui/src/app/components/stepper/stepper.component.ts b/modules/ui/src/app/components/stepper/stepper.component.ts
new file mode 100644
index 000000000..ea1377c33
--- /dev/null
+++ b/modules/ui/src/app/components/stepper/stepper.component.ts
@@ -0,0 +1,36 @@
+import { Component, Input, TemplateRef } from '@angular/core';
+import { CdkStepper, CdkStepperModule } from '@angular/cdk/stepper';
+import { NgForOf, NgIf, NgTemplateOutlet } from '@angular/common';
+import { MatIcon } from '@angular/material/icon';
+import { MatButton, MatIconButton } from '@angular/material/button';
+
+@Component({
+ selector: 'app-stepper',
+ standalone: true,
+ imports: [
+ NgForOf,
+ NgTemplateOutlet,
+ CdkStepperModule,
+ NgIf,
+ MatIcon,
+ MatIconButton,
+ MatButton,
+ ],
+ templateUrl: './stepper.component.html',
+ styleUrl: './stepper.component.scss',
+ providers: [{ provide: CdkStepper, useExisting: StepperComponent }],
+})
+export class StepperComponent extends CdkStepper {
+ @Input() header: TemplateRef | undefined;
+ @Input() activeClass = 'active';
+
+ stepsCount = [1, 2, 3, 4]; //TODO will be removed when all steps are implemented
+
+ forwardButtonHidden() {
+ return this.selectedIndex === this.stepsCount.length - 1;
+ }
+
+ backButtonHidden() {
+ return this.selectedIndex === 0;
+ }
+}
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
new file mode 100644
index 000000000..b0253ebc8
--- /dev/null
+++ b/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.html
@@ -0,0 +1,137 @@
+
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
new file mode 100644
index 000000000..cd5ce35b7
--- /dev/null
+++ b/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.scss
@@ -0,0 +1,159 @@
+/**
+ * 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 'src/theming/colors';
+@import 'src/theming/variables';
+
+$form-max-width: 732px;
+$form-min-width: 732px;
+$form-height: 993px;
+
+:host {
+ display: grid;
+ grid-template-rows: 1fr;
+ overflow: auto;
+ grid-template-columns: minmax(285px, $form-max-width);
+}
+
+.device-qualification-form {
+ 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;
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ grid-template-rows: repeat(4, 1fr);
+ grid-auto-flow: column;
+ padding-top: 16px;
+ p {
+ margin: 8px 0;
+ }
+}
+
+.device-form-actions {
+ padding: 0;
+ min-height: 30px;
+}
+
+.close-button {
+ color: $primary;
+}
+
+.device-form-mac-address-error {
+ white-space: nowrap;
+}
+
+.delete-button {
+ color: $primary;
+ margin-right: auto;
+}
+
+.hidden {
+ display: none;
+}
+
+.device-qualification-form-header {
+ position: relative;
+ padding-top: 24px;
+ &-title {
+ margin: 0;
+ font-size: 22px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 28px;
+ color: $dark-grey;
+ text-align: center;
+ padding: 38px 0;
+ background-image: url(/assets/icons/create_device_header.svg);
+ }
+
+ &-close-button {
+ position: absolute;
+ right: 0;
+ top: 0;
+ min-width: 24px;
+ width: 24px;
+ height: 24px;
+ box-sizing: content-box;
+ line-height: normal !important;
+ padding: 0;
+ margin: 0;
+
+ .close-button-icon {
+ width: 24px;
+ height: 24px;
+ margin: 0;
+ }
+
+ ::ng-deep * {
+ line-height: inherit !important;
+ }
+ }
+}
+
+.device-qualification-form-journey-label {
+ font-family: $font-secondary;
+ font-style: normal;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 24px;
+ letter-spacing: 0.1px;
+ color: $grey-800;
+ margin: 24px 16px 0 16px;
+}
+
+.device-qualification-form-journey-button {
+ padding: 0 18px 0 24px;
+}
+
+.device-qualification-form-journey-button-label {
+ font-family: $font-secondary;
+ font-style: normal;
+ font-weight: bold;
+ font-size: 14px;
+ line-height: 20px;
+ letter-spacing: 0.2px;
+ color: $grey-800;
+}
+
+.device-qualification-form-test-modules-container {
+ padding: 0 24px;
+}
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
new file mode 100644
index 000000000..7269cc539
--- /dev/null
+++ b/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.spec.ts
@@ -0,0 +1,318 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DeviceQualificationFromComponent } from './device-qualification-from.component';
+import {
+ MAT_DIALOG_DATA,
+ MatDialogModule,
+ MatDialogRef,
+} from '@angular/material/dialog';
+import { of } from 'rxjs';
+import { NgxMaskDirective, NgxMaskPipe, provideNgxMask } from 'ngx-mask';
+import { MatButtonModule } from '@angular/material/button';
+import { 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 { MatIconTestingModule } from '@angular/material/icon/testing';
+describe('DeviceQualificationFromComponent', () => {
+ let component: DeviceQualificationFromComponent;
+ let fixture: ComponentFixture;
+ let compiled: HTMLElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ DeviceQualificationFromComponent,
+ MatButtonModule,
+ ReactiveFormsModule,
+ MatCheckboxModule,
+ MatInputModule,
+ MatDialogModule,
+ BrowserAnimationsModule,
+ DeviceTestsComponent,
+ SpinnerComponent,
+ NgxMaskDirective,
+ NgxMaskPipe,
+ MatIconTestingModule,
+ ],
+ providers: [
+ {
+ provide: MatDialogRef,
+ useValue: {
+ keydownEvents: () => of(new KeyboardEvent('keydown', { code: '' })),
+ close: () => ({}),
+ },
+ },
+ { provide: MAT_DIALOG_DATA, useValue: {} },
+ provideNgxMask(),
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DeviceQualificationFromComponent);
+ component = fixture.componentInstance;
+ compiled = fixture.nativeElement as HTMLElement;
+ component.data = {
+ testModules: MOCK_TEST_MODULES,
+ devices: [],
+ };
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should contain device form', () => {
+ const form = compiled.querySelector('.device-qualification-form');
+
+ expect(form).toBeTruthy();
+ });
+
+ it('should close dialog on "cancel" click', () => {
+ const closeSpy = spyOn(component.dialogRef, 'close');
+ const closeButton = compiled.querySelector(
+ '.device-qualification-form-header-close-button'
+ ) as HTMLButtonElement;
+
+ closeButton?.click();
+
+ expect(closeSpy).toHaveBeenCalledWith();
+
+ closeSpy.calls.reset();
+ });
+
+ it('should not save data when fields are empty', () => {
+ const forwardButton = compiled.querySelector(
+ '.form-button-forward'
+ ) as HTMLButtonElement;
+ const model: HTMLInputElement = compiled.querySelector(
+ '.device-qualification-form-model'
+ ) as HTMLInputElement;
+ const manufacturer: HTMLInputElement = compiled.querySelector(
+ '.device-qualification-form-manufacturer'
+ ) as HTMLInputElement;
+ const macAddress: HTMLInputElement = compiled.querySelector(
+ '.device-qualification-form-mac-address'
+ ) as HTMLInputElement;
+
+ ['', ' '].forEach(value => {
+ model.value = value;
+ model.dispatchEvent(new Event('input'));
+ manufacturer.value = value;
+ manufacturer.dispatchEvent(new Event('input'));
+ macAddress.value = value;
+ macAddress.dispatchEvent(new Event('input'));
+ forwardButton?.click();
+ fixture.detectChanges();
+
+ const requiredErrors = compiled.querySelectorAll('mat-error');
+ expect(requiredErrors?.length).toEqual(3);
+
+ requiredErrors.forEach(error => {
+ expect(error?.innerHTML).toContain('required');
+ });
+ });
+ });
+
+ describe('test modules', () => {
+ it('should be present', () => {
+ const test = compiled.querySelectorAll('mat-checkbox');
+
+ expect(test.length).toEqual(2);
+ });
+
+ it('should be enabled', () => {
+ const tests = compiled.querySelectorAll('.device-form-test-modules p');
+
+ expect(tests[0].classList.contains('disabled')).toEqual(false);
+ });
+ });
+
+ describe('device model', () => {
+ it('should not contain errors when input is correct', () => {
+ const model: HTMLInputElement = compiled.querySelector(
+ '.device-qualification-form-model'
+ ) as HTMLInputElement;
+ ['model', 'Gebäude', 'jardĂn'].forEach(value => {
+ model.value = value;
+ model.dispatchEvent(new Event('input'));
+
+ const errors = component.model.errors;
+ const uiValue = model.value;
+ const formValue = component.model.value;
+
+ expect(uiValue).toEqual(formValue);
+ expect(errors).toBeNull();
+ });
+ });
+
+ it('should have "invalid_format" error when field does not satisfy validation rules', () => {
+ [
+ 'very long value very long value very long value very long value very long value very long value very long value',
+ 'as&@3$',
+ ].forEach(value => {
+ const model: HTMLInputElement = compiled.querySelector(
+ '.device-qualification-form-model'
+ ) as HTMLInputElement;
+ model.value = value;
+ model.dispatchEvent(new Event('input'));
+ component.model.markAsTouched();
+ fixture.detectChanges();
+
+ const modelError = compiled.querySelector('mat-error')?.innerHTML;
+ const error = component.model.hasError('invalid_format');
+
+ expect(error).toBeTruthy();
+ expect(modelError).toContain(
+ 'The device model name must be a maximum of 28 characters. Only letters, numbers, and accented letters are permitted.'
+ );
+ });
+ });
+ });
+
+ describe('device manufacturer', () => {
+ it('should not contain errors when input is correct', () => {
+ const manufacturer: HTMLInputElement = compiled.querySelector(
+ '.device-qualification-form-manufacturer'
+ ) as HTMLInputElement;
+ ['manufacturer', 'Gebäude', 'jardĂn'].forEach(value => {
+ manufacturer.value = value;
+ manufacturer.dispatchEvent(new Event('input'));
+
+ const errors = component.manufacturer.errors;
+ const uiValue = manufacturer.value;
+ const formValue = component.manufacturer.value;
+
+ expect(uiValue).toEqual(formValue);
+ expect(errors).toBeNull();
+ });
+ });
+
+ it('should have "invalid_format" error when field does not satisfy validation', () => {
+ [
+ 'very long value very long value very long value very long value very long value very long value very long value',
+ 'as&@3$',
+ ].forEach(value => {
+ const manufacturer: HTMLInputElement = compiled.querySelector(
+ '.device-qualification-form-manufacturer'
+ ) as HTMLInputElement;
+ manufacturer.value = value;
+ manufacturer.dispatchEvent(new Event('input'));
+ component.manufacturer.markAsTouched();
+ fixture.detectChanges();
+
+ const manufacturerError =
+ compiled.querySelector('mat-error')?.innerHTML;
+ const error = component.manufacturer.hasError('invalid_format');
+
+ expect(error).toBeTruthy();
+ expect(manufacturerError).toContain(
+ 'The manufacturer name must be a maximum of 28 characters. Only letters, numbers, and accented letters are permitted.'
+ );
+ });
+ });
+ });
+
+ describe('mac address', () => {
+ it('should not be disabled', () => {
+ expect(component.mac_addr.disabled).toBeFalse();
+ });
+
+ it('should not contain errors when input is correct', () => {
+ const macAddress: HTMLInputElement = compiled.querySelector(
+ '.device-qualification-form-mac-address'
+ ) as HTMLInputElement;
+ ['07:07:07:07:07:07', ' 07:07:07:07:07:07 '].forEach(value => {
+ macAddress.value = value;
+ macAddress.dispatchEvent(new Event('input'));
+
+ const errors = component.mac_addr.errors;
+ const formValue = component.mac_addr.value;
+
+ expect(macAddress.value).toEqual(formValue);
+ expect(errors).toBeNull();
+ });
+ });
+
+ it('should have "pattern" error when field does not satisfy pattern', () => {
+ ['value', 'q01e423573c4'].forEach(value => {
+ const macAddress: HTMLInputElement = compiled.querySelector(
+ '.device-qualification-form-mac-address'
+ ) as HTMLInputElement;
+ macAddress.value = value;
+ macAddress.dispatchEvent(new Event('input'));
+ component.mac_addr.markAsTouched();
+ fixture.detectChanges();
+
+ const macAddressError = compiled.querySelector('mat-error')?.innerHTML;
+ const error = component.mac_addr.hasError('pattern');
+
+ expect(error).toBeTruthy();
+ expect(macAddressError).toContain(
+ 'Please, check. A MAC address consists of 12 hexadecimal digits (0 to 9, a to f, or A to F).'
+ );
+ });
+ });
+
+ it('should have "has_same_mac_address" error when MAC address is already used', () => {
+ component.data = {
+ testModules: MOCK_TEST_MODULES,
+ devices: [device],
+ };
+ component.ngOnInit();
+ fixture.detectChanges();
+
+ const macAddress: HTMLInputElement = compiled.querySelector(
+ '.device-qualification-form-mac-address'
+ ) as HTMLInputElement;
+ macAddress.value = '00:1e:42:35:73:c4';
+ macAddress.dispatchEvent(new Event('input'));
+ component.mac_addr.markAsTouched();
+ fixture.detectChanges();
+
+ const macAddressError = compiled.querySelector('mat-error')?.innerHTML;
+ const error = component.mac_addr.hasError('has_same_mac_address');
+
+ expect(error).toBeTruthy();
+ expect(macAddressError).toContain(
+ 'This MAC address is already used for another device in the repository.'
+ );
+ });
+ });
+
+ describe('when device is present', () => {
+ beforeEach(() => {
+ component.data = {
+ devices: [device],
+ testModules: MOCK_TEST_MODULES,
+ device: {
+ manufacturer: 'Delta',
+ model: 'O3-DIN-CPU',
+ mac_addr: '00:1e:42:35:73:c4',
+ test_modules: {
+ udmi: {
+ enabled: true,
+ },
+ },
+ },
+ };
+ component.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should fill form values with device values', () => {
+ const model: HTMLInputElement = compiled.querySelector(
+ '.device-qualification-form-model'
+ ) as HTMLInputElement;
+ const manufacturer: HTMLInputElement = compiled.querySelector(
+ '.device-qualification-form-manufacturer'
+ ) as HTMLInputElement;
+
+ expect(model.value).toEqual('O3-DIN-CPU');
+ expect(manufacturer.value).toEqual('Delta');
+ });
+ });
+});
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
new file mode 100644
index 000000000..cd42d4b2a
--- /dev/null
+++ b/modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.ts
@@ -0,0 +1,184 @@
+import { Component, Inject, OnInit } from '@angular/core';
+import {
+ AbstractControl,
+ FormArray,
+ FormBuilder,
+ FormGroup,
+ ReactiveFormsModule,
+ Validators,
+} from '@angular/forms';
+import { DeviceValidators } from '../device-form/device.validators';
+import { Device, 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';
+import { CdkStep } from '@angular/cdk/stepper';
+import { StepperComponent } from '../../../../components/stepper/stepper.component';
+import {
+ MatError,
+ MatFormField,
+ MatFormFieldModule,
+} from '@angular/material/form-field';
+import { DeviceTestsComponent } from '../../../../components/device-tests/device-tests.component';
+import { MatButtonModule } from '@angular/material/button';
+import { MatInputModule } from '@angular/material/input';
+import { MatSelectModule } from '@angular/material/select';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { TextFieldModule } from '@angular/cdk/text-field';
+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';
+
+const MAC_ADDRESS_PATTERN =
+ '^[\\s]*[a-fA-F0-9]{2}(?:[:][a-fA-F0-9]{2}){5}[\\s]*$';
+
+interface DialogData {
+ title?: string;
+ device?: Device;
+ devices: Device[];
+ testModules: TestModule[];
+}
+
+@Component({
+ selector: 'app-device-qualification-from',
+ standalone: true,
+ imports: [
+ CdkStep,
+ StepperComponent,
+ MatFormField,
+ DeviceTestsComponent,
+ MatButtonModule,
+ CommonModule,
+ ReactiveFormsModule,
+ MatInputModule,
+ MatError,
+ MatFormFieldModule,
+ MatSelectModule,
+ MatCheckboxModule,
+ TextFieldModule,
+ NgxMaskDirective,
+ NgxMaskPipe,
+ MatIcon,
+ MatRadioGroup,
+ MatRadioButton,
+ ],
+ providers: [provideNgxMask()],
+ templateUrl: './device-qualification-from.component.html',
+ styleUrl: './device-qualification-from.component.scss',
+})
+export class DeviceQualificationFromComponent
+ extends EscapableDialogComponent
+ implements OnInit
+{
+ testModules: TestModule[] = [];
+ deviceQualificationForm!: FormGroup;
+ firstStep!: FormGroup;
+ device: Device | undefined;
+
+ get model() {
+ return this.firstStep.get('model') as AbstractControl;
+ }
+
+ get manufacturer() {
+ return this.firstStep.get('manufacturer') as AbstractControl;
+ }
+
+ get mac_addr() {
+ return this.firstStep.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;
+ }
+
+ constructor(
+ private fb: FormBuilder,
+ private deviceValidators: DeviceValidators,
+ private profileValidators: ProfileValidators,
+ public override dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: DialogData
+ ) {
+ super(dialogRef);
+ this.device = data.device;
+ }
+
+ ngOnInit(): void {
+ this.createDeviceForm();
+ this.testModules = this.data.testModules;
+ 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);
+ }
+ }
+
+ submit(): void {
+ this.device = this.createDeviceFromForm(this.first_step);
+ }
+
+ closeForm(): void {
+ this.dialogRef.close();
+ }
+
+ private createDeviceFromForm(formGroup: FormGroup): Device {
+ const testModules: { [key: string]: { enabled: boolean } } = {};
+ formGroup.value.test_modules.forEach((enabled: boolean, i: number) => {
+ testModules[this.testModules[i]?.name] = {
+ enabled: enabled,
+ };
+ });
+ return {
+ model: this.model.value.trim(),
+ manufacturer: this.manufacturer.value.trim(),
+ mac_addr: this.mac_addr.value.trim(),
+ test_modules: testModules,
+ } as Device;
+ }
+
+ private createDeviceForm() {
+ this.firstStep = this.fb.group({
+ model: [
+ '',
+ [
+ this.profileValidators.textRequired(),
+ this.deviceValidators.deviceStringFormat(),
+ ],
+ ],
+ manufacturer: [
+ '',
+ [
+ this.profileValidators.textRequired(),
+ this.deviceValidators.deviceStringFormat(),
+ ],
+ ],
+ mac_addr: [
+ '',
+ [
+ this.profileValidators.textRequired(),
+ Validators.pattern(MAC_ADDRESS_PATTERN),
+ this.deviceValidators.differentMACAddress(
+ this.data.devices,
+ this.data.device
+ ),
+ ],
+ ],
+ test_modules: new FormArray([]),
+ testing_journey: [0],
+ });
+ this.deviceQualificationForm = this.fb.group({
+ steps: this.fb.array([this.firstStep, this.fb.group({})]),
+ });
+ }
+}
diff --git a/modules/ui/src/app/pages/devices/devices.component.spec.ts b/modules/ui/src/app/pages/devices/devices.component.spec.ts
index ba99b6a9a..26f7a569a 100644
--- a/modules/ui/src/app/pages/devices/devices.component.spec.ts
+++ b/modules/ui/src/app/pages/devices/devices.component.spec.ts
@@ -25,10 +25,7 @@ import { Device } from '../../model/device';
import { DevicesComponent } from './devices.component';
import { DevicesModule } from './devices.module';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
-import {
- DeviceFormComponent,
- FormAction,
-} from './components/device-form/device-form.component';
+import { FormAction } from './components/device-form/device-form.component';
import { MatDialogRef } from '@angular/material/dialog';
import { device, MOCK_TEST_MODULES } from '../../mocks/device.mock';
import { SimpleDialogComponent } from '../../components/simple-dialog/simple-dialog.component';
@@ -42,6 +39,7 @@ import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { Component } from '@angular/core';
import { MOCK_PROGRESS_DATA_IN_PROGRESS } from '../../mocks/testrun.mock';
+import { DeviceQualificationFromComponent } from './components/device-qualification-from/device-qualification-from.component';
describe('DevicesComponent', () => {
let component: DevicesComponent;
@@ -149,7 +147,7 @@ describe('DevicesComponent', () => {
it('should open device dialog on "add device button" click', () => {
const openSpy = spyOn(component.dialog, 'open').and.returnValue({
afterClosed: () => of(true),
- } as MatDialogRef);
+ } as MatDialogRef);
fixture.detectChanges();
const button = compiled.querySelector(
'.device-add-button'
@@ -158,11 +156,11 @@ describe('DevicesComponent', () => {
expect(button).toBeTruthy();
expect(openSpy).toHaveBeenCalled();
- expect(openSpy).toHaveBeenCalledWith(DeviceFormComponent, {
- ariaLabel: 'Create device',
+ expect(openSpy).toHaveBeenCalledWith(DeviceQualificationFromComponent, {
+ ariaLabel: 'Create Device',
data: {
device: null,
- title: 'Create device',
+ title: 'Create Device',
testModules: [],
devices: [device, device, device],
},
@@ -179,13 +177,13 @@ describe('DevicesComponent', () => {
it('should open device dialog on item click', () => {
const openSpy = spyOn(component.dialog, 'open').and.returnValue({
afterClosed: () => of(true),
- } as MatDialogRef);
+ } as MatDialogRef);
fixture.detectChanges();
component.openDialog([device], MOCK_TEST_MODULES, device);
expect(openSpy).toHaveBeenCalled();
- expect(openSpy).toHaveBeenCalledWith(DeviceFormComponent, {
+ expect(openSpy).toHaveBeenCalledWith(DeviceQualificationFromComponent, {
ariaLabel: 'Edit device',
data: {
device: device,
@@ -205,12 +203,12 @@ describe('DevicesComponent', () => {
it('should open device dialog with delete-button focus element', () => {
const openSpy = spyOn(component.dialog, 'open').and.returnValue({
afterClosed: () => of(true),
- } as MatDialogRef);
+ } as MatDialogRef);
fixture.detectChanges();
component.openDialog([device], MOCK_TEST_MODULES, device, true);
- expect(openSpy).toHaveBeenCalledWith(DeviceFormComponent, {
+ expect(openSpy).toHaveBeenCalledWith(DeviceQualificationFromComponent, {
ariaLabel: 'Edit device',
data: {
device: device,
@@ -238,7 +236,7 @@ describe('DevicesComponent', () => {
it('should call setIsOpenAddDevice if dialog closes with null', () => {
spyOn(component.dialog, 'open').and.returnValue({
afterClosed: () => of(null),
- } as MatDialogRef);
+ } as MatDialogRef);
component.openDialog([], MOCK_TEST_MODULES);
@@ -252,7 +250,7 @@ describe('DevicesComponent', () => {
device,
action: FormAction.Delete,
}),
- } as MatDialogRef);
+ } as MatDialogRef);
component.openDialog([device], MOCK_TEST_MODULES, device);
diff --git a/modules/ui/src/app/pages/devices/devices.component.ts b/modules/ui/src/app/pages/devices/devices.component.ts
index be172aed3..e320663bf 100644
--- a/modules/ui/src/app/pages/devices/devices.component.ts
+++ b/modules/ui/src/app/pages/devices/devices.component.ts
@@ -23,7 +23,6 @@ import {
import { MatDialog } from '@angular/material/dialog';
import { Device, DeviceView, TestModule } from '../../model/device';
import {
- DeviceFormComponent,
FormAction,
FormResponse,
} from './components/device-form/device-form.component';
@@ -36,6 +35,7 @@ import { Router } from '@angular/router';
import { timer } from 'rxjs/internal/observable/timer';
import { TestrunInitiateFormComponent } from '../testrun/components/testrun-initiate-form/testrun-initiate-form.component';
import { DevicesStore } from './devices.store';
+import { DeviceQualificationFromComponent } from './components/device-qualification-from/device-qualification-from.component';
@Component({
selector: 'app-device-repository',
@@ -115,11 +115,11 @@ export class DevicesComponent implements OnInit, OnDestroy {
selectedDevice?: Device,
focusDeleteButton = false
): void {
- const dialogRef = this.dialog.open(DeviceFormComponent, {
- ariaLabel: selectedDevice ? 'Edit device' : 'Create device',
+ const dialogRef = this.dialog.open(DeviceQualificationFromComponent, {
+ ariaLabel: selectedDevice ? 'Edit device' : 'Create Device',
data: {
device: selectedDevice || null,
- title: selectedDevice ? 'Edit device' : 'Create device',
+ title: selectedDevice ? 'Edit device' : 'Create Device',
testModules: testModules,
devices,
},
diff --git a/modules/ui/src/assets/icons/create_device_header.svg b/modules/ui/src/assets/icons/create_device_header.svg
new file mode 100644
index 000000000..fd10cfa8f
--- /dev/null
+++ b/modules/ui/src/assets/icons/create_device_header.svg
@@ -0,0 +1,64 @@
+