diff --git a/modules/ui/src/app/mocks/profile.mock.ts b/modules/ui/src/app/mocks/profile.mock.ts index 000be4337..596f922f8 100644 --- a/modules/ui/src/app/mocks/profile.mock.ts +++ b/modules/ui/src/app/mocks/profile.mock.ts @@ -40,6 +40,7 @@ export const PROFILE_FORM: ProfileFormat[] = [ type: FormControlType.TEXTAREA, validation: { required: true, + max: '28', }, description: 'This tells us about the device', }, @@ -47,9 +48,8 @@ export const PROFILE_FORM: ProfileFormat[] = [ question: 'Has this device already been through a criticality assessment with testrun?', type: FormControlType.SELECT, - options: [], + options: ['1', '2', '3'], validation: { - max: '128', required: true, }, }, @@ -67,5 +67,9 @@ export const PROFILE_FORM: ProfileFormat[] = [ question: 'Comments', type: FormControlType.TEXT, description: 'Please enter any comments here', + validation: { + max: '28', + required: true, + }, }, ]; 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 1738d5ef0..b00f89e67 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,7 +15,6 @@ -->
-

Profile name *

{{ description }} + + Please, check. “ and \ are not allowed. + + + The field is required + @@ -187,6 +195,15 @@ id="{{ formControlName }}-group" [formControlName]="formControlName" /> {{ description }} + + Please, check. “ and \ are not allowed. + + + The field is required + @@ -204,6 +221,15 @@ id="{{ formControlName }}-group" [formControlName]="formControlName" /> {{ description }} + + The field is required + + + Please, check the email address. Valid e-mail can contain only latin + letters, numbers, @ and . (dot). + @@ -252,5 +278,8 @@ {{ 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 f65dc04c0..baae7fb38 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 @@ -34,6 +34,9 @@ &: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 { width: 100%; 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 cc2a328cb..9cc94bb30 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 @@ -173,10 +173,122 @@ describe('ProfileFormComponent', () => { }); if (item.type === FormControlType.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 || ''); + 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'); + const select = fields[uiIndex].querySelector( + 'mat-select' + ) as HTMLElement; + + select.focus(); + select.blur(); + + 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.TEXT || + item.type === FormControlType.TEXTAREA) && + item.validation?.required + ) { + describe('text or text-long', () => { + 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: HTMLInputElement = fields[uiIndex].querySelector( + 'input' + ) as HTMLInputElement; + input.value = ''; + input.dispatchEvent(new Event('input')); + component.getControl(index).markAsTouched(); + fixture.detectChanges(); + + const error = + fields[uiIndex].querySelector('mat-error')?.textContent; + + expect(error).toContain('The field is required'); + }); + + 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 very long value very long value very long value', + 'as\\\\\\\\\\""""""""', + ].forEach(value => { + 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( + 'input' + ) as HTMLInputElement; + input.value = value; + input.dispatchEvent(new Event('input')); + component.getControl(index).markAsTouched(); + fixture.detectChanges(); + + const error = compiled.querySelector('mat-error')?.textContent; + expect(error).toContain( + 'Please, check. “ and \\ are not allowed.' + ); + }); + }); + }); + } + + if ( + item.type === FormControlType.EMAIL_MULTIPLE && + item.validation?.required + ) { + describe('text or text-long', () => { + 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: HTMLInputElement = fields[uiIndex].querySelector( + 'input' + ) as HTMLInputElement; + input.value = ''; + input.dispatchEvent(new Event('input')); + component.getControl(index).markAsTouched(); + fixture.detectChanges(); + + const error = + fields[uiIndex].querySelector('mat-error')?.textContent; + + expect(error).toContain('The field is required'); + }); + + 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 very long value very long value very long value', + 'as\\\\\\\\\\""""""""', + ].forEach(value => { + 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( + 'input' + ) as HTMLInputElement; + input.value = value; + input.dispatchEvent(new Event('input')); + component.getControl(index).markAsTouched(); + fixture.detectChanges(); + + const error = compiled.querySelector('mat-error')?.textContent; + expect(error).toContain( + 'Please, check the email address. Valid e-mail can contain only latin letters, numbers, @ and . (dot).' + ); + }); + }); }); } }); 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 85f93f546..3019df378 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 @@ -20,25 +20,28 @@ import { OnInit, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatError, MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { CommonModule } from '@angular/common'; import { AbstractControl, - FormControl, FormBuilder, + FormControl, FormGroup, ReactiveFormsModule, + ValidatorFn, Validators, } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; import { DeviceValidators } from '../../devices/components/device-form/device.validators'; -import { Profile } from '../../../model/profile'; +import { + FormControlType, + Profile, + ProfileFormat, + Validation, +} from '../../../model/profile'; import { ProfileValidators } from './profile.validators'; -import { MatError } from '@angular/material/form-field'; - -import { FormControlType, ProfileFormat } from '../../../model/profile'; @Component({ selector: 'app-profile-form', @@ -73,7 +76,11 @@ export class ProfileFormComponent implements OnInit { } get nameControl() { - return this.profileForm.get('name') as AbstractControl; + return this.getControl('name'); + } + + getControl(name: string | number) { + return this.profileForm.get(name.toString()) as AbstractControl; } createProfileForm(questions: ProfileFormat[]): FormGroup { @@ -87,22 +94,47 @@ export class ProfileFormComponent implements OnInit { ]); questions.forEach((question, index) => { + const validators = this.getValidators(question.type, question.validation); if (question.type === FormControlType.SELECT_MULTIPLE) { group[index] = this.getMultiSelectGroup(question); } else { - group[index] = new FormControl(question.default || ''); + 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(Validators.required); + } + if (type === FormControlType.EMAIL_MULTIPLE) { + validators.push( + this.profileValidators.emailStringFormat(Number(validation.max)) + ); + } + if (type === FormControlType.TEXT || type === FormControlType.TEXTAREA) { + validators.push( + this.profileValidators.textFormat(Number(validation.max)) + ); + } + } + 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); + return this.fb.group(group, { + validators: question.validation?.required + ? [this.profileValidators.multiSelectRequired] + : [], + }); } getFormGroup(name: string): FormGroup { diff --git a/modules/ui/src/app/pages/risk-assessment/profile-form/profile.validators.ts b/modules/ui/src/app/pages/risk-assessment/profile-form/profile.validators.ts index 7de2cd353..bed826c45 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-form/profile.validators.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-form/profile.validators.ts @@ -14,10 +14,23 @@ * limitations under the License. */ import { Injectable } from '@angular/core'; -import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { + AbstractControl, + FormGroup, + ValidationErrors, + ValidatorFn, +} from '@angular/forms'; import { Profile } from '../../../model/profile'; + @Injectable({ providedIn: 'root' }) export class ProfileValidators { + readonly MULTIPLE_EMAIL_FORMAT_REGEXP = new RegExp( + '^(([a-zA-Z0-9_\\-\\.]+)@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.)|(([a-zA-Z0-9\\-]+\\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\\]?)(\\s*;\\s*|\\s*$))*$', + 'i' + ); + + readonly STRING_FORMAT_REGEXP = new RegExp('^[^"\\\\]*$', 'u'); + public differentProfileName(profiles: Profile[]): ValidatorFn { return (control: AbstractControl): ValidationErrors | null => { const value = control.value?.trim(); @@ -29,6 +42,33 @@ export class ProfileValidators { }; } + public multiSelectRequired(g: FormGroup) { + if (Object.values(g.value).every(value => value === false)) { + return { required: true }; + } + return null; + } + + public emailStringFormat(maxLength: number = 128): ValidatorFn { + return this.stringFormat(this.MULTIPLE_EMAIL_FORMAT_REGEXP, maxLength); + } + + public textFormat(maxLength: number = 128): ValidatorFn { + return this.stringFormat(this.STRING_FORMAT_REGEXP, maxLength); + } + + private stringFormat(regExp: RegExp, maxLength: number = 28): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value?.trim(); + if (value) { + if (value.length > maxLength) return { invalid_format: true }; + const result = regExp.test(value); + return !result ? { invalid_format: true } : null; + } + return null; + }; + } + private hasSameProfileName( profileName: string, profiles: Profile[] 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 2d1246369..cf82508de 100644 --- a/modules/ui/src/app/services/test-run.service.spec.ts +++ b/modules/ui/src/app/services/test-run.service.spec.ts @@ -616,4 +616,20 @@ describe('TestRunService', () => { req.flush(result); }); }); + + describe('fetchProfilesFormat', () => { + it('should get system status data with no changes', () => { + const result = { ...PROFILE_FORM }; + + service.fetchProfilesFormat().subscribe(res => { + expect(res).toEqual(result); + }); + + const req = httpTestingController.expectOne( + 'http://localhost:8000/profiles/format' + ); + expect(req.request.method).toBe('GET'); + req.flush(result); + }); + }); });