Procházet zdrojové kódy

Latest changes imported from AMP - inc semi-realtime validation

Richard Knight před 5 roky
rodič
revize
ccd4445824

+ 93 - 4
src/app/dynaform/components/_abstract/native-input.component.ts

@@ -1,7 +1,10 @@
-import { Input, Output, EventEmitter } from '@angular/core';
+import { OnInit, OnDestroy, Input, Output, ChangeDetectorRef, EventEmitter } from '@angular/core';
 import { FormControl } from '@angular/forms';
+import { FriendlyValidationErrorsService } from './../../services/friendly-validation-errors.service';
+import { Subject, Subscription } from 'rxjs';
+import { debounceTime } from 'rxjs/operators';
 
-export abstract class NativeInputComponent {
+export abstract class NativeInputComponent implements OnInit, OnDestroy {
 
 	@Input()
 	control: FormControl;
@@ -10,7 +13,7 @@ export abstract class NativeInputComponent {
 	set meta(meta: StringMap<any>) {
 		this._meta = meta;
 		this.exposeForTemplate();
-	};
+	}
 
 	@Output()
 	call: EventEmitter<string> = new EventEmitter<string>();
@@ -19,13 +22,72 @@ export abstract class NativeInputComponent {
 	exposeMetaInTemplate: string[] = [];
 	_meta: StringMap<any>;
 
+	pendingValidation: boolean;
+	hasFocus: boolean = false;
+	waitForFirstChange: boolean = false;
+	keyUp$: Subject<string> = new Subject();
+
+	valueSBX: Subscription;
+	statusSBX: Subscription;
+	keyUpSBX: Subscription;
+
+	keyUpValidationDelay: number = 2000; // Validate after 2 seconds IF the control still has focus
+
+	constructor(
+		protected valErrsService: FriendlyValidationErrorsService,
+		protected _cdr: ChangeDetectorRef
+	) {}
+
+	ngOnInit() {
+		// this.control is not an instance of FormControl when it's a Checkbutton in a Checkbutton group (but is when it's a solo Checkbutton) - BUT WHY? WHY!!!
+		// Hence the 'if' statement
+		if (this.control instanceof FormControl) {
+			this.valueSBX = this.control.valueChanges.subscribe(this.markForCheck.bind(this));
+			this.statusSBX = this.control.statusChanges.subscribe(status => {
+				if (status === 'PENDING') {
+					this.pendingValidation = true;
+				} else if (this.pendingValidation) {
+					this.pendingValidation = false;
+					this.markForCheck();
+				}
+			});
+		}
+		this.keyUpSBX = this.keyUp$.pipe(debounceTime(this.keyUpValidationDelay)).subscribe(val => {
+			if (this.hasFocus) {
+				// THE ORDER OF THE NEXT THREE LINES IS IMPORTANT!
+				// We MUST make the control as dirty before setting the value to avoid corrupting the initialValue recorded in Dynaform's makeAsyncTest utility
+				this.control.markAsTouched();
+				this.control.markAsDirty();
+				this.control.setValue(val);
+			}
+		});
+	}
+
+	ngOnDestroy() {
+		if (this.valueSBX) {
+			this.valueSBX.unsubscribe();
+		}
+		if (this.statusSBX) {
+			this.statusSBX.unsubscribe();
+		}
+		if (this.keyUpSBX) {
+			this.keyUpSBX.unsubscribe();
+		}
+	}
+
 	exposeForTemplate() {
 		// Move meta variables up a level, for direct access in templates
 		this.exposeMetaInTemplate.map(p => this[p] = this._meta[p] !== undefined ? this._meta[p] : this[p]);
 	}
 
+	markForCheck() {
+		if (this._cdr) {
+			this._cdr.markForCheck();
+		}
+	}
+
 	handle(fnId: string, val: any): void {
-		this.call.emit(fnId);
+		this.call.emit(fnId); // Add control's current value
 	}
 
 	handleChange(): void {
@@ -38,4 +100,31 @@ export abstract class NativeInputComponent {
 		return this.componentName;
 	}
 
+	getFirstFailureMsg(): string {
+		if (!this.control.errors || this.control.errors === {}) {
+			return '';
+		}
+		const key = Object.keys(this.control.errors)[0];
+		return this._meta.valFailureMsgs[key] || this.valErrsService.getFriendly(key, this.control.errors[key]);
+	}
+
+	gainFocus(): void {
+		this.hasFocus = true;
+		this.waitForFirstChange = true;
+	}
+
+	loseFocus(): void {
+		this.hasFocus = false;
+	}
+
+	handleKeyup(currentFieldValue: string): void {
+		this.keyUp$.next(currentFieldValue);
+		if (this.control.value && this.waitForFirstChange) {
+			// Hide any validation errors if the control has a previous value (set after Angular's first updateOn event) and its value has changed
+			// NOTE that this.control.value may lag behind currentFieldValue depending on the updateOn startegy chosen
+			this.control.markAsPending();
+			this.waitForFirstChange = false;
+		}
+	}
+
 }

+ 6 - 2
src/app/dynaform/components/custom/checkbutton/checkbutton.component.ts

@@ -1,6 +1,7 @@
 import { Component, OnChanges, forwardRef, ChangeDetectorRef } from '@angular/core';
 import { NG_VALUE_ACCESSOR } from '@angular/forms';
 import { CustomInputComponent } from './../../_abstract/custom-input.component';
+import { FriendlyValidationErrorsService } from './../../../services/friendly-validation-errors.service';
 
 @Component({
 	selector: 'app-checkbutton',
@@ -28,8 +29,11 @@ export class CheckbuttonComponent extends CustomInputComponent implements OnChan
 
 	readonly componentName = 'CheckbuttonComponent'; // For AOT compatibility, as class names don't survive minification
 
-	constructor(private _cdr: ChangeDetectorRef) {
-		super();
+	constructor(
+		protected valErrsService: FriendlyValidationErrorsService,
+		protected _cdr: ChangeDetectorRef
+	) {
+		super(valErrsService, _cdr);
 	}
 
 	ngOnChanges() {

+ 10 - 1
src/app/dynaform/components/custom/dropdown-modified-input/dropdown-modified-input.component.ts

@@ -1,7 +1,9 @@
-import { Component, OnInit, forwardRef } from '@angular/core';
+import { Component, OnInit, forwardRef, ChangeDetectorRef } from '@angular/core';
 import { NG_VALUE_ACCESSOR } from '@angular/forms';
 import { CustomInputComponent } from './../../_abstract/custom-input.component';
 import { ValueTransformer }	from './../../../interfaces';
+import { FriendlyValidationErrorsService } from './../../../services/friendly-validation-errors.service';
+
 
 @Component({
 	selector: 'app-dropdown-modified-input',
@@ -32,6 +34,13 @@ export class DropdownModifiedInputComponent extends CustomInputComponent impleme
 
 	readonly componentName = 'DropdownModifiedInputComponent'; // For AOT compatibility, as class names don't survive minification
 
+	constructor(
+		protected valErrsService: FriendlyValidationErrorsService,
+		protected _cdr: ChangeDetectorRef
+	) {
+		super(valErrsService, _cdr);
+	}
+
 	ngOnInit() {
 		this.controlValue = this.control.value;
 	}

+ 9 - 1
src/app/dynaform/components/custom/multiline/multiline.component.ts

@@ -1,6 +1,7 @@
-import { Component, forwardRef } from '@angular/core';
+import { Component, forwardRef, ChangeDetectorRef } from '@angular/core';
 import { NG_VALUE_ACCESSOR } from '@angular/forms';
 import { CustomInputComponent } from './../../_abstract/custom-input.component';
+import { FriendlyValidationErrorsService } from './../../../services/friendly-validation-errors.service';
 
 @Component({
 	selector: 'app-multiline',
@@ -24,6 +25,13 @@ export class MultilineComponent extends CustomInputComponent {
 
 	readonly componentName = 'MultilineComponent'; // For AOT compatibility, as class names don't survive minification
 
+	constructor(
+		protected valErrsService: FriendlyValidationErrorsService,
+		protected _cdr: ChangeDetectorRef
+	) {
+		super(valErrsService, _cdr);
+	}
+
 	writeValue(value: any): void {
 		this.value = value;
 		this.splitIntoLines(value, this._meta.lines || 5);

+ 6 - 2
src/app/dynaform/components/nocontrol/button-group/button-group.component.ts

@@ -1,11 +1,11 @@
-import { Component, Input, Output, EventEmitter, OnChanges } from '@angular/core';
+import { Component, Input, Output, EventEmitter, OnInit, OnChanges } from '@angular/core';
 
 @Component({
 	selector: 'app-button-group',
 	templateUrl: './button-group.component.html',
 	styleUrls: ['./button-group.component.scss']
 })
-export class ButtonGroupComponent implements OnChanges {
+export class ButtonGroupComponent implements OnInit, OnChanges {
 
 	@Input()
 	meta: StringMap<any>;
@@ -17,6 +17,10 @@ export class ButtonGroupComponent implements OnChanges {
 
 	readonly componentName = 'ButtonGroupComponent'; // For AOT compatibility, as class names don't survive minification
 
+	ngOnInit() {
+		this.buttons = this.meta.meta;
+	}
+
 	ngOnChanges() {
 		this.buttons = this.meta.meta;
 	}

+ 7 - 2
src/app/dynaform/components/nocontrol/heading/heading.component.ts

@@ -1,11 +1,11 @@
-import { Component, Input, OnChanges } from '@angular/core';
+import { Component, Input, OnInit, OnChanges } from '@angular/core';
 
 @Component({
 	selector: 'app-heading',
 	templateUrl: './heading.component.html',
 	styleUrls: ['./heading.component.scss']
 })
-export class HeadingComponent implements OnChanges {
+export class HeadingComponent implements OnInit, OnChanges {
 
 	@Input()
 	meta: StringMap<any>;
@@ -15,6 +15,11 @@ export class HeadingComponent implements OnChanges {
 
 	readonly componentName = 'HeadingComponent'; // For AOT compatibility, as class names don't survive minification
 
+	ngOnInit() {
+		this.text = this.meta.text;
+		this.level = this.meta.level || 2;
+	}
+	
 	ngOnChanges() {
 		this.text = this.meta.text;
 		this.level = this.meta.level || 2;

+ 21 - 0
src/app/dynaform/config/validation-messages.config.ts

@@ -0,0 +1,21 @@
+import { InjectionToken } from '@angular/core';
+import { FriendlyValErrors } from './../interfaces';
+
+// Configure dynaform-wide friendly validation error messages
+// These can be overriden in the metadata for individual controls
+
+export const FRIENDLY_VALIDATION_ERRORS = new InjectionToken<FriendlyValErrors>('validation-messages.config');
+
+export const friendlyValidationErrors: FriendlyValErrors = {
+	'required': 'Required',
+	'requiredtrue': 'Please tick to confirm',
+	'min': 'Pick a number above {{ min }}',
+	'max': 'Pick a number below {{ max }}',
+	'minlength': 'Enter a longer value, at least {{ requiredLength }} characters long',
+	'maxlength': 'Enter a shorter value, no more than {{ requiredLength }} characters long',
+	'email': 'Enter a valid email address',
+	'pattern': 'Invalid',
+	'alreadyTaken': 'This Id is already taken'
+};
+
+

+ 16 - 14
src/app/dynaform/directives/dynafield.directive.ts

@@ -10,6 +10,7 @@ import {
 	NG_ASYNC_VALIDATORS, AsyncValidatorFn
 } from '@angular/forms';
 
+
 import { ffcArr } from './../components';
 // const formFieldComponents = ffcArr.reduce((acc, componentClass) => ({ ...acc, [componentClass.name]: componentClass }), {}); // Works with JIT, but not AOT
 const getFormFieldComponents = () => {
@@ -85,7 +86,7 @@ export class DynafieldDirective extends NgControl implements OnInit, OnChanges,
 			const validComponentTypes = Object.keys(formFieldComponents).join(', ');
 			throw new Error(
 				`Dynaform Dynafield Error: Invalid field type: ${type}.
-			 	 Supported types: ${validComponentTypes}`
+				 Supported types: ${validComponentTypes}`
 			);
 		}
 		try {
@@ -93,8 +94,8 @@ export class DynafieldDirective extends NgControl implements OnInit, OnChanges,
 			const { name, class: cssClass, id: cssId, disabled } = meta;
 
 			// Create the component
-			const component = this.resolver.resolveComponentFactory<IFFC>(formFieldComponents[type]);
-			this.component = this.container.createComponent(component);
+			const componentFactory = this.resolver.resolveComponentFactory<IFFC>(formFieldComponents[type]);
+			this.component = this.container.createComponent(componentFactory);
 			const instance = this.component.instance;
 
 			// Support the recursive insertion of Dynaform components
@@ -145,9 +146,8 @@ export class DynafieldDirective extends NgControl implements OnInit, OnChanges,
 
 	ngOnChanges() {
 		// We won't support mutating components (e.g. Text --> Select) at this stage,
-		// but will support mutating an instantiated components metadata (e.g. changing a select's options, or css class).
-		// As the component is created in ngOnInt this does nothing in the onChanges run before ngOnInit,
-		// but responds to later input changes
+		// but will support mutating an instantiated components metadata
+		// As the component is created in ngOnInt this does nothing in the run before ngOnInit, but responds to later input changes
 		if (this.component) {
 			const { type, class: cssClass, id: cssId } = this.meta;
 			this.setCssId(cssId);
@@ -197,20 +197,22 @@ export class DynafieldDirective extends NgControl implements OnInit, OnChanges,
 		}
 	}
 
-	// ---------------------------------------
-	// Set id and classes on inserted component
-
 	setCssId(cssId): void {
 		const el = this.component.location.nativeElement;
 		cssId ? el.setAttribute('id', cssId) : el.removeAttribute('id');
 	}
 
 	setCssClasses(type, cssClass): void {
-		const el = this.component.location.nativeElement;
-		el.classList.add(type.toLowerCase().replace('component', ''));
-		if (cssClass) {
-			const classesToAdd = Array.isArray(cssClass) ? cssClass : [...cssClass.split(/\s+/)];
-			el.classList.add(...classesToAdd);
+		const classList = this.component.location.nativeElement.classList as DOMTokenList;
+		const componentTypeClass = type.toLowerCase().replace('component', '');
+		classList.add(componentTypeClass);
+		if (typeof cssClass === 'string' || Array.isArray(cssClass)) {
+			const classesToAdd = (Array.isArray(cssClass) ? cssClass : [...cssClass.split(/\s+/)]).filter(Boolean);
+			const classesToRemove = [];
+			const dontRemove = [ ...classesToAdd, componentTypeClass, 'ng-star-inserted' ];
+			classList.forEach(c => classesToRemove.push(dontRemove.indexOf(c) < 0 ? c : null));
+			classList.add(...classesToAdd);
+			classList.remove(...classesToRemove);
 		}
 	}
 

+ 2 - 2
src/app/dynaform/dynaform.component.ts

@@ -13,7 +13,7 @@ export interface DynarowContext {
 	selector: 'app-dynaform',
 	templateUrl: './dynaform.component.html',
 	styleUrls: ['./dynaform.component.scss'],
-	changeDetection: ChangeDetectionStrategy.Default // or ChangeDetectionStrategy.OnPush - might be more efficient. Experiment later.
+	changeDetection: ChangeDetectionStrategy.OnPush // or ChangeDetectionStrategy.OnPush - should be more efficient. Experiment later.
 })
 export class DynaformComponent implements OnInit, OnChanges {
 
@@ -245,7 +245,7 @@ export class DynaformComponent implements OnInit, OnChanges {
 			const errorsFlat = SuperForm.getAllErrorsFlat(this.formGroup);
 			return errorsFlat;
 		}
-		return 'No Errors';
+		return false;
 	}
 
 	handleCallback(fnId: string) {

+ 4 - 4
src/app/dynaform/dynaform.module.ts

@@ -9,8 +9,8 @@ import { DynaformComponent } from './dynaform.component';
 import { DynafieldDirective } from './directives/dynafield.directive';
 import { DynaformService } from './services/dynaform.service';
 import { ModelMapperService } from './services/model-mapper.service';
-
-import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
+import { FriendlyValidationErrorsService } from './services/friendly-validation-errors.service';
+// import { FRIENDLY_VALIDATION_ERRORS, friendlyValidationErrors } from './config/validation-messages.config'; // You may want to provide in a highher level module to override
 
 import { ffcArr } from './components'; // ffcArr = Form Field Components Array, exported from components/index.ts
 
@@ -31,7 +31,8 @@ import { ffcArr } from './components'; // ffcArr = Form Field Components Array,
 	entryComponents: ffcArr,
 	providers: [
 		DynaformService,
-		ModelMapperService
+		ModelMapperService,
+		FriendlyValidationErrorsService
 	],
 	exports: [
 		FormsModule,
@@ -41,4 +42,3 @@ import { ffcArr } from './components'; // ffcArr = Form Field Components Array,
 	]
 })
 export class DynaformModule { }
-

+ 12 - 0
src/app/dynaform/interfaces/index.ts

@@ -2,5 +2,17 @@ export interface ValueTransformer {
 	inputFn: (value: string) => { modifier: string, value: string };
 	outputFn: (modifier: string, value: string) => string;
 }
+export interface FriendlyValErrors {
+	'required': string;
+	'requiredtrue': string;
+	'min': string;
+	'max': string;
+	'minlength': string;
+	'maxlength': string;
+	'email': string;
+	'pattern': string;
+	'alreadyTaken': string;
+}
 
 export const a = 1;
+

+ 37 - 15
src/app/dynaform/models/field.model.ts

@@ -27,7 +27,6 @@ interface ISimpleFieldMetaData {
 	validators?: ValidatorFn[];				// Array of validator functions - following Angular FormControl API
 	asyncValidators?: AsyncValidatorFn[];	// Array of async validator functions - following Angular FormControl API
 	valFailureMsgs?: StringMap<any>;		// Validation failure messages - display appropriate message if validation fails
-	// onChange?: (val) => {};				// Function to call when field's value changes
 }
 
 interface IOption {
@@ -36,8 +35,8 @@ interface IOption {
 }
 
 interface IOptionsFieldMetaData extends ISimpleFieldMetaData {
-	options;									// Array of Options - for select, radio-button-group and other 'multiple-choice' types
-	horizontal?: boolean;						// Whether to arrang radio buttons or checkboxes horizontally (default false)
+	options: string[] | IOption[] | (() => IOption[]);	// Array of Options - for select, radio-button-group and other 'multiple-choice' types
+	horizontal?: boolean;								// Whether to arrange radio buttons or checkboxes horizontally (default false)
 }
 
 // For components that include links to other pages
@@ -83,11 +82,11 @@ abstract class SimpleField {
 	id?: string;
 	disabled = false;
 	change?: string;
-	validators: ValidatorFn[] = [];
-	asyncValidators: AsyncValidatorFn[] = [];
+	validators: ValidatorFn|ValidatorFn[] = [];
+	asyncValidators: AsyncValidatorFn|AsyncValidatorFn[] = [];
 	valFailureMsgs: StringMap<any> = {};
 
-	constructor(meta: ISimpleFieldMetaData) {
+	constructor(meta: ISimpleFieldMetaData, context?: any) {
 		Object.assign(this, meta);
 		if (!this.source) {
 			// If source is not supplied it's the same as the name
@@ -98,6 +97,19 @@ abstract class SimpleField {
 			// e.g. supervisorCardNumber --> Supervisor Card Number
 			this.label = unCamelCase(this.name);
 		}
+		// TODO: Consider binding context to standard validators as well as async validators
+		// BUT check this doesn't stop Angular's built-in validators from working (do they use 'this'?)
+		if (typeof this.asyncValidators === 'function' || Array.isArray(this.asyncValidators)) {
+			if (typeof this.asyncValidators === 'function') {
+				const boundFn = this.asyncValidators.bind(context);
+				this.asyncValidators = boundFn;
+			} else {
+				this.asyncValidators.forEach((valFn, i) => {
+					const boundFn = valFn.bind(context);
+					this.asyncValidators[i] = boundFn;
+				});
+			}
+		}
 	}
 }
 
@@ -116,6 +128,7 @@ class Option implements IOption {
 				this.value = opt.value;
 			}
 		} else {
+			// Simple string
 			this.label = opt;
 			this.value = opt;
 		}
@@ -123,7 +136,7 @@ class Option implements IOption {
 }
 
 abstract class OptionsField extends SimpleField {
-	options: Option[] = [];
+	options: Option[] | (() => Option[]) = [];
 	constructor(meta: IOptionsFieldMetaData, context?: any) {
 		super(meta);
 		let options;
@@ -163,6 +176,13 @@ class PasswordField extends SimpleField {
 class SelectField extends OptionsField {
 	type = 'Select';
 	link?: ILink;
+	constructor(meta: IOptionsFieldMetaData, context?: any) {
+		super(meta, context);
+		// If there's only one possible choice set the value
+		if (this.options.length === 1) {
+			this.default = this.options[0].value as string;
+		}
+	}
 }
 
 class RadioField extends OptionsField {
@@ -251,7 +271,7 @@ class CheckbuttonGroup extends CheckboxGroup {
 }
 
 // ---------------------------------------------------------------------------------------------------------------------
-// Concrete Implementations - Kendo Form Components
+// Concrete Implementations
 
 class TimepickerField extends SimpleField {
 	type = 'Timepicker';
@@ -290,6 +310,8 @@ class Container {
 	class?: string | string[];
 	id?: string;
 	meta: StringMap<any>; // TODO: Tighten up on type with union type
+	validators: ValidatorFn[] = [];
+	asyncValidators: AsyncValidatorFn[] = [];
 	constructor(containerMeta: StringMap<any>) {
 		Object.assign(this, containerMeta);
 		if (typeof this.label === 'undefined') {
@@ -322,9 +344,9 @@ class RepeatingContainer {
 			this.initialRepeat = containerMeta.meta.length;
 		}
 		this.meta = containerMeta.meta.map((m, i) => new Container({
-			name: `${this.prefix}${i+1}`,
+			name: `${this.prefix}${i + 1}`,
 			meta: m,
-			button: unCamelCase(`${this.prefix}${i+1}`),
+			button: unCamelCase(`${this.prefix}${i + 1}`),
 			focussed: this.display === 'SINGLE' ? i === 0 : true
 		}));
 	}
@@ -376,14 +398,14 @@ class Heading {
 }
 
 // ---------------------------------------------------------------------------------------------------------------------
-// Display
+// Display - displays non-editable vaklues lie a text field (but no input)
 
-class DisplayField {
+class DisplayField extends SimpleField {
 	value: string;
 	link?: ILink;
-	readonly noFormControls = true; // Indicates this has no FormControls associated with it
+	readonly disabled = true;
 	constructor(meta) {
-		Object.assign(this, meta);
+		super(meta);
 	}
 }
 
@@ -395,7 +417,7 @@ class DisplayField {
 // Interfaces
 export {
 	IOption, ILink
-}
+};
 
 // Classes
 export {

+ 166 - 24
src/app/dynaform/services/_formdata-utils.ts

@@ -22,7 +22,7 @@
  *
  */
 
-import { FormBuilder, FormGroup, FormArray, FormControl, AbstractControlOptions } from '@angular/forms';
+import { FormBuilder, FormGroup, FormArray, FormControl, AbstractControl, AbstractControlOptions } from '@angular/forms';
 import { cloneDeep, omit, reduce } from 'lodash/fp';
 import * as fmdModels from '../models/field.model';
 
@@ -82,7 +82,7 @@ const combineExtraMeta = (metaG, extraMeta, createFromExtra = false, containerSe
 				const baseObjWithAllKeys = getRCBaseObjectWithAllKeys(metaFoG, val, createFromExtra);
 				metaFoG.meta = generateRepeatedGroup(metaFoG, val, baseObjWithAllKeys);
 				const extra = {
-					...omit(['meta', 'seed'], val),
+					...omit(['meta', 'seed'], val as StringMap<any>),
 					meta: metaFoG.meta.map(
 						rgMem => combineExtraMeta(
 							rgMem.meta,
@@ -93,7 +93,7 @@ const combineExtraMeta = (metaG, extraMeta, createFromExtra = false, containerSe
 					)
 				};
 				combinedMeta[key] = combineMetaForField(metaFoG, {}, extra);
-				
+
 				// Stash a 'conbtainer template' for adding extra containers to the repeating container
 				combinedMeta[key].__containerTemplate = combineExtraMeta(
 					cloneDeep(baseObjWithAllKeys),
@@ -138,7 +138,7 @@ const generateRepeatedGroup = (metaFoG, extraMeta, baseObjWithAllKeys): StringMa
 	metaFoG.meta = metaFoG.meta.map( rcMem => ({ ...rcMem, meta: { ...baseObjWithAllKeys, ...rcMem.meta } }) ); // Add extra keys to model meta
 
 	// Extend repeated group from model (if any) to correct length, and add any missing names
-	const repeatedGroup = repeatInAutoMeta ? 
+	const repeatedGroup = repeatInAutoMeta ?
 		[ ...metaFoG.meta, ...Array(repeat - repeatInAutoMeta).fill({ meta: baseObjWithAllKeys }) ] :
 		Array(repeat).fill({ meta: baseObjWithAllKeys });
 	const fullyNamedRepeatedGroup = repeatedGroup.map((rgMem, i) => rgMem.name ? rgMem : { name: `group${i + 1}`, ...rgMem });
@@ -179,7 +179,7 @@ const buildFieldSpecificMetaInClosure = (metaG, context) => {
 		}
 		return 'text';
 	}
-	
+
 	const buildFieldClassName = (t: string): string => {
 		const start = t[0].toUpperCase() + t.slice(1);
 		if (start === 'Container' || start === 'RepeatingContainer' || start === 'Heading' || t.slice(-5) === 'Group') {
@@ -187,7 +187,7 @@ const buildFieldSpecificMetaInClosure = (metaG, context) => {
 		}
 		return start + 'Field';
 	};
-	
+
 	const buildModeledField = (metaFoG) => {
 		const type = resolveType(metaFoG);
 		const className = buildFieldClassName(type);
@@ -196,7 +196,7 @@ const buildFieldSpecificMetaInClosure = (metaG, context) => {
 		}
 		return new fmdModels[className](metaFoG, context);
 	};
-	
+
 	// Build Form Group Member
 	const buildModeledFieldGroupMember = (metaFoG) => {
 		const modeledGroupMember = buildModeledField(metaFoG);
@@ -213,11 +213,11 @@ const buildFieldSpecificMetaInClosure = (metaG, context) => {
 		}
 		return modeledGroupMember;
 	};
-	
+
 	// Build Form Group
 	const buildModeledFieldGroupReducerIteree = (res, metaFoG) => ({ ...res, [metaFoG.name]: buildModeledFieldGroupMember(metaFoG) });
 	const _buildFieldSpecificMeta = metaG => isRepeatingContainer(metaG) ?
-		metaG.map(rcMem => _buildFieldSpecificMeta(rcMem)) : 
+		metaG.map(rcMem => _buildFieldSpecificMeta(rcMem)) :
 		reduce(buildModeledFieldGroupReducerIteree, {}, metaG);
 	const buildFieldSpecificMeta = metaG => _buildFieldSpecificMeta(addMissingNames(metaG));
 
@@ -355,41 +355,56 @@ const extractFieldMappings = (metaG, parentPath = '') => Object.entries(metaG)
 // returns a function to build FormGroups containing FormControls, FormArrays and other FormGroups
 // ---------------------------------------------------------------------------------------------------------------------
 
+
+// TODO: In progress: elegantly adding validators at FormGroup and FormArray levels
+// Working, but code needs a rework
+
 const buildFormGroupFunctionFactory = (fb: FormBuilder): (meta) => FormGroup => {
 	// Establishes a closure over the supplied FormBuilder and returns a function that builds FormGroups from metadata
 	// ( it's done this way so we can use the FormBuilder singleton without reinitialising )
 
 	// Build Form Control
 	const buildControlState = metaF => ({ value: metaF.value || metaF.default, disabled: metaF.disabled });
-	const buildValidators = (metaF): AbstractControlOptions => ({
-		validators: metaF.validators,
-		asyncValidators: metaF.asyncValidators,
+	const buildValidators = (metaFoG): AbstractControlOptions => ({
+		validators: metaFoG.validators,
+		asyncValidators: metaFoG.asyncValidators,
 		// blur not working for custom components, so use change for custom and blur for text
-		updateOn: metaF.type === 'text' || metaF.type === 'textarea' ? 'blur' : 'change'
+		updateOn: getUpdateOn(metaFoG.type)
 	});
+	const BVAL = metaFoG => {
+		if (!metaFoG || !(metaFoG.validators || metaFoG.asyncValidators)) {
+			return undefined;
+		}
+		// console.log(metaFoG);
+		const res = buildValidators(metaFoG);
+		// console.log(res);
+		return res;
+	}
 	const buildFormControl = metaF => new FormControl(buildControlState(metaF), buildValidators(metaF));
 
 	// Build Form Array containing either Form Controls or Form Groups
-	const buildFormArray = (metaG): FormArray => {
+	const buildFormArray = (metaG, grMeta?): FormArray => {
 		return fb.array(
-			metaG.map(m => isContainer(m) ? _buildFormGroup(m.meta) : buildFormControl(m))
+			metaG.map(m => isContainer(m) ? _buildFormGroup(m.meta) : buildFormControl(m)),
+			buildValidators(grMeta)
 		);
 	};
 
-	// Build Form Group Member - builds a FormControl, FormArray or another FormGroup - which in turn can contain any of these
+	// Build Form Group Member
+	// Builds a FormControl, FormArray or another FormGroup - which in turn can contain any of these
 	const buildFormGroupMember = metaFoG => isGroup(metaFoG) ?
-		(isArray(metaFoG.meta) ? buildFormArray(metaFoG.meta) : _buildFormGroup(metaFoG.meta)) :
+		(isArray(metaFoG.meta) ? buildFormArray(metaFoG.meta, metaFoG) : _buildFormGroup(metaFoG.meta, metaFoG)) : // TODO: STINKY! REWORK with 1 param
 		buildFormControl(metaFoG);
 
 	const buildFormGroupReducerIteree = (res, metaFoG) => {
 		return metaFoG.noFormControls ? res : { ...res, [metaFoG.name]: buildFormGroupMember(metaFoG) };
 	};
-	const _buildFormGroup = _metaG => fb.group(reduce(buildFormGroupReducerIteree, {}, _metaG));
+	const _buildFormGroup = (_metaG, grMeta?) => fb.group(reduce(buildFormGroupReducerIteree, {}, _metaG), BVAL(grMeta));
 
 	// The main function - builds FormGroups containing other FormGroups, FormArrays and FormControls
 	const buildFormGroup = metaG => {
 		// Ensure that we have Field-Specific Metadata, not raw Objects
-		const metaWithNameKeys = addMissingNames(metaG); // <!--- DO WE REALLY HAVE TO CALL addMissingManes again here - it should have been done already?
+		const metaWithNameKeys = addMissingNames(metaG); // <!--- DO WE REALLY HAVE TO CALL addMissingNames again here - it should have been done already?
 		// MAYBE only run this if first entry isn't right, for reasons of efficiency
 		// const fieldModeledMeta = addMissingFieldSpecificMeta(metaWithNameKeys);
 		const fieldModeledMeta = metaWithNameKeys;
@@ -398,6 +413,94 @@ const buildFormGroupFunctionFactory = (fb: FormBuilder): (meta) => FormGroup =>
 	return buildFormGroup;
 };
 
+// Get the 'update on' strategy for validators
+const getUpdateOn = (type: string): 'blur'|'change'|'submit' => {
+	const t = type.toLowerCase();
+	let res;
+	if (t === 'text' || t === 'textarea' || t === 'password') {
+		res = 'blur';
+	} else {
+		res = 'change';
+	}
+	return res;
+};
+
+
+// ---------------------------------------------------------------------------------------------------------------------
+// Send RESET message to any attached AsyncValidators
+// This resets the stored initail value on controls where the field is required
+// (otherwise it isn't reset when the form is reset, and retails its previous value)
+// ---------------------------------------------------------------------------------------------------------------------
+
+const resetAsyncValidatorsRecursive = (group: FormGroup | FormArray): void => {
+	for (const key in group.controls) {
+		if (group.controls[key] instanceof FormControl) {
+			const asv = group.controls[key].asyncValidator;
+			if (asv) {
+				Array.isArray(asv) ? asv.forEach(f => f('RESET')) : asv('RESET');
+			}
+		} else {
+			resetAsyncValidatorsRecursive(group.controls[key]);
+		}
+	}
+};
+
+
+// ---------------------------------------------------------------------------------------------------------------------
+// Touch and update the validity of all controls in a FormGroup or FormArray / Reset validity of all controls
+// useful for displaying validation failures on submit
+// ---------------------------------------------------------------------------------------------------------------------
+
+const touchAndUpdateValidityRecursive = (group: FormGroup | FormArray): void => {
+	group.markAsTouched();
+	group.updateValueAndValidity();
+	for (const key in group.controls) {
+		if (group.controls[key] instanceof FormControl) {
+			group.controls[key].markAsTouched();
+			group.controls[key].updateValueAndValidity();
+		} else {
+			touchAndUpdateValidityRecursive(group.controls[key]);
+		}
+	}
+};
+
+const resetValidityRecursive = (group: FormGroup | FormArray): void => {
+	group.markAsUntouched();
+	group.updateValueAndValidity();
+	for (const key in group.controls) {
+		if (group.controls[key] instanceof FormControl) {
+			group.controls[key].markAsUntouched();
+			group.controls[key].updateValueAndValidity();
+		} else {
+			resetValidityRecursive(group.controls[key]);
+		}
+	}
+};
+
+/*
+const advanceUpdateStategyOfInvalidControlsRecursive = (group: FormGroup | FormArray): void => {
+	// For all invalid text / select / password control, change the updateOn startegy from 'blur' to 'change'
+	// For as-you-type feedback if validation has failed once
+	// NB: Only for the syncronous validators
+	for (const key in group.controls) {
+		if (group.controls[key] instanceof FormControl) {
+			console.log(key, group.controls[key].touched, group.controls[key].invalid);
+			if (group.controls[key].touched && group.controls[key].invalid) {
+				console.log('Replacing control', key);
+				const newControl = new FormControl(
+					group.controls[key].value,
+					{ updateOn: 'change', validators: group.controls[key].validator },
+					group.controls[key].asyncValidator
+				);
+				(group as any).setControl(key as any, newControl);
+			}
+		} else {
+			advanceUpdateStategyOfInvalidControlsRecursive(group.controls[key]);
+		}
+	}
+};
+*/
+
 
 // ---------------------------------------------------------------------------------------------------------------------
 // Reordering ( support for before / after instructions in metadata )
@@ -463,16 +566,52 @@ const generateNewModel = (originalModel, updates) => {
 	return updateObject(originalModel, updates);
 };
 
+// NOT FINISHED!!!
+const updateMeta = (newMeta: StringMap<any>, path: string, meta: StringMap<any>): StringMap<any> => {
+	// TODO: Finish this later
+	if (path === '/') {
+		const updatedMeta = updateObject(meta || this.meta, newMeta, true);
+		return updatedMeta;
+	}
+	// Drill down and update the branch specified by 'path' - all but final segment indicates a container
+	// What about array types? Think about this later!
+	// What about group components. Think about this later.
+	// What about bad paths. Think about this later.
+	console.log(path);
+	const segments = path.split('.');
+	console.log(segments);
+	let branchMeta = meta;
+	while (isContainer(branchMeta)) {
+		const s = segments.shift();
+		console.log(s, branchMeta[s].meta);
+		// TODO: add array check
+		branchMeta = branchMeta[s].meta;
+	}
+	while (segments.length > 1) {
+		const s = segments.shift();
+		console.log(s, branchMeta[s]);
+		// TODO: add array check
+		branchMeta = branchMeta[s];
+	}
+	branchMeta = branchMeta[segments[0]];
+	console.log(segments[0], branchMeta);
+
+	// Then something like...
+	const updatedMeta = updateObject(branchMeta, newMeta, true);
+	branchMeta = updatedMeta;
+	console.log(branchMeta);
+	return meta;
+};
+
 const updateObject = (obj, updates, createAdditionalKeys = false) => {
 	// THIS DOES NOT MUTATE obj, instead returning a new object
 	if (!isRealObject(obj)) {
 		obj = {};
 	}
-	console.log('obj is', obj, typeof obj);
 	if (Object.keys(obj).length === 0) {
 		createAdditionalKeys = true;
 	}
-	const shallowClone = { ...obj };
+	const shallowClone = { ...obj }; // This might be inefficient - consider using immutable or immer
 	Object.entries(updates).forEach(([key, val]) => safeSet(shallowClone, key, val, createAdditionalKeys));
 	return shallowClone;
 };
@@ -488,7 +627,7 @@ const safeSet = (obj, key, val, createAdditionalKeys = false) => {
 	}
 
 	if (undefinedNullOrScalar(currentVal)) {
-		console.log('safeSet undefinedNullOrScalar', key, val);
+		// console.log('safeSet undefinedNullOrScalar', key, val);
 		obj[key] = val;
 	} else {
 		if (Array.isArray(currentVal)) {
@@ -576,6 +715,9 @@ const addMissingFieldSpecificMeta = metaG => Object.entries(metaG)
 
 export {
 	autoMeta, combineModelWithMeta, combineExtraMeta, execMetaReorderingInstructions,
-	buildFieldSpecificMetaInClosure, extractFieldMappings, buildFormGroupFunctionFactory,
-	generateNewModel
+	buildFieldSpecificMetaInClosure, extractFieldMappings,
+	buildFormGroupFunctionFactory,
+	resetAsyncValidatorsRecursive,
+	touchAndUpdateValidityRecursive, resetValidityRecursive,
+	generateNewModel, updateMeta,
 };

+ 53 - 1
src/app/dynaform/services/dynaform.service.ts

@@ -65,6 +65,12 @@
  * buildNewModel
  *
  *
+ * VALIDATION
+ * ----------
+ * getValidationErrors  - get all the validation erros
+ * updateValidity		- mark all controls as touched, and update their validity state
+ * resetValidity		- mark all controls as pristine, and remove all validation failure messages
+ *
  * LOWER-LEVEL METHODS
  * -------------------
  *
@@ -93,15 +99,21 @@
 
 import { Injectable, ComponentRef } from '@angular/core';
 import { FormBuilder, FormGroup } from '@angular/forms';
+import { SuperForm } from 'angular-super-validator';
 import { ModelMapperService } from './model-mapper.service';
 
 import {
 	autoMeta, combineModelWithMeta, combineExtraMeta, execMetaReorderingInstructions,
-	buildFieldSpecificMetaInClosure, extractFieldMappings, buildFormGroupFunctionFactory, generateNewModel
+	buildFieldSpecificMetaInClosure, extractFieldMappings,
+	buildFormGroupFunctionFactory,
+	resetAsyncValidatorsRecursive,
+	touchAndUpdateValidityRecursive, resetValidityRecursive, // advanceUpdateStategyOfInvalidControlsRecursive,
+	generateNewModel, updateMeta
 } from './_formdata-utils';
 
 import { Option } from './../models/field.model';
 
+
 export interface IFormAndMeta {
 	form: FormGroup;
 	meta: StringMap<any>;
@@ -178,6 +190,21 @@ export class DynaformService {
 		const mapping = extractFieldMappings(meta || this.meta); // Memoize
 		const mappedModel = this.modelMapper.reverseMap(newModel, mapping);
 		(form || this.form).patchValue(mappedModel);
+		(form || this.form).updateValueAndValidity();
+	}
+
+	updateMeta(newMeta: StringMap<any>, path: string = '/', meta?: StringMap<any>): StringMap<any> {
+		const newFullMeta = updateMeta(newMeta, path, meta || this.meta);
+		if (!meta) {
+			this.meta = newFullMeta;
+		}
+		return newFullMeta;
+	}
+
+	resetForm(form?: FormGroup): void {
+		// Loop through the form and call reset on any Async validators
+		resetAsyncValidatorsRecursive(form || this.form);
+		(form || this.form).reset();
 	}
 
 	buildNewModel(originalModel: StringMap<any>, formVal?: StringMap<any>, meta?: StringMap<any>): StringMap<any> {
@@ -190,6 +217,31 @@ export class DynaformService {
 		return generateNewModel(originalModel, updates);
 	}
 
+	getValidationErrors(form?: FormGroup): StringMap<string> | false {
+		const _form = form || this.form;
+		if (!_form.valid) {
+			const errorsFlat = SuperForm.getAllErrorsFlat(_form);
+			return errorsFlat;
+		}
+		return false;
+	}
+
+	updateValidity(form?: FormGroup): void {
+		touchAndUpdateValidityRecursive(form || this.form);
+	}
+
+	resetValidity(form?: FormGroup): void {
+		resetValidityRecursive(form || this.form);
+	}
+
+	/*
+	goRealTime(form?: FormGroup): void {
+		// See https://medium.com/p/7acc7f93f357/responses/show INCLUDING FIRST COMMENT
+		// AND _setUpdateStrategy in Angular 8 source https://github.com/angular/angular/blob/8.2.0/packages/forms/src/model.ts#L879
+		// advanceUpdateStategyOfInvalidControlsRecursive(form || this.form);
+	}
+	*/
+
 	// -----------------------------------------------------------------------------------------------------------------
 	// Convenience methods combining several steps
 

+ 51 - 0
src/app/dynaform/services/friendly-validation-errors.service.ts

@@ -0,0 +1,51 @@
+import { Injectable, Inject, Optional } from '@angular/core';
+import { FRIENDLY_VALIDATION_ERRORS, friendlyValidationErrors } from './../config/validation-messages.config';
+import { FriendlyValErrors } from './../interfaces';
+import { path } from 'ramda';
+
+
+@Injectable({
+	providedIn: 'root'
+})
+export class FriendlyValidationErrorsService {
+
+	fErrs: FriendlyValErrors;
+
+	constructor(@Inject(FRIENDLY_VALIDATION_ERRORS) fErrs: FriendlyValErrors) {
+		if (!this.fErrs) {
+			this.fErrs = friendlyValidationErrors;
+		}
+	}
+
+	getFriendly(errorType: string, data?: StringMap<string|number>): string {
+		const friendlyError = this.fErrs[errorType];
+		if (friendlyError) {
+			const dataNotEmpty = typeof data === 'object' && data !== null && Object.keys(data).length;
+			return dataNotEmpty ? this.processTemplateSubstitutions(friendlyError, data) : friendlyError;
+		}
+		return errorType;
+	}
+
+	// -----------------
+	// Simple templating
+
+	private processTemplateSubstitutions(urlTemplate: string, data: StringMap<string|number>): string {
+		const tokens = this.getSubstitutionTokens(urlTemplate);
+		const processedUrl = tokens.reduce(
+			(str, token) => str.replace(new RegExp(`{{\\s${token}\\s+}}`), path(token.split('.'), data)),
+			urlTemplate
+		);
+		return processedUrl;
+	}
+
+	private getSubstitutionTokens(urlTemplate: string): string[] {
+		const resArr = [];
+		let tempArr;
+		const regex = new RegExp('{{\\s+(.*?)\\s+}}', 'g');
+		while ((tempArr = regex.exec(urlTemplate)) !== null) {
+			resArr.push(tempArr[1]);
+		}
+		return resArr;
+	}
+
+}

+ 33 - 1
src/app/dynaform/utils.ts

@@ -1,6 +1,10 @@
 // Utility functions for Dyynaform consumers
 
+import { FormControl, FormGroup, ValidationErrors } from '@angular/forms';
 import { ValueTransformer } from './interfaces';
+import { Observable, of, merge } from 'rxjs';
+import { filter, map, mapTo, switchMap, } from 'rxjs/internal/operators';
+
 
 // Dropdown Modified Input - Starts With / Contains / Matches
 const standardModifiers = ['Starts with', 'Contains', 'Matches'];
@@ -54,4 +58,32 @@ const excludeFields = (obj: StringMap<any>, fieldsToExclude: string | string[])
 	);
 };
 
-export { standardModifiers, standardTransformer, toArrTag, arrayPad, arrayToMeta, excludeFields };
+// Higher order function that takes a bound test function and failure flag
+// and returns an AsyncValidator-compatible function
+// that in turn returns an Observable of ValidationErrors
+const makeAsyncTest = (boundFnName: string, failureFlag: string): (fc: FormControl | FormGroup) => Observable<ValidationErrors> => {
+	let initialValue;
+	return function(fc: FormControl | FormGroup | 'RESET'): Observable<ValidationErrors> {
+		if (fc === 'RESET') {
+			// Special value that resets the initial value
+			initialValue = '';
+			return of({});
+		}
+		if (fc.pristine) {
+			initialValue = fc.value;
+		}
+		return merge(
+			of(fc).pipe(
+				filter(_fc => _fc.value !== initialValue),
+				switchMap(_fc => this[boundFnName](_fc.value, _fc.root)),
+				map(res => res ? {} : { [failureFlag]: true } )
+			),
+			of(fc).pipe(
+				filter(_fc => _fc.value === initialValue),
+				mapTo({})
+			)
+		);
+	};
+};
+
+export { standardModifiers, standardTransformer, toArrTag, arrayPad, arrayToMeta, excludeFields, makeAsyncTest };