ControlValueAccessor for an Array plus Validator
During the development of our web pages with Angular, a client specification for a highly customized form exceeded the default capabilities of Angular’s reactive forms.
A custom form component able to handle Arrays was written, which implements the ControlValueAccessor and Validator interfaces to have the control of sub-form and main form re-validation on each state change of a single array element.
The combination of ControlValueAccessor for an array with Validator seam to be a special case and infrequent on the Internet. In the following, a minimal example with comments is given as a free service from Redlink GmbH for easy reusage and a well economy of your time.
Classes
The YourArrayComponent class in the your-array.component.ts file contains the form value, being an array, and its validation method.
@Component({ | |
selector: 'app-your-array', | |
templateUrl: './your-array.component.html', | |
styleUrls: ['./your-array.component.scss'], | |
providers: [{ | |
provide: NG_VALUE_ACCESSOR, // Is an InjectionToken required by the ControlValueAccessor interface to provide a form value | |
useExisting: forwardRef(() => YourArrayComponent), // tells Angular to use the existing instance | |
multi: true, | |
}, | |
{ | |
provide: NG_VALIDATORS, // Is an InjectionToken required by this class to be able to be used as an Validator | |
useExisting: forwardRef(() => YourArrayComponent), | |
multi: true, | |
}] | |
}) | |
export class YourArrayComponent implements ControlValueAccessor, Validator { | |
yourArray: YourType[] = []; | |
discloseChange = (_: any) => {}; // Called on a value change | |
discloseTouched = () => {}; // Called if you care if the form was touched | |
discloseValidatorChange = () => {}; // Called on a validator change or re-validation; | |
newEntry(yourEntry: YourType): void { | |
const index = this.yourArray.findIndex(alreadyAdded => alreadyAdded.property === yourEntry.property); | |
if (index > -1) { | |
this.yourArray.splice(index, 1, yourEntry); | |
} else { | |
this.yourArray.splice(0, 0, yourEntry); | |
} | |
this.value = this.yourArray; // Invokes bottom setter | |
} | |
deleteEntry(yourEntry: YourType): void{ | |
const index = this.yourEntry.findIndex(alreadyAdded => alreadyAdded.property === yourEntry.property); | |
this.yourArray.splice(index, 1); | |
this.value = this.yourArray; // Invokes bottom setter | |
} | |
get value(): YourType[] { | |
return this.yourArray; | |
} | |
set value(newValue: YourType[]) { | |
this.yourArray = newValue; | |
this.discloseChange(this.yourArray); | |
this.discloseValidatorChange(); | |
} | |
registerOnChange(fn: any): void { | |
this.discloseChange = fn; | |
} | |
registerOnTouched(fn: any): void { | |
this.discloseTouched = fn; | |
} | |
writeValue(obj: YourType[]): void { | |
this.value = obj; | |
} | |
validate(control: AbstractControl): ValidationErrors | null { | |
let valid = true; | |
if (!!this.yourArray && this.yourArray.length > 0) { | |
this.yourArray.forEach(yourEntry => valid = valid && !!yourEntry); // Perform here your single item validation | |
} | |
return valid ? null : {invalid: true}; | |
} | |
registerOnValidatorChange?(fn: () => void): void { | |
this.discloseValidatorChange = fn; | |
} | |
} |
The your-array.component.scss file contains the style for the your-array.component.html file.
:host { | |
// Style here the elements of your-array.component.html as you wish | |
} |
The your-array.component.html file renders your array and invokes via received events from the list elements the validation of the array. Our client desired a new list entry creation with the possibility to add the entry to an existing list. Hence, the your-array.component.html looks as follows:
<div> | |
<app-your-array-entry [yourEntry]="" class="empty" (newEntry)="newEntry($event)"> | |
</app-your-array-entry> | |
<app-your-array-entry *ngFor="let yourEntry of value" | |
[yourEntry]="yourEntry" | |
(newEntry)="newEntry($event) | |
(deleteEntry)="deleteEntry($event)" | |
(changeEntry)="discloseValidatorChange()"> | |
</app-your-array-entry> | |
</div> |
The single app-your-array-entry.component.ts file renders the single list entries, which may emit the events to invoke methods in the your-array.component.ts file or YourArrayComponent class, respectively.
// The states are used to render the single entries as the single states say: | |
export enum YourState { | |
EMPTY = 0, // Typescript indexes automatically from the first one onward | |
DISPLAY, // All single states may be used to show or hide some icons, buttons... | |
EDIT | |
} | |
@Component({ | |
selector: 'app-your-array-entry', | |
templateUrl: './your-array-entry.component.html', | |
styleUrls: ['./your-array-entry.component.scss'] | |
}) | |
export class YourArrayEntryComponent implements OnInit { | |
@Input() | |
yourEntry: YourType; | |
@Output() | |
deleteEntry = new EventEmitter<YourType>(); | |
@Output() | |
newEntry = new EventEmitter<YourType>(); | |
@Output() | |
changeEntry = new EventEmitter<void>(); | |
state: YourState = YourState.EMPTY; | |
ngOnInit() { | |
// Perform rendering depending on the contents of the field "yourEntry" and set the correct state | |
} | |
onClick() { | |
switch (this.state) { | |
case YourState.EMPTY: // Create a new array entry (= new object) | |
// Perform any preprocessing here on a new entry created by the user | |
this.newEntry.emit(new YourType({ | |
...this.yourEntry | |
})); | |
this.yourEntry = undefined; // You may invoke a clear method here | |
break; | |
case YourState.DISPLAY: // Switch from DISPLAY to EDIT state | |
// Perform any preprocessing here before the user can edit the entry | |
this.state = YourState.EDIT; | |
break; | |
default: // YourState.EDIT: Switch from EDIT to DISPLAY state | |
// Perform any processing here on edit entry changes from by the user | |
this.state = YourState.DISPLAY; | |
break; | |
} | |
this.changeEntry.emit(); | |
} | |
onDeleteClick () { | |
this.state = YourState.DISPLAY; | |
this.delete.emit(this.yourEntry); | |
} | |
isStatusEdit(): boolean { // for a more readble your-array-entry.component.html | |
return this.state === YourState.EDIT; | |
} | |
isStatusEmpty(): boolean { // for a more readble your-array-entry.component.html | |
return this.state === YourState.EMPTY; | |
} | |
isStatusDisplay(): boolean { // for a more readble your-array-entry.component.html | |
return this.state === YourState.DISPLAY; | |
} | |
} |
The your-array-entry.component.scss file contains the style for the your-array-entry.component.html file.
:host { | |
// Style here the elements of your-array-entry.component.html as you wish | |
} |
The your-array-entry.component.html files renders one single array entry and adapts its looks to the state accordingly:
<div> | |
<p>{{yourEntry.yourProperty}} is in the state of {{state}}.</p> | |
<button (click)="onClick()"> | |
<ng-container *ngIf="isStatusEmpty()"> | |
Create new Array Entry | |
</ng-container> | |
<ng-container *ngIf="isStatusDisplay()"> | |
Edit Array Entry | |
</ng-container> | |
<ng-container *ngIf="isStatusEdit()"> | |
Submit edited Array Entry | |
</ng-container> | |
</button> | |
<button *ngIf="!isStatusEmpty()" type="button" (click)="onDeleteClick()"> | |
Delete Array Entry | |
</button> | |
</div> |
You now can validate single array entries on each of their state changes and customize all involved steps as you wish. You then can include the created YourArrayComponent in your form as follows:
<!-- In your main form, include the generated app-your-array component simply as --> | |
<form> | |
<!-- ... --> | |
<app-your-array [(ngModel)]="yourFormObject.yourArrayProperty" name="yourArray"> | |
</app-your-array> | |
<!-- ... --> | |
</form> |
Happy efficient and valid Angular coding!