From 740cff441c8c6f370a6787619d39ebd034d32b26 Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Thu, 1 Aug 2024 11:21:31 +0100 Subject: [PATCH 01/14] Add not started and disabled test results --- .../python/src/test_orc/test_orchestrator.py | 46 ++++++++++++++----- modules/test/base/python/src/test_module.py | 2 + 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index 13b466337..d0a64af07 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -51,10 +51,6 @@ def __init__(self, session, net_orc): str(self._session.get_api_port())) self._net_orc = net_orc self._test_in_progress = False - self._path = os.path.dirname( - os.path.dirname( - os.path.dirname( - os.path.dirname(os.path.dirname(os.path.realpath(__file__)))))) self._root_path = os.path.dirname( os.path.dirname( @@ -87,19 +83,41 @@ def run_test_modules(self): device = self._session.get_target_device() self._test_in_progress = True + LOGGER.info( f"Running test modules on device with mac addr {device.mac_addr}") test_modules = [] + for module in self._test_modules: + # Ignore test modules that are just base images etc if module is None or not module.enable_container: continue + # Ignore test modules that are disabled for this device if not self._is_module_enabled(module, device): continue + # Add module to list of modules to run test_modules.append(module) + + for test in module.tests: + + # Duplicate test obj so we don't alter the source + test_copy = copy.deepcopy(test) + + # Set result to Not Started + test_copy.result = "Not Started" + + # We don't want steps to resolve for not started tests + if hasattr(test_copy, "recommendations"): + test_copy.recommendations = None + + # Add test result to the session + self.get_session().add_test_result(test_copy) + + # Increment number of tests that will be run self.get_session().add_total_tests(len(module.tests)) for module in test_modules: @@ -540,13 +558,14 @@ def _run_test_module(self, module): LOGGER.info(f"Test module {module.name} has finished") - # Resolve all current log data in the containers log_stream - # this method is blocking so should be called in - # a thread or within a proper blocking context def _get_container_logs(self, log_stream): + """Resolve all current log data in the containers log_stream + this method is blocking so should be called in + a thread or within a proper blocking context""" self._container_logs = [] for log_chunk in log_stream: lines = log_chunk.decode("utf-8").splitlines() + # Process each line and strip blank space processed_lines = [line.strip() for line in lines if line.strip()] self._container_logs.extend(processed_lines) @@ -585,7 +604,7 @@ def _load_test_modules(self): LOGGER.debug("Loading test modules from /" + TEST_MODULES_DIR) loaded_modules = "Loaded the following test modules: " - test_modules_dir = os.path.join(self._path, TEST_MODULES_DIR) + test_modules_dir = os.path.join(self._root_path, TEST_MODULES_DIR) module_dirs = os.listdir(test_modules_dir) # Check if the directory protocol exists and move it to the beginning @@ -607,11 +626,14 @@ def _load_test_module(self, module_dir): LOGGER.debug(f"Loading test module {module_dir}") - modules_dir = os.path.join(self._path, TEST_MODULES_DIR) + modules_dir = os.path.join(self._root_path, TEST_MODULES_DIR) # Load basic module information module = TestModule() - with open(os.path.join(self._path, modules_dir, module_dir, MODULE_CONFIG), + with open(os.path.join(self._root_path, + modules_dir, + module_dir, + MODULE_CONFIG), encoding="UTF-8") as module_config_file: module_json = json.load(module_config_file) @@ -622,7 +644,7 @@ def _load_test_module(self, module_dir): if "enabled" in module_json["config"]: module.enabled = module_json["config"]["enabled"] - module.dir = os.path.join(self._path, modules_dir, module_dir) + module.dir = os.path.join(self._root_path, modules_dir, module_dir) module.dir_name = module_dir module.build_file = module_dir + ".Dockerfile" module.container_name = "tr-ct-" + module.dir_name + "-test" @@ -681,7 +703,7 @@ def _build_test_module(self, module): try: client.images.build( dockerfile=os.path.join(module.dir, module.build_file), - path=self._path, + path=self._root_path, forcerm=True, # Cleans up intermediate containers during build tag=module.image_name) except docker.errors.BuildError as error: diff --git a/modules/test/base/python/src/test_module.py b/modules/test/base/python/src/test_module.py index dcd9be905..d39d6ee00 100644 --- a/modules/test/base/python/src/test_module.py +++ b/modules/test/base/python/src/test_module.py @@ -119,6 +119,8 @@ def run_tests(self): test['description'] = 'This test could not be found' else: LOGGER.debug(f'Test {test["name"]} is disabled') + test['result'] = 'Disabled' + test['description'] = 'This test did not run because it is disabled' # To be added in v1.3.2 # result = 'Disabled', 'This test is disabled and did not run' From 5731e0ef8b41ef776c7b3d15d80d08bab01d8f4e Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Thu, 1 Aug 2024 11:30:42 +0100 Subject: [PATCH 02/14] Small fix --- framework/python/src/common/session.py | 1 - 1 file changed, 1 deletion(-) diff --git a/framework/python/src/common/session.py b/framework/python/src/common/session.py index feee6e8a0..a2cbd3b30 100644 --- a/framework/python/src/common/session.py +++ b/framework/python/src/common/session.py @@ -332,7 +332,6 @@ def add_test_result(self, result): updated = True if not updated: - result.result = 'In Progress' self._results.append(result) def add_module_report(self, module_report): From e99e3086cab31774b83124fdf493ab0cb3b463d1 Mon Sep 17 00:00:00 2001 From: Sofia Kurilova Date: Tue, 13 Aug 2024 11:04:26 +0200 Subject: [PATCH 03/14] Adds qualification form and creates first step (#670) * Adds qualification form and creates first step --- .../components/stepper/stepper.component.html | 35 ++ .../components/stepper/stepper.component.scss | 83 +++++ .../stepper/stepper.component.spec.ts | 36 ++ .../components/stepper/stepper.component.ts | 36 ++ .../device-qualification-from.component.html | 137 ++++++++ .../device-qualification-from.component.scss | 159 +++++++++ ...evice-qualification-from.component.spec.ts | 318 ++++++++++++++++++ .../device-qualification-from.component.ts | 184 ++++++++++ .../pages/devices/devices.component.spec.ts | 26 +- .../app/pages/devices/devices.component.ts | 8 +- .../src/assets/icons/create_device_header.svg | 64 ++++ 11 files changed, 1068 insertions(+), 18 deletions(-) create mode 100644 modules/ui/src/app/components/stepper/stepper.component.html create mode 100644 modules/ui/src/app/components/stepper/stepper.component.scss create mode 100644 modules/ui/src/app/components/stepper/stepper.component.spec.ts create mode 100644 modules/ui/src/app/components/stepper/stepper.component.ts create mode 100644 modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.html create mode 100644 modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.scss create mode 100644 modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.spec.ts create mode 100644 modules/ui/src/app/pages/devices/components/device-qualification-from/device-qualification-from.component.ts create mode 100644 modules/ui/src/assets/icons/create_device_header.svg 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 @@ +
+ +
+ +

{{ data.title }}

+
+
+ + + + + Device Manufacturer + + Please enter device manufacturer name + + Please, check. The manufacturer name must be a maximum of 28 + characters. Only letters, numbers, and accented letters are + permitted. + + + Device Manufacturer is required + + + + Device Model + + Please enter device name + + Please, check. The device model name must be a maximum of 28 + characters. Only letters, numbers, and accented letters are + permitted. + + + Device Model is required + + + + MAC address + + Please enter MAC address + + MAC address is required + + + Please, check. A MAC address consists of 12 hexadecimal digits (0 + to 9, a to f, or A to F). + + + This MAC address is already used for another device in the + repository. + + + + Please, select the testing journey for device + + + + + + 🛡️ Device Qualification + + + + + + 🚀 Pilot Assessment + + + + + + + + + + Second 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 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 2efd81cecee54bc267d92ec1567d12d48b002d15 Mon Sep 17 00:00:00 2001 From: Sofia Kurilova Date: Tue, 13 Aug 2024 14:50:51 +0200 Subject: [PATCH 04/14] Select device type (#679) * Refactoring - create common dynamic form; adds dynamic form in risk profile and device qualification process; generate 2nd step dynamically --- .../dynamic-form/dynamic-form.component.html | 266 ++++++++++++++++++ .../dynamic-form/dynamic-form.component.scss | 67 +++++ .../dynamic-form.component.spec.ts | 237 ++++++++++++++++ .../dynamic-form/dynamic-form.component.ts | 169 +++++++++++ modules/ui/src/app/mocks/device.mock.ts | 63 ++++- modules/ui/src/app/mocks/profile.mock.ts | 8 +- modules/ui/src/app/model/device.ts | 13 + modules/ui/src/app/model/profile.ts | 24 +- modules/ui/src/app/model/question.ts | 38 +++ .../device-qualification-from.component.html | 47 +++- .../device-qualification-from.component.scss | 50 ++-- ...evice-qualification-from.component.spec.ts | 59 +++- .../device-qualification-from.component.ts | 113 ++++++-- .../ui/src/app/pages/devices/devices.store.ts | 23 +- .../profile-form/profile-form.component.html | 256 +---------------- .../profile-form/profile-form.component.scss | 54 +--- .../profile-form.component.spec.ts | 171 +---------- .../profile-form/profile-form.component.ts | 64 +---- .../src/app/services/test-run.service.spec.ts | 18 +- .../ui/src/app/services/test-run.service.ts | 41 +-- 20 files changed, 1121 insertions(+), 660 deletions(-) create mode 100644 modules/ui/src/app/components/dynamic-form/dynamic-form.component.html create mode 100644 modules/ui/src/app/components/dynamic-form/dynamic-form.component.scss create mode 100644 modules/ui/src/app/components/dynamic-form/dynamic-form.component.spec.ts create mode 100644 modules/ui/src/app/components/dynamic-form/dynamic-form.component.ts create mode 100644 modules/ui/src/app/model/question.ts 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)) From 0cde98e69caa62574d2bf2ecd7f21312893db42e Mon Sep 17 00:00:00 2001 From: Sofia Kurilova Date: Wed, 14 Aug 2024 12:10:36 +0200 Subject: [PATCH 05/14] Step 3 qualification process (#681) * Adds validation between steps --- .../stepper/stepper-test.component.html | 39 +++++++++ .../components/stepper/stepper.component.html | 16 ++++ .../stepper/stepper.component.spec.ts | 81 +++++++++++++++++-- .../components/stepper/stepper.component.ts | 25 ++++++ .../device-qualification-from.component.html | 6 +- 5 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 modules/ui/src/app/components/stepper/stepper-test.component.html diff --git a/modules/ui/src/app/components/stepper/stepper-test.component.html b/modules/ui/src/app/components/stepper/stepper-test.component.html new file mode 100644 index 000000000..63570096a --- /dev/null +++ b/modules/ui/src/app/components/stepper/stepper-test.component.html @@ -0,0 +1,39 @@ + +
+ + + + Test + + + + + + Test + + + + + +
Header
+
diff --git a/modules/ui/src/app/components/stepper/stepper.component.html b/modules/ui/src/app/components/stepper/stepper.component.html index e2a9d1adb..487bce262 100644 --- a/modules/ui/src/app/components/stepper/stepper.component.html +++ b/modules/ui/src/app/components/stepper/stepper.component.html @@ -1,3 +1,18 @@ +
@@ -26,6 +41,7 @@
diff --git a/modules/ui/src/app/components/stepper/stepper.component.scss b/modules/ui/src/app/components/stepper/stepper.component.scss index 87c7ffecd..e300fb8c6 100644 --- a/modules/ui/src/app/components/stepper/stepper.component.scss +++ b/modules/ui/src/app/components/stepper/stepper.component.scss @@ -29,7 +29,8 @@ display: grid; padding: 24px; gap: 10px; - overflow: auto; + overflow: hidden; + height: 100%; } .form-footer { @@ -78,6 +79,6 @@ } &.hidden { - display: none; + visibility: hidden; } } diff --git a/modules/ui/src/app/components/stepper/stepper.component.ts b/modules/ui/src/app/components/stepper/stepper.component.ts index fdd0c9ce7..6f688d377 100644 --- a/modules/ui/src/app/components/stepper/stepper.component.ts +++ b/modules/ui/src/app/components/stepper/stepper.component.ts @@ -40,10 +40,8 @@ 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; + return this.selectedIndex === this.steps.length - 1; } backButtonHidden() { diff --git a/modules/ui/src/app/pages/devices/components/device-form/device-form.component.spec.ts b/modules/ui/src/app/pages/devices/components/device-form/device-form.component.spec.ts index 6ea72aa52..1911402a0 100644 --- a/modules/ui/src/app/pages/devices/components/device-form/device-form.component.spec.ts +++ b/modules/ui/src/app/pages/devices/components/device-form/device-form.component.spec.ts @@ -20,7 +20,7 @@ import { TestBed, } from '@angular/core/testing'; -import { DeviceFormComponent, FormAction } from './device-form.component'; +import { DeviceFormComponent } from './device-form.component'; import { MatButtonModule } from '@angular/material/button'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -39,6 +39,7 @@ import { NgxMaskDirective, NgxMaskPipe, provideNgxMask } from 'ngx-mask'; import { DevicesStore } from '../../devices.store'; import { device, MOCK_TEST_MODULES } from '../../../../mocks/device.mock'; import SpyObj = jasmine.SpyObj; +import { FormAction } from '../../devices.component'; describe('DeviceFormComponent', () => { let component: DeviceFormComponent; diff --git a/modules/ui/src/app/pages/devices/components/device-form/device-form.component.ts b/modules/ui/src/app/pages/devices/components/device-form/device-form.component.ts index 38ab05ffa..f7aa4f180 100644 --- a/modules/ui/src/app/pages/devices/components/device-form/device-form.component.ts +++ b/modules/ui/src/app/pages/devices/components/device-form/device-form.component.ts @@ -29,6 +29,7 @@ import { Subject } from 'rxjs'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { EscapableDialogComponent } from '../../../../components/escapable-dialog/escapable-dialog.component'; import { DevicesStore } from '../../devices.store'; +import { FormAction, FormResponse } from '../../devices.component'; const MAC_ADDRESS_PATTERN = '^[\\s]*[a-fA-F0-9]{2}(?:[:][a-fA-F0-9]{2}){5}[\\s]*$'; @@ -40,16 +41,6 @@ interface DialogData { testModules: TestModule[]; } -export enum FormAction { - Delete = 'Delete', - Save = 'Save', -} - -export interface FormResponse { - device?: Device; - action: FormAction; -} - @Component({ selector: 'app-device-form', templateUrl: './device-form.component.html', 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 60f7226b0..cf1d60fb6 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 @@ -32,10 +32,11 @@

{{ data.title }}

+ [header]="header" + [selectedIndex]="selectedIndex"> Device Manufacturer @@ -151,26 +152,43 @@

{{ data.title }}

[editable]="true" [formGroupName]="step.step" [stepControl]="getStep(step.step)"> -

- {{ step.title }} -

-

- {{ step.description }} -

- +
+

+ {{ step.title }} +

+

+ {{ step.description }} +

+ +
- - Final step + +
+

Summary

+

+ The device has been configured. Please check the setup. +

+
+
+ + +
+
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 f4c06d5e6..97b92dfbc 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 @@ -145,3 +145,27 @@ $form-height: 993px; margin: 0; padding-top: 8px; } + +.device-qualification-form-step-content { + overflow: scroll; +} + +.device-qualification-form-page { + display: grid; + gap: 10px; + height: 100%; + overflow: hidden; + &:last-of-type { + grid-template-rows: min-content min-content 1fr min-content; + } +} + +.device-qualification-form-actions { + text-align: center; + .close-button { + padding: 0 16px; + } + .save-button { + margin: 0 16px; + } +} 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 cc320e03f..2dd7263f2 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 @@ -39,6 +39,7 @@ 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'; +import { FormAction } from '../../devices.component'; describe('DeviceQualificationFromComponent', () => { let component: DeviceQualificationFromComponent; let fixture: ComponentFixture; @@ -85,6 +86,7 @@ describe('DeviceQualificationFromComponent', () => { component.data = { testModules: MOCK_TEST_MODULES, devices: [], + index: 0, }; testrunServiceMock.fetchQuestionnaireFormat.and.returnValue( of(DEVICES_FORM) @@ -97,13 +99,13 @@ describe('DeviceQualificationFromComponent', () => { expect(component).toBeTruthy(); }); - it('should fetch devices format', () => { + it('should contain device form', () => { const form = compiled.querySelector('.device-qualification-form'); expect(form).toBeTruthy(); }); - it('should contain device form', () => { + it('should fetch devices format', () => { const getQuestionnaireFormatSpy = spyOn( component.devicesStore, 'getQuestionnaireFormat' @@ -122,7 +124,23 @@ describe('DeviceQualificationFromComponent', () => { closeButton?.click(); - expect(closeSpy).toHaveBeenCalledWith(); + expect(closeSpy).toHaveBeenCalledWith({ + action: FormAction.Close, + index: 0, + device: { + manufacturer: '', + model: '', + mac_addr: '', + test_modules: { + udmi: { + enabled: false, + }, + connection: { + enabled: true, + }, + }, + }, + }); closeSpy.calls.reset(); }); @@ -304,6 +322,7 @@ describe('DeviceQualificationFromComponent', () => { component.data = { testModules: MOCK_TEST_MODULES, devices: [device], + index: 0, }; component.ngOnInit(); fixture.detectChanges(); @@ -341,6 +360,7 @@ describe('DeviceQualificationFromComponent', () => { }, }, }, + index: 0, }; component.ngOnInit(); @@ -360,7 +380,7 @@ describe('DeviceQualificationFromComponent', () => { }); }); - describe('with questioner', () => { + describe('with questionnaire', () => { it('should have steps', () => { expect( (component.deviceQualificationForm.get('steps') as FormArray).controls 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 bdb8dc92d..f4efb34db 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 @@ -18,7 +18,9 @@ import { Component, ElementRef, Inject, + OnDestroy, OnInit, + ViewChild, } from '@angular/core'; import { AbstractControl, @@ -56,7 +58,8 @@ 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'; +import { skip, Subject, takeUntil, timer } from 'rxjs'; +import { FormAction, FormResponse } from '../../devices.component'; const MAC_ADDRESS_PATTERN = '^[\\s]*[a-fA-F0-9]{2}(?:[:][a-fA-F0-9]{2}){5}[\\s]*$'; @@ -66,6 +69,7 @@ interface DialogData { device?: Device; devices: Device[]; testModules: TestModule[]; + index: number; } @Component({ @@ -98,12 +102,16 @@ interface DialogData { }) export class DeviceQualificationFromComponent extends EscapableDialogComponent - implements OnInit, AfterViewInit + implements OnInit, AfterViewInit, OnDestroy { + @ViewChild('stepper') public stepper!: StepperComponent; testModules: TestModule[] = []; deviceQualificationForm: FormGroup = this.fb.group({}); device: Device | undefined; format: DeviceQuestionnaireSection[] = []; + selectedIndex: number = 0; + + private destroy$: Subject = new Subject(); get model() { return this.getStep(0).get('model') as AbstractControl; @@ -146,6 +154,13 @@ export class DeviceQualificationFromComponent this.devicesStore.questionnaireFormat$.pipe(skip(1)).subscribe(format => { this.createDeviceForm(format); this.format = format; + if (this.data.index) { + timer(0) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.selectedIndex = this.data.index; + }); + } }); this.devicesStore.getQuestionnaireFormat(); @@ -157,12 +172,21 @@ export class DeviceQualificationFromComponent this.element.nativeElement.offsetHeight + 'px'; } + ngOnDestroy() { + this.destroy$.next(true); + this.destroy$.unsubscribe(); + } + submit(): void { this.device = this.createDeviceFromForm(this.getStep(0)); } closeForm(): void { - this.dialogRef.close(); + this.dialogRef.close({ + action: FormAction.Close, + device: this.createDeviceFromForm(this.getStep(0)), + index: this.stepper.selectedIndex, + } as FormResponse); } getStep(step: number) { @@ -229,7 +253,7 @@ export class DeviceQualificationFromComponent ); }); - // TODO dummy step + // summary step (this.deviceQualificationForm.get('steps') as FormArray).controls.push( this.fb.group({}) ); diff --git a/modules/ui/src/app/pages/devices/devices.component.html b/modules/ui/src/app/pages/devices/devices.component.html index aef3730c5..7ea597d42 100644 --- a/modules/ui/src/app/pages/devices/devices.component.html +++ b/modules/ui/src/app/pages/devices/devices.component.html @@ -22,7 +22,7 @@

Devices

{ mockDevicesStore = jasmine.createSpyObj('DevicesStore', [ 'setIsOpenAddDevice', 'selectDevice', - 'deleteDevice', 'setStatus', 'getTestModules', ]); @@ -163,6 +161,7 @@ describe('DevicesComponent', () => { title: 'Create Device', testModules: [], devices: [device, device, device], + index: 0, }, autoFocus: true, hasBackdrop: true, @@ -180,7 +179,7 @@ describe('DevicesComponent', () => { } as MatDialogRef); fixture.detectChanges(); - component.openDialog([device], MOCK_TEST_MODULES, device); + component.openDialog([device], MOCK_TEST_MODULES, device, true); expect(openSpy).toHaveBeenCalled(); expect(openSpy).toHaveBeenCalledWith(DeviceQualificationFromComponent, { @@ -190,6 +189,7 @@ describe('DevicesComponent', () => { title: 'Edit device', devices: [device], testModules: MOCK_TEST_MODULES, + index: 0, }, autoFocus: true, hasBackdrop: true, @@ -199,31 +199,6 @@ describe('DevicesComponent', () => { openSpy.calls.reset(); }); - - it('should open device dialog with delete-button focus element', () => { - const openSpy = spyOn(component.dialog, 'open').and.returnValue({ - afterClosed: () => of(true), - } as MatDialogRef); - fixture.detectChanges(); - - component.openDialog([device], MOCK_TEST_MODULES, device, true); - - expect(openSpy).toHaveBeenCalledWith(DeviceQualificationFromComponent, { - ariaLabel: 'Edit device', - data: { - device: device, - title: 'Edit device', - devices: [device], - testModules: MOCK_TEST_MODULES, - }, - autoFocus: '.delete-button', - hasBackdrop: true, - disableClose: true, - panelClass: 'device-form-dialog', - }); - - openSpy.calls.reset(); - }); }); it('should disable device if deviceInProgress is exist', () => { @@ -243,21 +218,7 @@ describe('DevicesComponent', () => { expect(mockDevicesStore.setIsOpenAddDevice).toHaveBeenCalled(); }); - it('should delete device if dialog closes with object, action delete and selected device', () => { - spyOn(component.dialog, 'open').and.returnValue({ - afterClosed: () => - of({ - device, - action: FormAction.Delete, - }), - } as MatDialogRef); - - component.openDialog([device], MOCK_TEST_MODULES, device); - - expect(mockDevicesStore.deleteDevice).toHaveBeenCalled(); - }); - - describe('delete device dialog', () => { + describe('close dialog', () => { beforeEach(() => { component.viewModel$ = of({ devices: [device, device, device], @@ -274,32 +235,20 @@ describe('DevicesComponent', () => { expect(item.length).toEqual(3); })); - it('should delete device when dialog return true', () => { - spyOn(component.dialog, 'open').and.returnValue({ - afterClosed: () => of(true), - } as MatDialogRef); - - component.openDeleteDialog([device], MOCK_TEST_MODULES, device); - - const args = mockDevicesStore.deleteDevice.calls.argsFor(0); - // @ts-expect-error config is in object - expect(args[0].device).toEqual(device); - expect(mockDevicesStore.deleteDevice).toHaveBeenCalled(); - }); - it('should open device dialog when dialog return null', () => { const openDeviceDialogSpy = spyOn(component, 'openDialog'); spyOn(component.dialog, 'open').and.returnValue({ afterClosed: () => of(null), } as MatDialogRef); - component.openDeleteDialog([device], MOCK_TEST_MODULES, device); + component.openCloseDialog([device], MOCK_TEST_MODULES, device); expect(openDeviceDialogSpy).toHaveBeenCalledWith( [device], MOCK_TEST_MODULES, device, - true + false, + 0 ); }); }); diff --git a/modules/ui/src/app/pages/devices/devices.component.ts b/modules/ui/src/app/pages/devices/devices.component.ts index e320663bf..a7243e148 100644 --- a/modules/ui/src/app/pages/devices/devices.component.ts +++ b/modules/ui/src/app/pages/devices/devices.component.ts @@ -22,21 +22,28 @@ import { } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Device, DeviceView, TestModule } from '../../model/device'; -import { - FormAction, - FormResponse, -} from './components/device-form/device-form.component'; import { Subject, takeUntil } from 'rxjs'; import { SimpleDialogComponent } from '../../components/simple-dialog/simple-dialog.component'; import { combineLatest } from 'rxjs/internal/observable/combineLatest'; import { FocusManagerService } from '../../services/focus-manager.service'; import { Routes } from '../../model/routes'; 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'; +export enum FormAction { + Delete = 'Delete', + Close = 'Close', + Save = 'Save', +} + +export interface FormResponse { + device?: Device; + action: FormAction; + index: number; +} + @Component({ selector: 'app-device-repository', templateUrl: './devices.component.html', @@ -113,17 +120,19 @@ export class DevicesComponent implements OnInit, OnDestroy { devices: Device[] = [], testModules: TestModule[], selectedDevice?: Device, - focusDeleteButton = false + isEditDevice = false, + index = 0 ): void { const dialogRef = this.dialog.open(DeviceQualificationFromComponent, { - ariaLabel: selectedDevice ? 'Edit device' : 'Create Device', + ariaLabel: isEditDevice ? 'Edit device' : 'Create Device', data: { device: selectedDevice || null, - title: selectedDevice ? 'Edit device' : 'Create Device', + title: isEditDevice ? 'Edit device' : 'Create Device', testModules: testModules, devices, + index, }, - autoFocus: focusDeleteButton ? '.delete-button' : true, + autoFocus: true, hasBackdrop: true, disableClose: true, panelClass: 'device-form-dialog', @@ -138,37 +147,30 @@ export class DevicesComponent implements OnInit, OnDestroy { this.devicesStore.setIsOpenAddDevice(false); return; } - if ( - response.action === FormAction.Save && - response.device && - !selectedDevice - ) { - timer(10) - .pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.focusManagerService.focusFirstElementInContainer(); - }); - } - if (response.action === FormAction.Delete && selectedDevice) { - this.devicesStore.selectDevice(selectedDevice); - this.openDeleteDialog(devices, testModules, selectedDevice); + if (response.action === FormAction.Close) { + this.openCloseDialog( + devices, + testModules, + response.device, + isEditDevice, + response.index + ); } }); } - openDeleteDialog( + openCloseDialog( devices: Device[], testModules: TestModule[], - device: Device + device?: Device, + isEditDevice = false, + index = 0 ) { const dialogRef = this.dialog.open(SimpleDialogComponent, { - ariaLabel: 'Delete device', + ariaLabel: 'Close the Device menu', data: { - title: 'Delete device?', - content: `You are about to delete ${ - device.manufacturer + ' ' + device.model - }. Are you sure?`, - device: device, + title: 'Close the Device menu?', + content: `Are you ok to close the Device menu?`, }, autoFocus: true, hasBackdrop: true, @@ -179,24 +181,16 @@ export class DevicesComponent implements OnInit, OnDestroy { dialogRef ?.afterClosed() .pipe(takeUntil(this.destroy$)) - .subscribe(deleteDevice => { - if (deleteDevice) { - this.devicesStore.deleteDevice({ - device, - onDelete: () => { - this.focusNextButton(); - this.devicesStore.selectDevice(null); - }, - }); - } else { - this.openDialog(devices, testModules, device, true); + .subscribe(close => { + if (!close) { + this.openDialog(devices, testModules, device, isEditDevice, index); this.devicesStore.selectDevice(null); } }); } private focusNextButton() { - // Try to focus next device item, if exitst + // Try to focus next device item, if exist const next = this.element.nativeElement.querySelector( '.device-item-selected + app-device-item button' ); From d953b40ee43d474a6d4ce83e75116e4c20c3ce5f Mon Sep 17 00:00:00 2001 From: Olga Mardvilko Date: Mon, 19 Aug 2024 19:55:29 +0300 Subject: [PATCH 08/14] 356882947: (feat) add pilot program section to welcome modal (#694) --- modules/ui/src/app/app.component.html | 6 +- modules/ui/src/app/app.component.spec.ts | 2 - .../components/callout/callout.component.html | 2 + .../components/callout/callout.component.ts | 2 + .../consent-dialog.component.html | 24 ++------ .../consent-dialog.component.scss | 11 ++++ .../consent-dialog.component.spec.ts | 57 ++----------------- .../consent-dialog.component.ts | 4 +- .../components/version/version.component.ts | 8 +-- modules/ui/src/app/model/callout-type.ts | 1 + modules/ui/src/app/model/version.ts | 1 - 11 files changed, 30 insertions(+), 88 deletions(-) diff --git a/modules/ui/src/app/app.component.html b/modules/ui/src/app/app.component.html index 122e8b129..77e184734 100644 --- a/modules/ui/src/app/app.component.html +++ b/modules/ui/src/app/app.component.html @@ -68,11 +68,7 @@ + (consentShownEvent)="consentShown()">
diff --git a/modules/ui/src/app/app.component.spec.ts b/modules/ui/src/app/app.component.spec.ts index b626123d7..05ae036ec 100644 --- a/modules/ui/src/app/app.component.spec.ts +++ b/modules/ui/src/app/app.component.spec.ts @@ -825,7 +825,5 @@ class FakeShutdownAppComponent { }) class FakeVersionComponent { @Input() consentShown!: boolean; - @Input() hasRiskProfiles!: boolean; @Output() consentShownEvent = new EventEmitter(); - @Output() navigateToRiskAssessmentEvent = new EventEmitter(); } diff --git a/modules/ui/src/app/components/callout/callout.component.html b/modules/ui/src/app/components/callout/callout.component.html index a3fb7930c..39fc14f3f 100644 --- a/modules/ui/src/app/components/callout/callout.component.html +++ b/modules/ui/src/app/components/callout/callout.component.html @@ -15,11 +15,13 @@ -->
{{ type }} + 🚀

diff --git a/modules/ui/src/app/components/callout/callout.component.ts b/modules/ui/src/app/components/callout/callout.component.ts index bfb6ea9bf..64487236d 100644 --- a/modules/ui/src/app/components/callout/callout.component.ts +++ b/modules/ui/src/app/components/callout/callout.component.ts @@ -16,6 +16,7 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; +import { CalloutType } from '../../model/callout-type'; @Component({ selector: 'app-callout', @@ -26,5 +27,6 @@ import { MatIconModule } from '@angular/material/icon'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class CalloutComponent { + public readonly CalloutType = CalloutType; @Input() type = ''; } diff --git a/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.html b/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.html index 516e72b49..6867dd445 100644 --- a/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.html +++ b/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.html @@ -81,24 +81,12 @@

Welcome to Testrun!

-
- - Risk Assessment feature added! -

- Now you can answer a short questionnaire, create a security profile and - attach it to the Testrun result to complete a device verification. Also, - it will speed up the process a lot! -

-

- +

+ + Pilot Assessment +

+ Pilot project support is now offered through Testrun. Follow the + instructions set out to get your pilot recommendation.

diff --git a/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.scss b/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.scss index 9eb334496..c32f47a41 100644 --- a/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.scss +++ b/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.scss @@ -81,6 +81,17 @@ } } +.section-container-pilot { + .section-title { + font-weight: 500; + letter-spacing: 0.25px; + } + .section-content { + margin: 0; + padding-top: 9px; + } +} + .consent-actions { border-top: 1px solid $lighter-grey; margin: 0 -16px; diff --git a/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.spec.ts b/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.spec.ts index c538554f2..56fd7b246 100644 --- a/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.spec.ts +++ b/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.spec.ts @@ -46,7 +46,7 @@ describe('ConsentDialogComponent', () => { }); fixture = TestBed.createComponent(ConsentDialogComponent); component = fixture.componentInstance; - component.data = { version: NEW_VERSION, hasRiskProfiles: false }; + component.data = { version: NEW_VERSION }; component.optOut = false; fixture.detectChanges(); compiled = fixture.nativeElement as HTMLElement; @@ -61,7 +61,7 @@ describe('ConsentDialogComponent', () => { const confirmButton = compiled.querySelector( '.confirm-button' ) as HTMLButtonElement; - const dialogRes = { grant: true, isNavigateToRiskAssessment: undefined }; + const dialogRes = { grant: true }; confirmButton?.click(); @@ -77,7 +77,7 @@ describe('ConsentDialogComponent', () => { const confirmButton = compiled.querySelector( '.confirm-button' ) as HTMLButtonElement; - const dialogRes = { grant: false, isNavigateToRiskAssessment: undefined }; + const dialogRes = { grant: false }; confirmButton?.click(); @@ -99,7 +99,7 @@ describe('ConsentDialogComponent', () => { describe('with new version available', () => { beforeEach(() => { - component.data = { version: NEW_VERSION, hasRiskProfiles: false }; + component.data = { version: NEW_VERSION }; fixture.detectChanges(); }); @@ -122,7 +122,7 @@ describe('ConsentDialogComponent', () => { describe('with no new version available', () => { beforeEach(() => { - component.data = { version: VERSION, hasRiskProfiles: false }; + component.data = { version: VERSION }; fixture.detectChanges(); }); @@ -134,51 +134,4 @@ describe('ConsentDialogComponent', () => { expect(content).toBeNull(); }); }); - - describe('with no risk assessment profiles', () => { - beforeEach(() => { - component.data = { version: VERSION, hasRiskProfiles: false }; - fixture.detectChanges(); - }); - - it('should has risk-assessment content', () => { - const content = compiled.querySelector( - '.section-content.risk-assessment' - ) as HTMLElement; - - const innerContent = content.innerHTML.trim(); - expect(innerContent).toContain( - 'Now you can answer a short questionnaire' - ); - }); - - it('should close dialog with isNavigateToRiskAssessment as true when click "confirm"', () => { - const closeSpy = spyOn(component.dialogRef, 'close'); - const riskAssessmentBtn = compiled.querySelector( - '.risk-assessment-button' - ) as HTMLButtonElement; - const dialogRes = { grant: true, isNavigateToRiskAssessment: true }; - - riskAssessmentBtn?.click(); - - expect(closeSpy).toHaveBeenCalledWith(dialogRes); - - closeSpy.calls.reset(); - }); - }); - - describe('with risk assessment profiles', () => { - beforeEach(() => { - component.data = { version: VERSION, hasRiskProfiles: true }; - fixture.detectChanges(); - }); - - it('should not has risk-assessment content', () => { - const content = compiled.querySelector( - '.section-content.risk-assessment' - ) as HTMLElement; - - expect(content).toBeNull(); - }); - }); }); diff --git a/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.ts b/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.ts index 8b27a8961..e84659748 100644 --- a/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.ts +++ b/modules/ui/src/app/components/version/consent-dialog/consent-dialog.component.ts @@ -29,7 +29,6 @@ import { FormsModule } from '@angular/forms'; type DialogData = { version: Version; - hasRiskProfiles: boolean; }; @Component({ @@ -54,11 +53,10 @@ export class ConsentDialogComponent { @Inject(MAT_DIALOG_DATA) public data: DialogData ) {} - confirm(optOut: boolean, isNavigateToRiskAssessment?: boolean) { + confirm(optOut: boolean) { // dialog should be closed with opposite value to grant or deny access to GA const dialogResult: ConsentDialogResult = { grant: !optOut, - isNavigateToRiskAssessment, }; this.dialogRef.close(dialogResult); } diff --git a/modules/ui/src/app/components/version/version.component.ts b/modules/ui/src/app/components/version/version.component.ts index 186c3bcf6..d56e1cdd4 100644 --- a/modules/ui/src/app/components/version/version.component.ts +++ b/modules/ui/src/app/components/version/version.component.ts @@ -48,9 +48,7 @@ declare const gtag: Function; }) export class VersionComponent implements OnInit, OnDestroy { @Input() consentShown!: boolean; - @Input() hasRiskProfiles!: boolean; @Output() consentShownEvent = new EventEmitter(); - @Output() navigateToRiskAssessmentEvent = new EventEmitter(); version$!: Observable; private destroy$: Subject = new Subject(); @@ -86,7 +84,7 @@ export class VersionComponent implements OnInit, OnDestroy { } openConsentDialog(version: Version) { - const dialogData = { version, hasRiskProfiles: this.hasRiskProfiles }; + const dialogData = { version }; const dialogRef = this.dialog.open(ConsentDialogComponent, { ariaLabel: 'Welcome to Testrun modal window', data: dialogData, @@ -106,10 +104,6 @@ export class VersionComponent implements OnInit, OnDestroy { gtag('consent', 'update', { analytics_storage: dialogResult.grant ? 'granted' : 'denied', }); - - if (dialogResult.isNavigateToRiskAssessment) { - this.navigateToRiskAssessmentEvent.emit(); - } }); } diff --git a/modules/ui/src/app/model/callout-type.ts b/modules/ui/src/app/model/callout-type.ts index c784b46f6..c8f86d1c4 100644 --- a/modules/ui/src/app/model/callout-type.ts +++ b/modules/ui/src/app/model/callout-type.ts @@ -15,6 +15,7 @@ */ export enum CalloutType { Info = 'info', + InfoPilot = 'info pilot', Check = 'check_circle', Warning = 'warning_amber', Error = 'error', diff --git a/modules/ui/src/app/model/version.ts b/modules/ui/src/app/model/version.ts index 21ec6ee38..5eb946e42 100644 --- a/modules/ui/src/app/model/version.ts +++ b/modules/ui/src/app/model/version.ts @@ -23,5 +23,4 @@ export interface Version { export interface ConsentDialogResult { grant: boolean; - isNavigateToRiskAssessment?: boolean; } From dcbf9379a733ecbcfaa4f7afef9d828363ae7a2d Mon Sep 17 00:00:00 2001 From: Sofia Kurilova Date: Tue, 20 Aug 2024 12:26:11 +0200 Subject: [PATCH 09/14] Display outdated device Banner (#693) * Adds expired device callout * Save state of callout in session storage --- modules/ui/src/app/app.component.html | 29 +++++++++++++++-- modules/ui/src/app/app.component.spec.ts | 27 ++++++++++++++++ modules/ui/src/app/app.component.ts | 9 ++++++ modules/ui/src/app/app.store.spec.ts | 29 ++++++++++++++++- modules/ui/src/app/app.store.ts | 32 +++++++++++++++++++ .../components/callout/callout.component.html | 8 ++++- .../components/callout/callout.component.scss | 10 ++++++ .../callout/callout.component.spec.ts | 23 +++++++++++++ .../components/callout/callout.component.ts | 16 ++++++++-- .../device-item/device-item.component.spec.ts | 3 +- modules/ui/src/app/mocks/device.mock.ts | 20 +++++++++++- modules/ui/src/app/mocks/reports.mock.ts | 9 ++++++ modules/ui/src/app/mocks/testrun.mock.ts | 2 ++ modules/ui/src/app/model/device.ts | 6 ++++ .../device-form/device-form.component.spec.ts | 5 ++- .../device-form/device-form.component.ts | 3 +- ...evice-qualification-from.component.spec.ts | 3 ++ .../testrun-initiate-form.component.spec.ts | 3 +- modules/ui/src/app/store/actions.ts | 5 +++ modules/ui/src/app/store/effects.spec.ts | 13 +++++++- modules/ui/src/app/store/effects.ts | 14 ++++++++ modules/ui/src/app/store/reducers.spec.ts | 13 ++++++++ modules/ui/src/app/store/reducers.ts | 6 ++++ modules/ui/src/app/store/selectors.spec.ts | 7 ++++ modules/ui/src/app/store/selectors.ts | 5 +++ modules/ui/src/app/store/state.ts | 2 ++ modules/ui/src/index.html | 16 ++++++++++ 27 files changed, 305 insertions(+), 13 deletions(-) diff --git a/modules/ui/src/app/app.component.html b/modules/ui/src/app/app.component.html index 77e184734..7243e5ed9 100644 --- a/modules/ui/src/app/app.component.html +++ b/modules/ui/src/app/app.component.html @@ -167,9 +167,9 @@

Testrun

*ngIf="vm.hasConnectionSettings === true && vm.hasDevices === false"> Step 2: To perform a device test please Testrun >Risk Assessment questionnaire?
+ + Further information is required in your device configurations. Please + update your + Devices + to continue testing. + diff --git a/modules/ui/src/app/app.component.spec.ts b/modules/ui/src/app/app.component.spec.ts index 05ae036ec..bb2edb3fa 100644 --- a/modules/ui/src/app/app.component.spec.ts +++ b/modules/ui/src/app/app.component.spec.ts @@ -54,6 +54,7 @@ import { selectError, selectHasConnectionSettings, selectHasDevices, + selectHasExpiredDevices, selectHasRiskProfiles, selectInterfaces, selectIsOpenStartTestrun, @@ -164,6 +165,7 @@ describe('AppComponent', () => { { selector: selectError, value: null }, { selector: selectMenuOpened, value: false }, { selector: selectHasDevices, value: false }, + { selector: selectHasExpiredDevices, value: false }, { selector: selectHasRiskProfiles, value: false }, { selector: selectStatus, value: null }, { selector: selectSystemStatus, value: null }, @@ -757,6 +759,31 @@ describe('AppComponent', () => { }); }); }); + + describe('with expired devices', () => { + beforeEach(() => { + store.overrideSelector(selectHasExpiredDevices, true); + fixture.detectChanges(); + }); + + it('should have callout component', () => { + const callouts = compiled.querySelectorAll('app-callout'); + let hasExpiredDeviceCallout = false; + callouts.forEach(callout => { + if ( + callout?.innerHTML + .trim() + .includes( + 'Further information is required in your device configurations.' + ) + ) { + hasExpiredDeviceCallout = true; + } + }); + + expect(hasExpiredDeviceCallout).toBeTrue(); + }); + }); }); it('should not call toggleSettingsBtn focus on closeSetting when device length is 0', async () => { diff --git a/modules/ui/src/app/app.component.ts b/modules/ui/src/app/app.component.ts index 2214b8927..d2559c926 100644 --- a/modules/ui/src/app/app.component.ts +++ b/modules/ui/src/app/app.component.ts @@ -123,6 +123,9 @@ export class AppComponent { navigateToDeviceRepository(): void { this.route.navigate([Routes.Devices]); + } + navigateToAddDevice(): void { + this.route.navigate([Routes.Devices]); this.store.dispatch(setIsOpenAddDevice({ isOpenAddDevice: true })); } @@ -207,4 +210,10 @@ export class AppComponent { this.appStore.setFocusOnPage(); }); } + + calloutClosed(id: string | null) { + if (id) { + this.appStore.setCloseCallout(id); + } + } } diff --git a/modules/ui/src/app/app.store.spec.ts b/modules/ui/src/app/app.store.spec.ts index 0130e9b40..d90f1cac7 100644 --- a/modules/ui/src/app/app.store.spec.ts +++ b/modules/ui/src/app/app.store.spec.ts @@ -15,13 +15,14 @@ */ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { of, skip, take } from 'rxjs'; -import { AppStore, CONSENT_SHOWN_KEY } from './app.store'; +import { AppStore, CALLOUT_STATE_KEY, CONSENT_SHOWN_KEY } from './app.store'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { AppState } from './store/state'; import { selectError, selectHasConnectionSettings, selectHasDevices, + selectHasExpiredDevices, selectHasRiskProfiles, selectInterfaces, selectIsOpenWaitSnackBar, @@ -57,6 +58,12 @@ const mock = (() => { setItem: (key: string, value: string) => { store[key] = value + ''; }, + getObject: (key: string) => { + return store[key] || null; + }, + setObject: (key: string, value: object) => { + store[key] = JSON.stringify(value); + }, clear: () => { store = {}; }, @@ -109,6 +116,7 @@ describe('AppStore', () => { appStore = TestBed.inject(AppStore); store.overrideSelector(selectHasDevices, true); + store.overrideSelector(selectHasExpiredDevices, true); store.overrideSelector(selectHasRiskProfiles, false); store.overrideSelector(selectReports, []); store.overrideSelector(selectHasConnectionSettings, true); @@ -154,6 +162,7 @@ describe('AppStore', () => { expect(store).toEqual({ consentShown: false, hasDevices: true, + hasExpiredDevices: true, hasRiskProfiles: false, reports: [], isStatusLoaded: false, @@ -162,6 +171,7 @@ describe('AppStore', () => { isMenuOpen: true, interfaces: {}, settingMissedError: null, + calloutState: new Map(), }); done(); }); @@ -303,5 +313,22 @@ describe('AppStore', () => { ); }); }); + + describe('setCloseCallout', () => { + it('should update store', done => { + appStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => { + expect(store.calloutState.get('test')).toEqual(true); + done(); + }); + + appStore.setCloseCallout('test'); + }); + + it('should update storage', () => { + appStore.setCloseCallout('test'); + + expect(mock.getObject(CALLOUT_STATE_KEY)).toBeTruthy(); + }); + }); }); }); diff --git a/modules/ui/src/app/app.store.ts b/modules/ui/src/app/app.store.ts index 562d2618e..12486d7db 100644 --- a/modules/ui/src/app/app.store.ts +++ b/modules/ui/src/app/app.store.ts @@ -21,6 +21,7 @@ import { selectError, selectHasConnectionSettings, selectHasDevices, + selectHasExpiredDevices, selectHasRiskProfiles, selectInterfaces, selectMenuOpened, @@ -52,16 +53,20 @@ import { TestRunMqttService } from './services/test-run-mqtt.service'; import { NotificationService } from './services/notification.service'; export const CONSENT_SHOWN_KEY = 'CONSENT_SHOWN'; +export const CALLOUT_STATE_KEY = 'CALLOUT_STATE'; export interface AppComponentState { consentShown: boolean; isStatusLoaded: boolean; systemStatus: TestrunStatus | null; + calloutState: Map; } @Injectable() export class AppStore extends ComponentStore { private consentShown$ = this.select(state => state.consentShown); + private calloutState$ = this.select(state => state.calloutState); private isStatusLoaded$ = this.select(state => state.isStatusLoaded); private hasDevices$ = this.store.select(selectHasDevices); + private hasExpiredDevices$ = this.store.select(selectHasExpiredDevices); private hasRiskProfiles$ = this.store.select(selectHasRiskProfiles); private reports$ = this.store.select(selectReports); private hasConnectionSetting$ = this.store.select( @@ -77,6 +82,7 @@ export class AppStore extends ComponentStore { viewModel$ = this.select({ consentShown: this.consentShown$, hasDevices: this.hasDevices$, + hasExpiredDevices: this.hasExpiredDevices$, hasRiskProfiles: this.hasRiskProfiles$, reports: this.reports$, isStatusLoaded: this.isStatusLoaded$, @@ -85,6 +91,7 @@ export class AppStore extends ComponentStore { isMenuOpen: this.isMenuOpen$, interfaces: this.interfaces$, settingMissedError: this.settingMissedError$, + calloutState: this.calloutState$, }); updateConsent = this.updater((state, consentShown: boolean) => ({ @@ -92,6 +99,17 @@ export class AppStore extends ComponentStore { consentShown, })); + updateCalloutState = this.updater((state, callout: string) => { + const calloutState = state.calloutState; + calloutState.set(callout, true); + // @ts-expect-error property is defined in index.html + sessionStorage.setObject(CALLOUT_STATE_KEY, calloutState); + return { + ...state, + calloutState: new Map(calloutState), + }; + }); + updateIsStatusLoaded = this.updater((state, isStatusLoaded: boolean) => ({ ...state, isStatusLoaded, @@ -214,6 +232,14 @@ export class AppStore extends ComponentStore { ); }); + setCloseCallout = this.effect(trigger$ => { + return trigger$.pipe( + tap((id: string) => { + this.updateCalloutState(id); + }) + ); + }); + constructor( private store: Store, private testRunService: TestRunService, @@ -221,10 +247,16 @@ export class AppStore extends ComponentStore { private focusManagerService: FocusManagerService, private notificationService: NotificationService ) { + // @ts-expect-error get object is defined in index.html + const calloutState = sessionStorage.getObject(CALLOUT_STATE_KEY); + super({ consentShown: sessionStorage.getItem(CONSENT_SHOWN_KEY) !== null, isStatusLoaded: false, systemStatus: null, + calloutState: calloutState + ? new Map(Object.entries(calloutState)) + : new Map(), }); } } diff --git a/modules/ui/src/app/components/callout/callout.component.html b/modules/ui/src/app/components/callout/callout.component.html index 39fc14f3f..075f9430d 100644 --- a/modules/ui/src/app/components/callout/callout.component.html +++ b/modules/ui/src/app/components/callout/callout.component.html @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. --> -
+

+
diff --git a/modules/ui/src/app/components/callout/callout.component.scss b/modules/ui/src/app/components/callout/callout.component.scss index 8a9d77125..5a1abc842 100644 --- a/modules/ui/src/app/components/callout/callout.component.scss +++ b/modules/ui/src/app/components/callout/callout.component.scss @@ -31,6 +31,10 @@ top: 60px; } +:host.hidden { + display: none; +} + @media (width < 742px) { :host + ::ng-deep app-callout { top: 80px; @@ -117,3 +121,9 @@ line-height: 20px; letter-spacing: 0.2px; } + +.callout-close-button { + margin-left: auto; + margin-right: -20px; + color: $warn; +} diff --git a/modules/ui/src/app/components/callout/callout.component.spec.ts b/modules/ui/src/app/components/callout/callout.component.spec.ts index 28215ec7c..53e76a814 100644 --- a/modules/ui/src/app/components/callout/callout.component.spec.ts +++ b/modules/ui/src/app/components/callout/callout.component.spec.ts @@ -42,4 +42,27 @@ describe('CalloutComponent', () => { expect(calloutContainerdEl?.classList).toContain('mockValue'); }); + + describe('closeable', () => { + beforeEach(() => { + component.closable = true; + fixture.detectChanges(); + }); + + it('should have close button', () => { + const closeButton = compiled.querySelector('.callout-close-button'); + + expect(closeButton).toBeTruthy(); + }); + + it('should emit event', () => { + const calloutClosedSpy = spyOn(component.calloutClosed, 'emit'); + const closeButton = compiled.querySelector( + '.callout-close-button' + ) as HTMLButtonElement; + closeButton?.click(); + + expect(calloutClosedSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/modules/ui/src/app/components/callout/callout.component.ts b/modules/ui/src/app/components/callout/callout.component.ts index 64487236d..e4be32f45 100644 --- a/modules/ui/src/app/components/callout/callout.component.ts +++ b/modules/ui/src/app/components/callout/callout.component.ts @@ -13,20 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + HostBinding, + Input, + Output, +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; import { CalloutType } from '../../model/callout-type'; @Component({ selector: 'app-callout', standalone: true, - imports: [CommonModule, MatIconModule], + imports: [CommonModule, MatIconModule, MatButtonModule], templateUrl: './callout.component.html', styleUrls: ['./callout.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class CalloutComponent { public readonly CalloutType = CalloutType; + @HostBinding('class.hidden') @Input() closed: boolean = false; + @Input() id: string | null = null; @Input() type = ''; + @Input() closable = false; + @Output() calloutClosed = new EventEmitter(); } diff --git a/modules/ui/src/app/components/device-item/device-item.component.spec.ts b/modules/ui/src/app/components/device-item/device-item.component.spec.ts index 2fe79d8db..dce87cda3 100644 --- a/modules/ui/src/app/components/device-item/device-item.component.spec.ts +++ b/modules/ui/src/app/components/device-item/device-item.component.spec.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Device, DeviceView } from '../../model/device'; +import { Device, DeviceStatus, DeviceView } from '../../model/device'; import { DeviceItemComponent } from './device-item.component'; import { DevicesModule } from '../../pages/devices/devices.module'; @@ -39,6 +39,7 @@ describe('DeviceItemComponent', () => { component = fixture.componentInstance; compiled = fixture.nativeElement as HTMLElement; component.device = { + status: DeviceStatus.VALID, manufacturer: 'Delta', model: 'O3-DIN-CPU', mac_addr: '00:1e:42:35:73:c4', diff --git a/modules/ui/src/app/mocks/device.mock.ts b/modules/ui/src/app/mocks/device.mock.ts index c8b12c523..33e1006a0 100644 --- a/modules/ui/src/app/mocks/device.mock.ts +++ b/modules/ui/src/app/mocks/device.mock.ts @@ -13,11 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Device, DeviceQuestionnaireSection } from '../model/device'; +import { + Device, + DeviceStatus, + DeviceQuestionnaireSection, +} from '../model/device'; import { ProfileRisk } from '../model/profile'; import { FormControlType } from '../model/question'; export const device = { + status: DeviceStatus.VALID, + manufacturer: 'Delta', + model: 'O3-DIN-CPU', + mac_addr: '00:1e:42:35:73:c4', + test_modules: { + dns: { + enabled: true, + }, + }, +} as Device; + +export const expired_device = { + status: DeviceStatus.INVALID, manufacturer: 'Delta', model: 'O3-DIN-CPU', mac_addr: '00:1e:42:35:73:c4', @@ -28,6 +45,7 @@ export const device = { }, } as Device; export const updated_device = { + status: DeviceStatus.VALID, manufacturer: 'Alpha', model: 'O3-XYZ-CPU', mac_addr: '00:1e:42:35:73:11', diff --git a/modules/ui/src/app/mocks/reports.mock.ts b/modules/ui/src/app/mocks/reports.mock.ts index e1422a36c..82e0a48ef 100644 --- a/modules/ui/src/app/mocks/reports.mock.ts +++ b/modules/ui/src/app/mocks/reports.mock.ts @@ -1,11 +1,13 @@ import { HistoryTestrun, TestrunStatus } from '../model/testrun-status'; import { MatTableDataSource } from '@angular/material/table'; +import { DeviceStatus } from '../model/device'; export const HISTORY = [ { mac_addr: '01:02:03:04:05:06', status: 'compliant', device: { + status: DeviceStatus.VALID, manufacturer: 'Delta', model: '03-DIN-SRC', mac_addr: '01:02:03:04:05:06', @@ -19,6 +21,7 @@ export const HISTORY = [ status: 'compliant', mac_addr: '01:02:03:04:05:07', device: { + status: DeviceStatus.VALID, manufacturer: 'Delta', model: '03-DIN-SRC', mac_addr: '01:02:03:04:05:07', @@ -32,6 +35,7 @@ export const HISTORY = [ mac_addr: null, status: 'compliant', device: { + status: DeviceStatus.VALID, manufacturer: 'Delta', model: '03-DIN-SRC', mac_addr: '01:02:03:04:05:08', @@ -48,6 +52,7 @@ export const HISTORY_AFTER_REMOVE = [ mac_addr: '01:02:03:04:05:06', status: 'compliant', device: { + status: DeviceStatus.VALID, manufacturer: 'Delta', model: '03-DIN-SRC', mac_addr: '01:02:03:04:05:06', @@ -61,6 +66,7 @@ export const HISTORY_AFTER_REMOVE = [ mac_addr: null, status: 'compliant', device: { + status: DeviceStatus.VALID, manufacturer: 'Delta', model: '03-DIN-SRC', mac_addr: '01:02:03:04:05:08', @@ -77,6 +83,7 @@ export const FORMATTED_HISTORY = [ status: 'compliant', mac_addr: '01:02:03:04:05:06', device: { + status: DeviceStatus.VALID, manufacturer: 'Delta', model: '03-DIN-SRC', mac_addr: '01:02:03:04:05:06', @@ -93,6 +100,7 @@ export const FORMATTED_HISTORY = [ status: 'compliant', mac_addr: '01:02:03:04:05:07', device: { + status: DeviceStatus.VALID, manufacturer: 'Delta', model: '03-DIN-SRC', mac_addr: '01:02:03:04:05:07', @@ -109,6 +117,7 @@ export const FORMATTED_HISTORY = [ mac_addr: null, status: 'compliant', device: { + status: DeviceStatus.VALID, manufacturer: 'Delta', model: '03-DIN-SRC', mac_addr: '01:02:03:04:05:08', diff --git a/modules/ui/src/app/mocks/testrun.mock.ts b/modules/ui/src/app/mocks/testrun.mock.ts index bb588634c..f782c58f3 100644 --- a/modules/ui/src/app/mocks/testrun.mock.ts +++ b/modules/ui/src/app/mocks/testrun.mock.ts @@ -19,6 +19,7 @@ import { TestrunStatus, TestsData, } from '../model/testrun-status'; +import { DeviceStatus } from '../model/device'; export const TEST_DATA_RESULT: IResult[] = [ { @@ -71,6 +72,7 @@ const PROGRESS_DATA_RESPONSE = ( status, mac_addr: '01:02:03:04:05:06', device: { + status: DeviceStatus.VALID, manufacturer: 'Delta', model: '03-DIN-CPU', mac_addr: '01:02:03:04:05:06', diff --git a/modules/ui/src/app/model/device.ts b/modules/ui/src/app/model/device.ts index 1fa187838..4a0d3d14d 100644 --- a/modules/ui/src/app/model/device.ts +++ b/modules/ui/src/app/model/device.ts @@ -21,6 +21,12 @@ export interface Device { mac_addr: string; test_modules?: TestModules; firmware?: string; + status: DeviceStatus; +} + +export enum DeviceStatus { + VALID = 'Valid', + INVALID = 'Invalid', } /** diff --git a/modules/ui/src/app/pages/devices/components/device-form/device-form.component.spec.ts b/modules/ui/src/app/pages/devices/components/device-form/device-form.component.spec.ts index 1911402a0..087adc37e 100644 --- a/modules/ui/src/app/pages/devices/components/device-form/device-form.component.spec.ts +++ b/modules/ui/src/app/pages/devices/components/device-form/device-form.component.spec.ts @@ -31,7 +31,7 @@ import { MatDialogRef, } from '@angular/material/dialog'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Device } from '../../../../model/device'; +import { Device, DeviceStatus } from '../../../../model/device'; import { of } from 'rxjs'; import { DeviceTestsComponent } from '../../../../components/device-tests/device-tests.component'; import { SpinnerComponent } from '../../../../components/spinner/spinner.component'; @@ -167,6 +167,7 @@ describe('DeviceFormComponent', () => { it('should save data when form is valid', () => { const device: Device = { + status: DeviceStatus.VALID, manufacturer: 'manufacturer', model: 'model', mac_addr: '07:07:07:07:07:07', @@ -371,6 +372,7 @@ describe('DeviceFormComponent', () => { devices: [device], testModules: MOCK_TEST_MODULES, device: { + status: DeviceStatus.VALID, manufacturer: 'Delta', model: 'O3-DIN-CPU', mac_addr: '00:1e:42:35:73:c4', @@ -412,6 +414,7 @@ describe('DeviceFormComponent', () => { const args = mockDevicesStore.editDevice.calls.argsFor(0); // @ts-expect-error config is in object expect(args[0].device).toEqual({ + status: DeviceStatus.VALID, manufacturer: 'Delta', model: 'O3-DIN-CPU', mac_addr: '00:1e:42:35:73:c4', diff --git a/modules/ui/src/app/pages/devices/components/device-form/device-form.component.ts b/modules/ui/src/app/pages/devices/components/device-form/device-form.component.ts index f7aa4f180..36743230c 100644 --- a/modules/ui/src/app/pages/devices/components/device-form/device-form.component.ts +++ b/modules/ui/src/app/pages/devices/components/device-form/device-form.component.ts @@ -23,7 +23,7 @@ import { } from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { Device, TestModule } from '../../../../model/device'; +import { Device, DeviceStatus, TestModule } from '../../../../model/device'; import { DeviceValidators } from './device.validators'; import { Subject } from 'rxjs'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; @@ -159,6 +159,7 @@ export class DeviceFormComponent } ); return { + status: DeviceStatus.VALID, model: this.model.value.trim(), manufacturer: this.manufacturer.value.trim(), mac_addr: this.mac_addr.value.trim(), 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 2dd7263f2..ec7e4dd11 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 @@ -40,6 +40,8 @@ import { TestRunService } from '../../../../services/test-run.service'; import { DevicesStore } from '../../devices.store'; import { provideMockStore } from '@ngrx/store/testing'; import { FormAction } from '../../devices.component'; +import { DeviceStatus } from '../../../../model/device'; + describe('DeviceQualificationFromComponent', () => { let component: DeviceQualificationFromComponent; let fixture: ComponentFixture; @@ -351,6 +353,7 @@ describe('DeviceQualificationFromComponent', () => { devices: [device], testModules: MOCK_TEST_MODULES, device: { + status: DeviceStatus.VALID, manufacturer: 'Delta', model: 'O3-DIN-CPU', mac_addr: '00:1e:42:35:73:c4', diff --git a/modules/ui/src/app/pages/testrun/components/testrun-initiate-form/testrun-initiate-form.component.spec.ts b/modules/ui/src/app/pages/testrun/components/testrun-initiate-form/testrun-initiate-form.component.spec.ts index 2a99f17b0..6fe08782b 100644 --- a/modules/ui/src/app/pages/testrun/components/testrun-initiate-form/testrun-initiate-form.component.spec.ts +++ b/modules/ui/src/app/pages/testrun/components/testrun-initiate-form/testrun-initiate-form.component.spec.ts @@ -23,7 +23,7 @@ import { } from '@angular/material/dialog'; import { TestRunService } from '../../../../services/test-run.service'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; -import { Device } from '../../../../model/device'; +import { Device, DeviceStatus } from '../../../../model/device'; import { DeviceItemComponent } from '../../../../components/device-item/device-item.component'; import { ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; @@ -196,6 +196,7 @@ describe('ProgressInitiateFormComponent', () => { component.startTestRun(); expect(testRunServiceMock.startTestrun).toHaveBeenCalledWith({ + status: DeviceStatus.VALID, manufacturer: 'Delta', model: 'O3-DIN-CPU', mac_addr: '00:1e:42:35:73:c4', diff --git a/modules/ui/src/app/store/actions.ts b/modules/ui/src/app/store/actions.ts index 9235f980f..cf9d3eea5 100644 --- a/modules/ui/src/app/store/actions.ts +++ b/modules/ui/src/app/store/actions.ts @@ -80,6 +80,11 @@ export const setHasDevices = createAction( props<{ hasDevices: boolean }>() ); +export const setHasExpiredDevices = createAction( + '[Shared] Set Has Expired Devices', + props<{ hasExpiredDevices: boolean }>() +); + export const setDevices = createAction( '[Shared] Set Devices', props<{ devices: Device[] }>() diff --git a/modules/ui/src/app/store/effects.spec.ts b/modules/ui/src/app/store/effects.spec.ts index 6f6fae967..b469f8752 100644 --- a/modules/ui/src/app/store/effects.spec.ts +++ b/modules/ui/src/app/store/effects.spec.ts @@ -33,7 +33,7 @@ import { selectMenuOpened, selectSystemStatus, } from './selectors'; -import { device } from '../mocks/device.mock'; +import { device, expired_device } from '../mocks/device.mock'; import { MOCK_PROGRESS_DATA_CANCELLING, MOCK_PROGRESS_DATA_COMPLIANT, @@ -141,6 +141,17 @@ describe('Effects', () => { }); }); + it('onSetExpiredDevices$ should call setHasExpiredDevices', done => { + actions$ = of(actions.setDevices({ devices: [device, expired_device] })); + + effects.onSetExpiredDevices$.subscribe(action => { + expect(action).toEqual( + actions.setHasExpiredDevices({ hasExpiredDevices: true }) + ); + done(); + }); + }); + it('onSetRiskProfiles$ should call setHasRiskProfiles', done => { actions$ = of(actions.setRiskProfiles({ riskProfiles: [PROFILE_MOCK] })); diff --git a/modules/ui/src/app/store/effects.ts b/modules/ui/src/app/store/effects.ts index b94eb55b1..3405c6ef2 100644 --- a/modules/ui/src/app/store/effects.ts +++ b/modules/ui/src/app/store/effects.ts @@ -54,6 +54,7 @@ import { import { takeUntil } from 'rxjs/internal/operators/takeUntil'; import { NotificationService } from '../services/notification.service'; import { Profile } from '../model/profile'; +import { DeviceStatus } from '../model/device'; const WAIT_TO_OPEN_SNACKBAR_MS = 60 * 1000; @@ -144,6 +145,19 @@ export class AppEffects { ); }); + onSetExpiredDevices$ = createEffect(() => { + return this.actions$.pipe( + ofType(AppActions.setDevices), + map(({ devices }) => + AppActions.setHasExpiredDevices({ + hasExpiredDevices: devices.some( + device => device.status === DeviceStatus.INVALID + ), + }) + ) + ); + }); + onSetRiskProfiles$ = createEffect(() => { return this.actions$.pipe( ofType(AppActions.setRiskProfiles), diff --git a/modules/ui/src/app/store/reducers.spec.ts b/modules/ui/src/app/store/reducers.spec.ts index 7d7d5219c..56d52d61d 100644 --- a/modules/ui/src/app/store/reducers.spec.ts +++ b/modules/ui/src/app/store/reducers.spec.ts @@ -21,6 +21,7 @@ import { setDevices, setHasConnectionSettings, setHasDevices, + setHasExpiredDevices, setHasRiskProfiles, setIsOpenAddDevice, setIsOpenStartTestrun, @@ -164,6 +165,18 @@ describe('Reducer', () => { }); }); + describe('setHasExpiredDevices action', () => { + it('should update state', () => { + const initialState = initialSharedState; + const action = setHasExpiredDevices({ hasExpiredDevices: true }); + const state = fromReducer.sharedReducer(initialState, action); + const newState = { ...initialState, ...{ hasExpiredDevices: true } }; + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); + describe('setDevices action', () => { it('should update state', () => { const initialState = initialSharedState; diff --git a/modules/ui/src/app/store/reducers.ts b/modules/ui/src/app/store/reducers.ts index 9d8bc7cde..05d1c6c61 100644 --- a/modules/ui/src/app/store/reducers.ts +++ b/modules/ui/src/app/store/reducers.ts @@ -65,6 +65,12 @@ export const sharedReducer = createReducer( hasDevices, }; }), + on(Actions.setHasExpiredDevices, (state, { hasExpiredDevices }) => { + return { + ...state, + hasExpiredDevices, + }; + }), on(Actions.setDevices, (state, { devices }) => { return { ...state, diff --git a/modules/ui/src/app/store/selectors.spec.ts b/modules/ui/src/app/store/selectors.spec.ts index b6c709c57..29b580351 100644 --- a/modules/ui/src/app/store/selectors.spec.ts +++ b/modules/ui/src/app/store/selectors.spec.ts @@ -33,6 +33,7 @@ import { selectStatus, selectSystemStatus, selectTestModules, + selectHasExpiredDevices, } from './selectors'; describe('Selectors', () => { @@ -49,6 +50,7 @@ describe('Selectors', () => { hasConnectionSettings: false, devices: [], hasDevices: false, + hasExpiredDevices: false, isOpenAddDevice: false, riskProfiles: [], hasRiskProfiles: false, @@ -94,6 +96,11 @@ describe('Selectors', () => { expect(result).toEqual(false); }); + it('should select hasExpiredDevices', () => { + const result = selectHasExpiredDevices.projector(initialState); + expect(result).toEqual(false); + }); + it('should select riskProfiles', () => { const result = selectRiskProfiles.projector(initialState); expect(result).toEqual([]); diff --git a/modules/ui/src/app/store/selectors.ts b/modules/ui/src/app/store/selectors.ts index 9671bcd10..451fa31d9 100644 --- a/modules/ui/src/app/store/selectors.ts +++ b/modules/ui/src/app/store/selectors.ts @@ -47,6 +47,11 @@ export const selectHasDevices = createSelector( (state: AppState) => state.shared.hasDevices ); +export const selectHasExpiredDevices = createSelector( + selectAppState, + (state: AppState) => state.shared.hasExpiredDevices +); + export const selectDevices = createSelector( selectAppState, (state: AppState) => state.shared.devices diff --git a/modules/ui/src/app/store/state.ts b/modules/ui/src/app/store/state.ts index 63e0c5eff..263f76cd5 100644 --- a/modules/ui/src/app/store/state.ts +++ b/modules/ui/src/app/store/state.ts @@ -43,6 +43,7 @@ export interface SharedState { devices: Device[]; //used in app, devices, testrun hasDevices: boolean; + hasExpiredDevices: boolean; //app, risk-assessment, testrun, reports riskProfiles: Profile[]; hasRiskProfiles: boolean; @@ -78,6 +79,7 @@ export const initialSharedState: SharedState = { isStopTestrun: false, isOpenWaitSnackBar: false, hasDevices: false, + hasExpiredDevices: false, devices: [], deviceInProgress: null, riskProfiles: [], diff --git a/modules/ui/src/index.html b/modules/ui/src/index.html index 091591cac..375cf9243 100644 --- a/modules/ui/src/index.html +++ b/modules/ui/src/index.html @@ -28,6 +28,22 @@ j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; f.parentNode.insertBefore(j, f); })(window, document, 'script', 'dataLayer', 'GTM-NDFZ7L89'); + + Storage.prototype.setObject = function (key, value) { + if (value instanceof Map) { + this.setItem( + key, + JSON.stringify(Object.fromEntries(value.entries())) + ); + } else { + this.setItem(key, JSON.stringify(value)); + } + }; + + Storage.prototype.getObject = function (key) { + var value = this.getItem(key); + return value && JSON.parse(value); + }; From 78f747ed4eee1f88f9ec8077fdeaf614033b65a6 Mon Sep 17 00:00:00 2001 From: Sofia Kurilova Date: Tue, 20 Aug 2024 13:01:54 +0200 Subject: [PATCH 10/14] Save state of callout in session storage (#696) --- modules/ui/src/app/components/callout/callout.component.html | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/ui/src/app/components/callout/callout.component.html b/modules/ui/src/app/components/callout/callout.component.html index 075f9430d..8a01c241b 100644 --- a/modules/ui/src/app/components/callout/callout.component.html +++ b/modules/ui/src/app/components/callout/callout.component.html @@ -26,6 +26,7 @@