[UI] Make sure form validation displays non-valid fields as red in all forms (#7064)

* Add validation to multi-container component

This covers the following forms:
- Add commands when adding a Composite Command

Co-authored-by: Philippe Martin <phmartin@redhat.com>

* Add validation to multi-key-value component

This covers the following forms:
- Add Environment variables in Create Container
- Add Deployment annotations in Create Container
- Add Service annotations in Create Container

Co-authored-by: Philippe Martin <phmartin@redhat.com>

* Add validation to multi-text component

This covers the following forms:
- Add Command in Create Container
- Add Args in Create Container
- Add Args in Create Image

Co-authored-by: Philippe Martin <phmartin@redhat.com>

* Add validation to select-container component

This covers the following forms:
- Select or Create container in Add Exec Command
- Select or create image component in Add Image Command
- Select or create Resource in Add Apply command

Co-authored-by: Philippe Martin <phmartin@redhat.com>

* Add validation to volume-mounts component

This covers the following forms:
- Select or Create volume mount in Create container

Co-authored-by: Philippe Martin <phmartin@redhat.com>

* Add error helper message for invalid volume size quantities

* Fix Cypress tests

* Generate static UI

* fixup! Add error helper message for invalid volume size quantities

Co-authored-by: Philippe Martin <phmartin@redhat.com>

* Generate static UI

---------

Co-authored-by: Philippe Martin <phmartin@redhat.com>
This commit is contained in:
Armel Soro
2023-09-05 17:14:37 +02:00
committed by GitHub
parent 3f93ac0744
commit adc96994d9
14 changed files with 230 additions and 133 deletions

View File

@@ -11,6 +11,6 @@
<body class="mat-typography">
<div id="loading">Loading, please wait...</div>
<app-root></app-root>
<script src="runtime.1289ea0acffcdc5e.js" type="module"></script><script src="polyfills.8b3b37cedaf377c3.js" type="module"></script><script src="main.4d8dc3ef32c88ca3.js" type="module"></script>
<script src="runtime.1289ea0acffcdc5e.js" type="module"></script><script src="polyfills.8b3b37cedaf377c3.js" type="module"></script><script src="main.9400449aa2437590.js" type="module"></script>
</body></html>

File diff suppressed because one or more lines are too long

View File

@@ -62,7 +62,7 @@ describe('devfile editor spec', () => {
cy.getByDataCy('container-env-value-2').type("val3");
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-0').type("/mnt/vol1");
cy.getByDataCy('volume-mount-path-0').type("/mnt/vol1", {force: true});
cy.getByDataCy('volume-mount-name-0').click().get('mat-option').contains('volume1').click();
cy.getByDataCy('endpoints-add').click();
@@ -70,7 +70,7 @@ describe('devfile editor spec', () => {
cy.getByDataCy('endpoint-targetPort-0').type("4001");
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-1').type("/mnt/vol2");
cy.getByDataCy('volume-mount-path-1').type("/mnt/vol2", {force: true});
cy.getByDataCy('volume-mount-name-1').click().get('mat-option').contains('(New Volume)').click();
cy.getByDataCy('volume-name').type('volume2');
cy.getByDataCy('volume-create').click();
@@ -134,11 +134,11 @@ describe('devfile editor spec', () => {
cy.getByDataCy('container-source-mapping').type('/mnt/sources');
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-0').type("/mnt/vol1");
cy.getByDataCy('volume-mount-path-0').type("/mnt/vol1", {force: true});
cy.getByDataCy('volume-mount-name-0').click().get('mat-option').contains('volume1').click();
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-1').type("/mnt/vol2");
cy.getByDataCy('volume-mount-path-1').type("/mnt/vol2", {force: true});
cy.getByDataCy('volume-mount-name-1').click().get('mat-option').contains('(New Volume)').click();
cy.getByDataCy('volume-name').type('volume2');
cy.getByDataCy('volume-create').click();
@@ -397,11 +397,11 @@ describe('devfile editor spec', () => {
cy.getByDataCy('container-image').type('an-image');
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-0').type("/mnt/vol1");
cy.getByDataCy('volume-mount-path-0').type("/mnt/vol1", {force: true});
cy.getByDataCy('volume-mount-name-0').click().get('mat-option').contains('volume1').click();
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-1').type("/mnt/vol2");
cy.getByDataCy('volume-mount-path-1').type("/mnt/vol2", {force: true});
cy.getByDataCy('volume-mount-name-1').click().get('mat-option').contains('(New Volume)').click();
cy.getByDataCy('volume-name').type('volume2');
cy.getByDataCy('volume-create').click();

View File

@@ -1,14 +1,15 @@
<h3>{{title}}</h3>
<div class="group">
<span *ngFor="let command of commands; let i=index">
<span *ngFor="let control of form.controls; index as i">
<mat-form-field appearance="fill">
<mat-select [value]="command" (selectionChange)="onCommandChange(i, $event.value)">
<mat-label><span>Command</span></mat-label>
<mat-select [formControl]="control">
<mat-option *ngFor="let commandElement of commandList" [value]="commandElement">{{commandElement}}</mat-option>
</mat-select>
</mat-form-field>
</span>
<button *ngIf="commands.length > 0" mat-icon-button (click)="addCommand()">
<button *ngIf="form.controls.length > 0" mat-icon-button (click)="addCommand('')">
<mat-icon class="tab-icon material-icons-outlined">add</mat-icon>
</button>
<button *ngIf="commands.length == 0" mat-flat-button (click)="addCommand()">{{addLabel}}</button>
<button *ngIf="form.controls.length == 0" mat-flat-button (click)="addCommand('')">{{addLabel}}</button>
</div>

View File

@@ -1,5 +1,14 @@
import { Component, Input } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import {Component, forwardRef, Input} from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormArray,
FormControl,
FormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR, ValidationErrors, Validator,
Validators
} from '@angular/forms';
@Component({
selector: 'app-multi-command',
@@ -10,10 +19,15 @@ import { NG_VALUE_ACCESSOR } from '@angular/forms';
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: MultiCommandComponent
}
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => MultiCommandComponent),
multi: true,
},
]
})
export class MultiCommandComponent {
export class MultiCommandComponent implements ControlValueAccessor, Validator {
@Input() addLabel: string = "";
@Input() commandList: string[] = [];
@@ -21,10 +35,16 @@ export class MultiCommandComponent {
onChange = (_: string[]) => {};
commands: string[] = [];
form = new FormArray<FormControl>([]);
writeValue(value: any) {
this.commands = value;
constructor() {
this.form.valueChanges.subscribe(value => {
this.onChange(value);
});
}
writeValue(value: string[]) {
value.forEach(v => this.addCommand(v));
}
registerOnChange(onChange: any) {
@@ -33,13 +53,19 @@ export class MultiCommandComponent {
registerOnTouched(_: any) {}
addCommand() {
this.commands.push("");
this.onChange(this.commands);
newCommand(cmdName : string) {
return new FormControl(cmdName, [Validators.required]);
}
onCommandChange(i: number, cmd: string) {
this.commands[i] = cmd;
this.onChange(this.commands);
addCommand(cmdName: string) {
this.form.push(this.newCommand(cmdName));
}
/* Validator implementation */
validate(control: AbstractControl): ValidationErrors | null {
if (!this.form.valid) {
return {'internal': true};
}
return null;
}
}

View File

@@ -1,16 +1,18 @@
<div class="group">
<span *ngFor="let entry of entries; let i=index">
<div *ngFor="let control of form.controls; index as i">
<ng-container [formGroup]="control">
<mat-form-field class="mid-width" appearance="outline">
<mat-label><span>Name</span></mat-label>
<input [attr.data-cy]="dataCyPrefix+'-name-'+i" matInput [value]="entry.name" (change)="onKeyChange(i, $event)" (input)="onKeyChange(i, $event)">
<input [attr.data-cy]="dataCyPrefix+'-name-'+i" matInput formControlName="name">
</mat-form-field>
<mat-form-field class="mid-width" appearance="outline">
<mat-label><span>Value</span></mat-label>
<input [attr.data-cy]="dataCyPrefix+'-value-'+i" matInput [value]="entry.value" (change)="onValueChange(i, $event)" (input)="onValueChange(i, $event)">
<input [attr.data-cy]="dataCyPrefix+'-value-'+i" matInput formControlName="value">
</mat-form-field>
</span>
<button [attr.data-cy]="dataCyPrefix+'-plus'" *ngIf="entries.length > 0" mat-icon-button (click)="addEntry()">
</ng-container>
</div>
<button [attr.data-cy]="dataCyPrefix+'-plus'" *ngIf="form.controls.length > 0" mat-icon-button (click)="addEntry('', '')">
<mat-icon class="tab-icon material-icons-outlined">add</mat-icon>
</button>
<button [attr.data-cy]="dataCyPrefix+'-add'" *ngIf="entries.length == 0" mat-flat-button (click)="addEntry()">{{addLabel}}</button>
<button [attr.data-cy]="dataCyPrefix+'-add'" *ngIf="form.controls.length == 0" mat-flat-button (click)="addEntry('', '')">{{addLabel}}</button>
</div>

View File

@@ -1,5 +1,16 @@
import { Component, Input, forwardRef } from '@angular/core';
import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, Validators } from '@angular/forms';
import {Component, forwardRef, Input} from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormArray,
FormControl,
FormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
interface KeyValue {
name: string;
@@ -28,13 +39,19 @@ export class MultiKeyValueComponent implements ControlValueAccessor, Validator {
@Input() dataCyPrefix: string = "";
@Input() addLabel: string = "";
form = new FormArray<FormGroup>([]);
onChange = (_: KeyValue[]) => {};
onValidatorChange = () => {};
entries: KeyValue[] = [];
constructor() {
this.form.valueChanges.subscribe(value => {
this.onChange(value);
});
}
writeValue(value: KeyValue[]) {
this.entries = value;
value.forEach(v => this.addEntry(v.name, v.value));
}
registerOnChange(onChange: any) {
@@ -43,31 +60,22 @@ export class MultiKeyValueComponent implements ControlValueAccessor, Validator {
registerOnTouched(_: any) {}
addEntry() {
this.entries.push({name: "", value: ""});
this.onChange(this.entries);
newKeyValueForm(kv: KeyValue): FormGroup {
return new FormGroup({
name: new FormControl(kv.name, [Validators.required]),
value: new FormControl(kv.value, [Validators.required]),
});
}
onKeyChange(i: number, e: Event) {
const target = e.target as HTMLInputElement;
this.entries[i].name = target.value;
this.onChange(this.entries);
}
onValueChange(i: number, e: Event) {
const target = e.target as HTMLInputElement;
this.entries[i].value = target.value;
this.onChange(this.entries);
addEntry(name: string, value: string) {
this.form.push(this.newKeyValueForm({name, value}));
}
/* Validator implementation */
validate(control: AbstractControl): ValidationErrors | null {
for (let i=0; i<this.entries.length; i++) {
const entry = this.entries[i];
if (entry.name == "" || entry.value == "") {
if (!this.form.valid) {
return {'internal': true};
}
}
return null;
}

View File

@@ -1,13 +1,13 @@
<h3 *ngIf="title">{{title}}</h3>
<div class="group">
<span *ngFor="let text of texts; let i=index">
<span *ngFor="let control of form.controls; index as i">
<mat-form-field class="inline" appearance="outline">
<mat-label><span>{{label}}</span></mat-label>
<input matInput [value]="text" (change)="onTextChange(i, $event)">
<input matInput [formControl]="control">
</mat-form-field>
</span>
<button *ngIf="texts.length > 0" mat-icon-button (click)="addText()">
<button *ngIf="form.controls.length > 0" mat-icon-button (click)="addText('')">
<mat-icon class="tab-icon material-icons-outlined">add</mat-icon>
</button>
<button *ngIf="texts.length == 0" mat-flat-button (click)="addText()">{{addLabel}}</button>
<button *ngIf="form.controls.length == 0" mat-flat-button (click)="addText('')">{{addLabel}}</button>
</div>

View File

@@ -1,5 +1,15 @@
import { Component, Input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import {Component, forwardRef, Input} from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormArray,
FormControl,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
@Component({
selector: 'app-multi-text',
@@ -10,10 +20,15 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: MultiTextComponent
}
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => MultiTextComponent),
multi: true,
},
]
})
export class MultiTextComponent implements ControlValueAccessor {
export class MultiTextComponent implements ControlValueAccessor, Validator {
@Input() label: string = "";
@Input() addLabel: string = "";
@@ -21,13 +36,20 @@ export class MultiTextComponent implements ControlValueAccessor {
onChange = (_: string[]) => {};
texts: string[] = [];
form = new FormArray<FormControl>([]);
writeValue(value: any) {
if (value == null) {
value = [];
constructor() {
this.form.valueChanges.subscribe(value => {
this.onChange(value);
});
}
this.texts = value;
newText(text: string): FormControl {
return new FormControl(text, [Validators.required]);
}
writeValue(value: string[]) {
value?.forEach(v => this.addText(v));
}
registerOnChange(onChange: any) {
@@ -36,14 +58,15 @@ export class MultiTextComponent implements ControlValueAccessor {
registerOnTouched(_: any) {}
addText() {
this.texts.push("");
this.onChange(this.texts);
addText(text: string) {
this.form.push(this.newText(text));
}
onTextChange(i: number, e: Event) {
const target = e.target as HTMLInputElement;
this.texts[i] = target.value;
this.onChange(this.texts);
/* Validator implementation */
validate(control: AbstractControl): ValidationErrors | null {
if (!this.form.valid) {
return {'internal': true};
}
return null;
}
}

View File

@@ -1,6 +1,6 @@
<mat-form-field appearance="fill">
<mat-label>{{label}}</mat-label>
<mat-select data-cy="select-container" [value]="container" (selectionChange)="onSelectChange($event.value)">
<mat-select [formControl]="formCtrl" data-cy="select-container" (selectionChange)="onSelectChange($event.value)">
<mat-option *ngFor="let container of containers" [value]="container">{{container}}</mat-option>
<mat-option value="!">(New {{label}})</mat-option>
</mat-select>

View File

@@ -1,5 +1,12 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import {Component, EventEmitter, forwardRef, Input, Output} from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormArray, FormControl,
FormGroup, NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors, Validator, Validators
} from '@angular/forms';
@Component({
selector: 'app-select-container',
@@ -10,21 +17,30 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: SelectContainerComponent
}
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => SelectContainerComponent),
multi: true,
},
]
})
export class SelectContainerComponent implements ControlValueAccessor {
export class SelectContainerComponent implements ControlValueAccessor, Validator {
@Input() containers: string[] = [];
@Input() label: string = "";
@Output() createNew = new EventEmitter<boolean>();
container: string = "";
formCtrl: FormControl;
onChange = (_: string) => {};
writeValue(value: any) {
this.container = value;
constructor() {
this.formCtrl = new FormControl('', [Validators.required]);
}
writeValue(value: string) {
this.formCtrl.setValue(value);
}
registerOnChange(onChange: any) {
@@ -39,4 +55,12 @@ export class SelectContainerComponent implements ControlValueAccessor {
}
this.createNew.emit(v == "!");
}
/* Validator implementation */
validate(control: AbstractControl): ValidationErrors | null {
if (!this.formCtrl.valid) {
return {'internal': true};
}
return null;
}
}

View File

@@ -1,25 +1,27 @@
<div class="group">
<div *ngFor="let vm of volumeMounts; let i=index">
<div *ngFor="let control of form.controls; index as i">
<ng-container [formGroup]="control">
<mat-form-field class="inline" appearance="outline">
<mat-label><span>Volume</span></mat-label>
<mat-select [attr.data-cy]="'volume-mount-name-'+i" [value]="vm.name" (selectionChange)="onNameChange(i, $event.value)">
<mat-select formControlName="name" [attr.data-cy]="'volume-mount-name-'+i" (selectionChange)="onNameChange(i, $event.value)">
<mat-option *ngFor="let volume of volumes" [value]="volume">{{volume}}</mat-option>
<mat-option value="!">(New Volume)</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="inline" appearance="outline">
<mat-label><span>Mount Path</span></mat-label>
<input (input)="onPathChange(i, $event)" [attr.data-cy]="'volume-mount-path-'+i" matInput [value]="vm.path" (change)="onPathChange(i, $event)">
<input formControlName="path" [attr.data-cy]="'volume-mount-path-'+i" matInput>
</mat-form-field>
<app-volume
*ngIf="showNewVolume[i]"
(created)="onNewVolumeCreated(i, $event)"
></app-volume>
</ng-container>
</div>
<button data-cy="volume-mount-add" *ngIf="volumeMounts.length > 0" mat-icon-button (click)="add()">
<button data-cy="volume-mount-add" *ngIf="form.controls.length > 0" mat-icon-button (click)="add('', '')">
<mat-icon class="tab-icon material-icons-outlined">add</mat-icon>
</button>
<button data-cy="volume-mount-add" *ngIf="volumeMounts.length == 0" mat-flat-button (click)="add()">Add Volume Mount</button>
<button data-cy="volume-mount-add" *ngIf="form.controls.length == 0" mat-flat-button (click)="add('', '')">Add Volume Mount</button>
</div>

View File

@@ -1,6 +1,17 @@
import { Component, EventEmitter, Input, Output, forwardRef } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator } from '@angular/forms';
import { Volume, VolumeMount } from 'src/app/api-gen';
import {Component, EventEmitter, forwardRef, Input, Output} from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormArray,
FormControl,
FormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import {Volume, VolumeMount} from 'src/app/api-gen';
@Component({
selector: 'app-volume-mounts',
@@ -19,20 +30,27 @@ import { Volume, VolumeMount } from 'src/app/api-gen';
},
]
})
export class VolumeMountsComponent implements Validator {
export class VolumeMountsComponent implements ControlValueAccessor, Validator {
@Input() volumes: string[] = [];
@Output() createNewVolume = new EventEmitter<Volume>();
volumeMounts: VolumeMount[] = [];
form = new FormArray<FormGroup>([]);
showNewVolume: boolean[] = [];
onChange = (_: VolumeMount[]) => {};
onValidatorChange = () => {};
writeValue(value: any) {
this.volumeMounts = value;
constructor() {
this.form.valueChanges.subscribe(value => {
this.onChange(value);
});
}
writeValue(value: VolumeMount[]) {
value.forEach(v => this.add(v.name, v.path));
}
registerOnChange(onChange: any) {
@@ -41,29 +59,24 @@ export class VolumeMountsComponent implements Validator {
registerOnTouched(_: any) {}
add() {
this.volumeMounts.push({name: "", path: ""});
this.onChange(this.volumeMounts);
newVolumeMount(vol: VolumeMount): FormGroup {
return new FormGroup({
name: new FormControl(vol.name, [Validators.required]),
path: new FormControl(vol.path, [Validators.required]),
});
}
onPathChange(i: number, e: Event) {
const target = e.target as HTMLInputElement;
this.volumeMounts[i].path = target.value;
this.onChange(this.volumeMounts);
add(name: string, path: string) {
this.form.push(this.newVolumeMount({name, path}));
}
onNameChange(i: number, name: string) {
if (name != "!") {
this.volumeMounts[i].name = name;
this.onChange(this.volumeMounts);
}
this.showNewVolume[i] = name == "!";
}
onNewVolumeCreated(i: number, v: Volume) {
this.volumes.push(v.name);
this.volumeMounts[i].name = v.name;
this.form.at(i).get('name')?.setValue(v.name);
this.createNewVolume.next(v);
this.showNewVolume[i] = false;
this.onValidatorChange();
@@ -71,12 +84,9 @@ export class VolumeMountsComponent implements Validator {
/* Validator implementation */
validate(control: AbstractControl): ValidationErrors | null {
for (let i=0; i<this.volumeMounts.length; i++) {
const vm = this.volumeMounts[i];
if (vm.name == "" || vm.path == "") {
if (!this.form.valid) {
return {'internal': true};
}
}
return null;
}

View File

@@ -10,6 +10,7 @@
</mat-form-field>
<mat-form-field appearance="outline" class="mid-width">
<mat-label><span>Size</span></mat-label>
<mat-error>Example of valid quantities: 300k (300*1000), 30Mi(30*1024²), 3Gi (3*1024³), 3G (3*1000³)</mat-error>
<input placeholder="Minimal size of the volume" data-cy="volume-size" matInput formControlName="size">
</mat-form-field>
<mat-checkbox data-cy="volume-ephemeral" formControlName="ephemeral">Volume is Ephemeral</mat-checkbox>