From 89c6ebdf4f789b9f327cac53e09700f6b9966fa2 Mon Sep 17 00:00:00 2001
From: jojohoch <joachim.hoch@iqb.hu-berlin.de>
Date: Fri, 16 Jul 2021 16:41:42 +0200
Subject: [PATCH] [player] Prepare validation of form elements

* When creating components for each element a
`ValidationMessageComponent` is also generated dynamically
* Prepare usage of default values for form elements
* Add `form.ts` for form specific interfaces
* Rename some variables and properties
---
 .../element-components/checkbox.component.ts  |  6 ++-
 .../correction.component.ts                   | 32 +++++++--------
 .../element-components/dropdown.component.ts  |  4 +-
 .../radio-button-group.component.ts           |  2 +-
 .../text-field.component.ts                   | 30 +++++++-------
 .../form-element-component.directive.ts       | 28 ++++++++-----
 projects/common/form.service.ts               | 16 ++++----
 projects/common/form.ts                       | 11 +++++
 projects/common/unit.ts                       |  5 ---
 projects/player/src/app/app.component.ts      | 40 ++++++++++++-------
 projects/player/src/app/app.module.ts         |  4 +-
 .../components/element-overlay.component.ts   | 40 ++++++++++++++-----
 .../src/app/components/section.component.ts   |  2 +-
 .../validation-message.component.ts           | 28 +++++++++++++
 14 files changed, 162 insertions(+), 86 deletions(-)
 create mode 100644 projects/common/form.ts
 create mode 100644 projects/player/src/app/components/validation-message.component.ts

diff --git a/projects/common/element-components/checkbox.component.ts b/projects/common/element-components/checkbox.component.ts
index c68db4f50..38aee9c55 100644
--- a/projects/common/element-components/checkbox.component.ts
+++ b/projects/common/element-components/checkbox.component.ts
@@ -5,8 +5,8 @@ import { FormElementComponent } from '../form-element-component.directive';
 @Component({
   selector: 'app-checkbox',
   template: `
-      <mat-checkbox class="example-margin"
-                    [formControl]="formControl"
+      <mat-checkbox #checkbox class="example-margin"
+                    [formControl]="formElementControl"
                     [style.width.px]="elementModel.width"
                     [style.height.px]="elementModel.height"
                     [style.background-color]="elementModel.backgroundColor"
@@ -22,4 +22,6 @@ import { FormElementComponent } from '../form-element-component.directive';
 })
 export class CheckboxComponent extends FormElementComponent {
   elementModel!: CheckboxElement;
+  // TODO: get from elementModel
+  defaultValue: boolean = true;
 }
diff --git a/projects/common/element-components/compound-components/correction.component.ts b/projects/common/element-components/compound-components/correction.component.ts
index a8743f85d..390d7c5c6 100644
--- a/projects/common/element-components/compound-components/correction.component.ts
+++ b/projects/common/element-components/compound-components/correction.component.ts
@@ -5,26 +5,26 @@ import { FormElementComponent } from '../../form-element-component.directive';
 @Component({
   selector: 'app-correction',
   template: `
-    <div>
-      <p>
-        {{$any(elementModel).text}}
-      </p>
-      <div *ngFor="let sentence of elementModel.sentences"
-           fxLayout="column">
-        <div fxLayout="row">
-          <div *ngFor="let word of sentence.split(' ');"
+      <div>
+          <p>
+              {{$any(elementModel).text}}
+          </p>
+          <div *ngFor="let sentence of elementModel.sentences"
                fxLayout="column">
-              <mat-form-field>
-              <input matInput type="text"
-                     [formControl]="formControl">
-              </mat-form-field>
-              <div>
-                  {{word}}
+              <div fxLayout="row">
+                  <div *ngFor="let word of sentence.split(' ');"
+                       fxLayout="column">
+                      <mat-form-field>
+                          <input matInput type="text"
+                                 [formControl]="formElementControl">
+                      </mat-form-field>
+                      <div>
+                          {{word}}
+                      </div>
+                  </div>
               </div>
           </div>
-        </div>
       </div>
-    </div>
   `,
   styles: [
     'mat-form-field {margin: 5px}'
diff --git a/projects/common/element-components/dropdown.component.ts b/projects/common/element-components/dropdown.component.ts
index fa6a6544c..f6f19b4e5 100644
--- a/projects/common/element-components/dropdown.component.ts
+++ b/projects/common/element-components/dropdown.component.ts
@@ -15,9 +15,9 @@ import { FormElementComponent } from '../form-element-component.directive';
                      [style.font-weight]="elementModel.bold ? 'bold' : ''"
                      [style.font-style]="elementModel.italic ? 'italic' : ''"
                      [style.text-decoration]="elementModel.underline ? 'underline' : ''">
-            {{$any(elementModel).label}}
+              {{$any(elementModel).label}}
           </mat-label>
-          <mat-select [formControl]="formControl">
+          <mat-select [formControl]="formElementControl">
               <mat-option *ngFor="let option of elementModel.options" [value]="option">
                   {{option}}
               </mat-option>
diff --git a/projects/common/element-components/radio-button-group.component.ts b/projects/common/element-components/radio-button-group.component.ts
index ff3f35d56..ca9be5510 100644
--- a/projects/common/element-components/radio-button-group.component.ts
+++ b/projects/common/element-components/radio-button-group.component.ts
@@ -16,7 +16,7 @@ import { FormElementComponent } from '../form-element-component.directive';
            [style.text-decoration]="elementModel.underline ? 'underline' : ''">
           <label id="radio-group-label">{{elementModel.label}}</label>
           <mat-radio-group aria-labelledby="radio-group-label" fxLayout="{{elementModel.alignment}}"
-                           [formControl]="formControl">
+                           [formControl]="formElementControl">
               <mat-radio-button *ngFor="let option of elementModel.options" [value]="option">
                   {{option}}
               </mat-radio-button>
diff --git a/projects/common/element-components/text-field.component.ts b/projects/common/element-components/text-field.component.ts
index 3b4278920..d83f27c03 100644
--- a/projects/common/element-components/text-field.component.ts
+++ b/projects/common/element-components/text-field.component.ts
@@ -5,21 +5,21 @@ import { FormElementComponent } from '../form-element-component.directive';
 @Component({
   selector: 'app-text-field',
   template: `
-    <input *ngIf="elementModel.multiline === false" matInput
-           placeholder="{{elementModel.placeholder}}"
-           [formControl]="formControl">
-    <textarea *ngIf="elementModel.multiline === true" matInput
-              placeholder="{{elementModel.placeholder}}"
-              [formControl]="formControl"
-              [style.width.px]="elementModel.width"
-              [style.height.px]="elementModel.height"
-              [style.background-color]="elementModel.backgroundColor"
-              [style.color]="elementModel.fontColor"
-              [style.font-family]="elementModel.font"
-              [style.font-size.px]="elementModel.fontSize"
-              [style.font-weight]="elementModel.bold ? 'bold' : ''"
-              [style.font-style]="elementModel.italic ? 'italic' : ''"
-              [style.text-decoration]="elementModel.underline ? 'underline' : ''">
+      <input *ngIf="elementModel.multiline === false" matInput
+             placeholder="{{elementModel.placeholder}}"
+             [formControl]="formElementControl">
+      <textarea *ngIf="elementModel.multiline === true" matInput
+                placeholder="{{elementModel.placeholder}}"
+                [formControl]="formElementControl"
+                [style.width.px]="elementModel.width"
+                [style.height.px]="elementModel.height"
+                [style.background-color]="elementModel.backgroundColor"
+                [style.color]="elementModel.fontColor"
+                [style.font-family]="elementModel.font"
+                [style.font-size.px]="elementModel.fontSize"
+                [style.font-weight]="elementModel.bold ? 'bold' : ''"
+                [style.font-style]="elementModel.italic ? 'italic' : ''"
+                [style.text-decoration]="elementModel.underline ? 'underline' : ''">
     </textarea>
   `
 })
diff --git a/projects/common/form-element-component.directive.ts b/projects/common/form-element-component.directive.ts
index 3811b31dd..4b38ebc9d 100644
--- a/projects/common/form-element-component.directive.ts
+++ b/projects/common/form-element-component.directive.ts
@@ -1,27 +1,35 @@
 import { Directive, OnInit } from '@angular/core';
-import { FormControl, FormGroup } from '@angular/forms';
-import { pairwise, startWith } from 'rxjs/operators';
+import {
+  FormControl, FormGroup, ValidatorFn
+} from '@angular/forms';
+import { pairwise } from 'rxjs/operators';
 import { UnitUIElement } from './unit';
 import { FormService } from './form.service';
 
 @Directive()
 export abstract class FormElementComponent implements OnInit {
-  elementModel!: UnitUIElement;
+  abstract elementModel: UnitUIElement;
   parentForm!: FormGroup;
-  formControl: FormControl = new FormControl();
+  defaultValue!: unknown;
+  formElementControl!: FormControl;
 
-  constructor(private formService: FormService) { }
+  constructor(private formService: FormService) {}
 
   ngOnInit(): void {
-    this.formService.registerFormControl(this.elementModel.id);
-    this.formControl = this.getFormControl(this.elementModel.id);
-    this.formControl.valueChanges
-      .pipe(startWith(null), pairwise())
+    const formControl = new FormControl(this.defaultValue, this.getValidations());
+    const id = this.elementModel.id;
+    this.formService.registerFormControl({ id, formControl });
+    this.formElementControl = this.getFormControl(id);
+    this.formElementControl.valueChanges
+      .pipe(pairwise())
       .subscribe(
         ([prevValue, nextValue] : [unknown, unknown]) => this.onValueChange([prevValue, nextValue])
       );
   }
 
+  // TODO: get from elementModel examples, example: [Validators.requiredTrue, Validators.required]
+  private getValidations = (): ValidatorFn[] => [];
+
   private getFormControl(id: string): FormControl {
     // workaround for editor
     return (this.parentForm) ? this.parentForm.controls[id] as FormControl : new FormControl();
@@ -29,6 +37,6 @@ export abstract class FormElementComponent implements OnInit {
 
   private onValueChange(values: [unknown, unknown]): void {
     const element = this.elementModel.id;
-    this.formService.changeElementValue({ element, values });
+    this.formService.changeElementValue({ id: element, values });
   }
 }
diff --git a/projects/common/form.service.ts b/projects/common/form.service.ts
index d667760b0..61d1dd048 100644
--- a/projects/common/form.service.ts
+++ b/projects/common/form.service.ts
@@ -1,27 +1,27 @@
 import { Injectable } from '@angular/core';
 import { Observable, Subject } from 'rxjs';
-import { ChangeElement } from './unit';
+import { FormControlElement, ValueChangeElement } from './form';
 
 @Injectable({
   providedIn: 'root'
 })
 export class FormService {
-  private _elementValueChanged = new Subject<ChangeElement>();
-  private _controlAdded = new Subject<string>();
+  private _elementValueChanged = new Subject<ValueChangeElement>();
+  private _controlAdded = new Subject<FormControlElement>();
 
-  get elementValueChanged(): Observable<ChangeElement> {
+  get elementValueChanged(): Observable<ValueChangeElement> {
     return this._elementValueChanged.asObservable();
   }
 
-  get controlAdded(): Observable<string> {
+  get controlAdded(): Observable<FormControlElement> {
     return this._controlAdded.asObservable();
   }
 
-  changeElementValue(elementValues: ChangeElement): void {
+  changeElementValue(elementValues: ValueChangeElement): void {
     this._elementValueChanged.next(elementValues);
   }
 
-  registerFormControl(controlId: string): void {
-    this._controlAdded.next(controlId);
+  registerFormControl(control: FormControlElement): void {
+    this._controlAdded.next(control);
   }
 }
diff --git a/projects/common/form.ts b/projects/common/form.ts
new file mode 100644
index 000000000..0648b3bf2
--- /dev/null
+++ b/projects/common/form.ts
@@ -0,0 +1,11 @@
+import { FormControl } from '@angular/forms';
+
+export interface ValueChangeElement {
+  id: string;
+  values: [unknown, unknown];
+}
+
+export interface FormControlElement {
+  id: string;
+  formControl: FormControl;
+}
diff --git a/projects/common/unit.ts b/projects/common/unit.ts
index 4f6bc7886..09e532081 100644
--- a/projects/common/unit.ts
+++ b/projects/common/unit.ts
@@ -85,8 +85,3 @@ export interface AudioElement extends UnitUIElement {
 export interface VideoElement extends UnitUIElement {
   src: string;
 }
-
-export interface ChangeElement {
-  element: string;
-  values: [unknown, unknown];
-}
diff --git a/projects/player/src/app/app.component.ts b/projects/player/src/app/app.component.ts
index 388203bac..2e8d2e849 100644
--- a/projects/player/src/app/app.component.ts
+++ b/projects/player/src/app/app.component.ts
@@ -1,9 +1,10 @@
 import {
   Component, EventEmitter, Input, Output
 } from '@angular/core';
-import { FormControl, FormGroup } from '@angular/forms';
-import { ChangeElement, Unit } from '../../../common/unit';
+import { FormGroup } from '@angular/forms';
+import { Unit } from '../../../common/unit';
 import { FormService } from '../../../common/form.service';
+import { FormControlElement, ValueChangeElement } from '../../../common/form';
 
 interface StartData {
   unitDefinition: string;
@@ -13,21 +14,23 @@ interface StartData {
 @Component({
   selector: 'player-aspect',
   template: `
-      <form *ngIf="form" [formGroup]="form">
+      <form [formGroup]="form">
           <mat-tab-group mat-align-tabs="start">
               <mat-tab *ngFor="let page of unit.pages; let i = index" label="Seite {{i+1}}">
                   <app-page [parentForm]="form" [page]="page"></app-page>
               </mat-tab>
           </mat-tab-group>
-          <button class="form-item" mat-flat-button color="primary" (click)="submit()" [disabled]="!form.valid">Print
-              form.value
-          </button>
-          <pre>{{unit | json}}</pre>
       </form>
+      <button class="form-item" mat-flat-button color="primary" (click)="submit()">Print
+          form.value
+      </button>
+      <button class="form-item" mat-flat-button color="primary" (click)="markAsTouch()" >markAsTouch
+      </button>
+      <pre>{{unit | json}}</pre>
   `
 })
 export class AppComponent {
-  form!: FormGroup;
+  form: FormGroup = new FormGroup({});
   unit: Unit = {
     pages: []
   };
@@ -41,8 +44,10 @@ export class AppComponent {
   @Output() valueChanged = new EventEmitter<string>();
 
   constructor(private formService: FormService) {
-    formService.elementValueChanged.subscribe((value: ChangeElement): void => this.onElementValueChanges(value));
-    formService.controlAdded.subscribe((value: string): void => this.addControl(value));
+    formService.elementValueChanged
+      .subscribe((value: ValueChangeElement): void => this.onElementValueChanges(value));
+    formService.controlAdded
+      .subscribe((control: FormControlElement): void => this.addControl(control));
   }
 
   private initForm(): void {
@@ -50,24 +55,31 @@ export class AppComponent {
     this.form.valueChanges.subscribe(v => this.onFormChanges(v));
   }
 
-  private addControl(id: string): void {
-    this.form.addControl(id, new FormControl());
+  private addControl(control: FormControlElement): void {
+    this.form.addControl(control.id, control.formControl);
   }
 
-  private onElementValueChanges = (value: ChangeElement): void => {
-    console.log(`Player: onElementValueChanges - ${value.element}: ${value.values[0]} -> ${value.values[1]}`);
+  private onElementValueChanges = (value: ValueChangeElement): void => {
+    // eslint-disable-next-line no-console
+    console.log(`Player: onElementValueChanges - ${value.id}: ${value.values[0]} -> ${value.values[1]}`);
   };
 
   private onFormChanges(value: unknown): void {
     const allValues: string = JSON.stringify(value);
+    // eslint-disable-next-line no-console
     console.log('Player: emit valueChanged', allValues);
     this.valueChanged.emit(allValues);
   }
 
   submit(): void {
+    // eslint-disable-next-line no-console
     console.log('Player: form.value', this.form.value);
   }
 
+  markAsTouch(): void {
+    this.form.markAllAsTouched();
+  }
+
   // exampleUnit = {
   //   pages: [
   //     {
diff --git a/projects/player/src/app/app.module.ts b/projects/player/src/app/app.module.ts
index 4f3789549..6714b2e0e 100644
--- a/projects/player/src/app/app.module.ts
+++ b/projects/player/src/app/app.module.ts
@@ -9,13 +9,15 @@ import { PageComponent } from './components/page.component';
 import { SectionComponent } from './components/section.component';
 import { SharedModule } from '../../../common/app.module';
 import { ElementOverlayComponent } from './components/element-overlay.component';
+import { ValidationMessageComponent } from './components/validation-message.component';
 
 @NgModule({
   declarations: [
     AppComponent,
     PageComponent,
     SectionComponent,
-    ElementOverlayComponent
+    ElementOverlayComponent,
+    ValidationMessageComponent
   ],
   imports: [
     BrowserModule,
diff --git a/projects/player/src/app/components/element-overlay.component.ts b/projects/player/src/app/components/element-overlay.component.ts
index 44f4e33b9..7a7431d11 100644
--- a/projects/player/src/app/components/element-overlay.component.ts
+++ b/projects/player/src/app/components/element-overlay.component.ts
@@ -1,35 +1,53 @@
 import {
-  Component, ComponentFactoryResolver, Input, OnInit, ViewChild, ViewContainerRef
+  Component, ComponentFactory, ComponentFactoryResolver, ComponentRef, Input, OnInit, ViewChild, ViewContainerRef
 } from '@angular/core';
 import { FormGroup } from '@angular/forms';
 import { UnitUIElement } from '../../../../common/unit';
 import * as ComponentUtils from '../../../../common/component-utils';
 import { FormElementComponent } from '../../../../common/form-element-component.directive';
+import { ValidationMessageComponent } from './validation-message.component';
 
 @Component({
   selector: 'app-element-overlay',
   template: `
       <div [style.position]="'absolute'"
-           [style.left.px]="element.xPosition"
-           [style.top.px]="element.yPosition">
-          <ng-template #elementContainer></ng-template>
+           [style.left.px]="elementModel.xPosition"
+           [style.top.px]="elementModel.yPosition">
+          <ng-template #elementComponentContainer></ng-template>
+          <ng-template #errorMessageComponentContainer></ng-template>
       </div>
   `
 })
+
 export class ElementOverlayComponent implements OnInit {
-  @Input() element!: UnitUIElement;
-  @ViewChild('elementContainer', { read: ViewContainerRef, static: true }) private elementContainer!: ViewContainerRef;
+  @Input() elementModel!: UnitUIElement;
+  @ViewChild('elementComponentContainer',
+    { read: ViewContainerRef, static: true }) private elementComponentContainer!: ViewContainerRef;
+
+  @ViewChild('validationMessageComponentContainer',
+    { read: ViewContainerRef, static: true }) private validationMessageComponentContainer!: ViewContainerRef;
+
   parentForm!: FormGroup;
 
   constructor(private componentFactoryResolver: ComponentFactoryResolver) { }
 
   ngOnInit(): void {
-    const componentFactory = ComponentUtils.getComponentFactory(this.element.type, this.componentFactoryResolver);
-    const childComponent = this.elementContainer.createComponent(componentFactory).instance;
-    childComponent.elementModel = this.element;
+    // eslint-disable-next-line max-len
+    const elementComponentFactory =
+      ComponentUtils.getComponentFactory(this.elementModel.type, this.componentFactoryResolver);
+    const elementComponent = this.elementComponentContainer.createComponent(elementComponentFactory).instance;
+    elementComponent.elementModel = this.elementModel;
+
+    if (elementComponent instanceof FormElementComponent) {
+      elementComponent.parentForm = this.parentForm;
+
+      const validationMessageComponentFactory: ComponentFactory<ValidationMessageComponent> =
+        this.componentFactoryResolver.resolveComponentFactory(ValidationMessageComponent);
+      const validationMessageComponentRef: ComponentRef<ValidationMessageComponent> =
+        this.validationMessageComponentContainer.createComponent(validationMessageComponentFactory);
 
-    if (childComponent instanceof FormElementComponent) {
-      childComponent.parentForm = this.parentForm;
+      validationMessageComponentRef.instance.parentForm = this.parentForm;
+      validationMessageComponentRef.instance.elementModel = this.elementModel;
     }
   }
 }
diff --git a/projects/player/src/app/components/section.component.ts b/projects/player/src/app/components/section.component.ts
index c7400882d..5af3385b6 100644
--- a/projects/player/src/app/components/section.component.ts
+++ b/projects/player/src/app/components/section.component.ts
@@ -33,7 +33,7 @@ export class SectionComponent implements OnInit {
       this.componentFactoryResolver.resolveComponentFactory(ElementOverlayComponent);
     const overlayRef: ComponentRef<ElementOverlayComponent> =
       this.elementContainer.createComponent(overlayFactory);
-    overlayRef.instance.element = element;
+    overlayRef.instance.elementModel = element;
     overlayRef.instance.parentForm = this.parentForm;
   }
 }
diff --git a/projects/player/src/app/components/validation-message.component.ts b/projects/player/src/app/components/validation-message.component.ts
new file mode 100644
index 000000000..70d7de24d
--- /dev/null
+++ b/projects/player/src/app/components/validation-message.component.ts
@@ -0,0 +1,28 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl, FormGroup } from '@angular/forms';
+import { UnitUIElement } from '../../../../common/unit';
+
+@Component({
+  selector: 'app-error-message',
+  template: `
+      <mat-error *ngIf="formElementControl && formElementControl.touched && formElementControl.errors">
+          {{requiredMessage}}
+      </mat-error>
+  `
+})
+
+export class ValidationMessageComponent implements OnInit {
+  elementModel!: UnitUIElement;
+  parentForm!: FormGroup;
+  formElementControl!: FormControl;
+
+  ngOnInit(): void {
+    this.formElementControl = this.parentForm.controls[this.elementModel.id] as FormControl;
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  get requiredMessage(): string {
+    // TODO: get from elementModel
+    return 'Name is required';
+  }
+}
-- 
GitLab