Explorar o código

Updating from live version

Richard Knight %!s(int64=6) %!d(string=hai) anos
pai
achega
12dfc02d0b

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

@@ -1,16 +1,16 @@
 import { Input, OnInit } from '@angular/core';
-import { FormGroup } from '@angular/forms';
+import { FormGroup, FormArray } from '@angular/forms';
 
 export abstract class GroupInputComponent implements OnInit {
 
 	@Input()
-	control: FormGroup;
+	control: FormGroup | FormArray;
 
 	@Input()
 	meta;
 
 	formGroup: FormGroup;
-	childMeta: Array<StringMap>;
+	childMetaArray: Array<StringMap>;
 	controlNames: Array<string>;
 
 	exposeMetaInTemplate: string[] = [];
@@ -19,7 +19,7 @@ export abstract class GroupInputComponent implements OnInit {
 		// 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]);
 		this.formGroup = this.control as FormGroup;
-		this.childMeta = Object.values(this.meta.meta); // Metadata array of all controls in group
+		this.childMetaArray = Object.values(this.meta.meta); // Metadata array of all controls in group
 		this.controlNames = Object.keys(this.formGroup.controls);
 	}
 

+ 6 - 1
src/app/dynaform/components/group/checkbutton-group/checkbutton-group.component.html

@@ -1,7 +1,12 @@
 <div class="lmp-checkbutton-group btn-group btn-group-toggle" [formGroup]="formGroup">
 	<app-checkbutton *ngFor="let groupMember of controlNames; let i = index"
 		[formControlName]="groupMember"
-		[meta]="childMeta[i]"
+		[meta]="childMetaArray[i]"
 	>
 	</app-checkbutton>
 </div>
+<div *ngIf="showAllOrNone">
+	<a (click)="selectAll($event)" href class="btn btn-sm btn-outline-primary">Select All</a> 
+	<a (click)="selectNone($event)" href class="btn btn-sm btn-outline-primary">Select None</a>
+</div>
+

+ 22 - 6
src/app/dynaform/components/group/checkbutton-group/checkbutton-group.component.ts

@@ -12,10 +12,12 @@ export class CheckbuttonGroupComponent extends GroupInputComponent implements On
 	firstControl: FormControl;
 
 	constructor(
-		@Attribute('firstEnablesRest') private firstEnablesRest
+		@Attribute('firstEnablesRest') private firstEnablesRest,
+		@Attribute('allOrNone') private showAllOrNone
 	) {
 		super();
 		this.firstEnablesRest = firstEnablesRest === ''; // True if 'firstEnablesRest' exists as component attribute
+		this.showAllOrNone = showAllOrNone === ''; // True if 'firstEnablesRest' exists as component attribute
 	}
 
 	ngOnInit() {
@@ -23,18 +25,21 @@ export class CheckbuttonGroupComponent extends GroupInputComponent implements On
 		if (this.meta.firstEnablesRest) {
 			this.firstEnablesRest = this.meta.firstEnablesRest;
 		}
+		if (this.meta.showAllOrNone) {
+			this.showAllOrNone = this.meta.showAllOrNone;
+		}
 		if (this.firstEnablesRest) {
 			this.firstControl = this.formGroup.controls[this.controlNames[0]] as FormControl;
-			this.childMeta[0].isDisabled = false;
-			this.childMeta.slice(1).map(meta => { meta.isDisabled = !this.firstControl.value; return meta; });
+			this.childMetaArray[0].isDisabled = false;
+			this.childMetaArray.slice(1).map(meta => { meta.isDisabled = !this.firstControl.value; return meta; });
 
 			// Observe value changes on first control
 			this.firstControl.valueChanges.subscribe(val => {
-				for (let i = 1; i < this.childMeta.length; i++) {
+				for (let i = 1; i < this.childMetaArray.length; i++) {
 					// NOTE: We reassign the input (array member) to trigger change detection (otherwise it doesn't run)
 					// See https://juristr.com/blog/2016/04/angular2-change-detection/
-					this.childMeta[i] = {
-						...this.childMeta[1],
+					this.childMetaArray[i] = {
+						...this.childMetaArray[1],
 						isDisabled: !val
 					};
 				}
@@ -42,4 +47,15 @@ export class CheckbuttonGroupComponent extends GroupInputComponent implements On
 		}
 	}
 
+	selectAll(e: Event): false {
+		this.controlNames.forEach(c => this.formGroup.get(c).setValue(this.meta.meta[c].value));
+		(e.target as HTMLLinkElement).blur();
+		return false;
+	}
+
+	selectNone(e: Event): false {
+		this.controlNames.forEach(c => this.formGroup.get(c).setValue(null));
+		(e.target as HTMLLinkElement).blur();
+		return false;
+	}
 }

+ 2 - 0
src/app/dynaform/components/index.ts

@@ -12,3 +12,5 @@ export { CheckbuttonComponent } from './custom/checkbutton/checkbutton.component
 export { CheckbuttonGroupComponent } from './group/checkbutton-group/checkbutton-group.component';
 export { TimepickerComponent } from './kendo/timepicker/timepicker.component';
 export { DatepickerComponent } from './kendo/datepicker/datepicker.component';
+export { ButtonGroupComponent } from './nocontrol/button-group/button-group.component';;
+

+ 3 - 0
src/app/dynaform/components/nocontrol/button-group/button-group.component.html

@@ -0,0 +1,3 @@
+<a *ngFor="let button of buttons" class="btn btn-sm" [ngClass]="button.class" (click)="handle(button.fnId, $event)" href>
+	<span *ngIf="button.icon" class="k-icon" [ngClass]="button.icon"></span> {{ button.label }}
+</a>

+ 0 - 0
src/app/dynaform/components/nocontrol/button-group/button-group.component.scss


+ 25 - 0
src/app/dynaform/components/nocontrol/button-group/button-group.component.spec.ts

@@ -0,0 +1,25 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ButtonGroupComponent } from './button-group.component';
+
+describe('ButtonGroupComponent', () => {
+  let component: ButtonGroupComponent;
+  let fixture: ComponentFixture<ButtonGroupComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ ButtonGroupComponent ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(ButtonGroupComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 28 - 0
src/app/dynaform/components/nocontrol/button-group/button-group.component.ts

@@ -0,0 +1,28 @@
+import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
+
+@Component({
+	selector: 'app-button-group',
+	templateUrl: './button-group.component.html',
+	styleUrls: ['./button-group.component.scss']
+})
+export class ButtonGroupComponent implements OnInit {
+
+	@Input()
+	meta: StringMap;
+
+	@Output()
+	call: EventEmitter<string> = new EventEmitter<string>();
+	
+	buttons: StringMap[];
+
+	ngOnInit() {
+		this.buttons = this.meta.meta;
+	}
+	
+	handle(fnId: string, e: Event) {
+		e.preventDefault();
+		(e.target as HTMLElement).blur();
+		this.call.emit(fnId);
+	}
+
+}

+ 9 - 0
src/app/dynaform/directives/dynafield.directive.ts

@@ -15,6 +15,7 @@ interface FFC {
 	control: FormControl; // Remember, this can be an individual control or a FormGroup
 	meta: StringMap;
 	propagateChange?: Function;
+	call?: EventEmitter<string>
 }
 type FFCCustom = FFC & ControlValueAccessor;
 
@@ -43,6 +44,9 @@ export class DynafieldDirective extends NgControl implements OnInit, OnDestroy {
 	@Output('ngModelChange')
 	update = new EventEmitter();
 
+	@Output()
+	call: EventEmitter<string> = new EventEmitter<string>();
+
 	component: ComponentRef<FFC|FFCCustom>;
 	_control;
 
@@ -90,6 +94,11 @@ export class DynafieldDirective extends NgControl implements OnInit, OnDestroy {
 			instance.control = control;
 			instance.meta = meta;
 
+			// Listen for 'call' Output events and send them onwards
+			if (instance.call) {
+				instance.call.subscribe(val => this.call.emit(val));
+			}
+
 			// Add id and classes (as specified)
 			if (cssId) {
 				el.id = cssId;

+ 16 - 16
src/app/dynaform/dynaform.component.html

@@ -1,5 +1,5 @@
-<ng-container *ngFor="let controlName of controlNames">
-	<ng-container *ngTemplateOutlet="template ? template : default; context: getTemplateContext(controlName)"></ng-container>
+<ng-container *ngFor="let rowName of dynaFormRows">
+	<ng-container *ngTemplateOutlet="template ? template : default; context: getTemplateContext(rowName)"></ng-container>
 </ng-container>
 
 
@@ -7,12 +7,10 @@
      used when a TemplateRef is NOT supplied to component -->
 <ng-template #default let-control="control" let-meta="meta">
 	
-	<div class="row" *ngIf="meta.type !== 'Container'; else recursiveDynaform" [ngClass]="getRowClass(meta.type)">
-		<div class="col-sm-4">
-			<label [for]="meta.name">{{ meta.label }}</label>
-		</div>
+	<div class="form-group row" *ngIf="meta.type !== 'Container'; else recursiveDynaform" [ngClass]="getRowClass(meta.type)">
+		<label class="col-sm-4" [for]="meta.name">{{ meta.label }}</label>
 		<div class="col-sm-8">
-			<ng-container dynafield [control]="control" [meta]="meta"></ng-container>
+			<ng-container dynafield [control]="control" [meta]="meta" (call)="handle($event)"></ng-container>
 		</div>
 	</div>
 	
@@ -26,12 +24,14 @@
 </ng-template>
 
 
-<!-- Display validation unfo if debugging and not nested -->
- <pre *ngIf="debug && path.length === 0"
-	[ngClass]="{
-		'alert-success' 	: formGroup.valid && formGroup.dirty,
-		'alert-danger' 		: !formGroup.valid && formGroup.dirty,
-		'alert-secondary' 	: !formGroup.dirty
-	}"
-	class="alert mt-4"
-	role="alert">{{ formGroup.pristine ? 'Untouched' : getValidationErrors() | json }}</pre>
+<!-- Display validation status if debugging and not nested -->
+<div *ngIf="debug && path.length === 0">
+	<pre [ngClass]="{
+			'alert-success' 	: formGroup.valid && formGroup.dirty,
+			'alert-danger' 		: !formGroup.valid && formGroup.dirty,
+			'alert-secondary' 	: !formGroup.dirty
+		}"
+		class="alert mt-4"
+		role="alert">{{ formGroup.pristine ? 'Untouched' : getValidationErrors() | json }}</pre>
+</div>
+

+ 15 - 10
src/app/dynaform/dynaform.component.ts

@@ -1,7 +1,6 @@
-import { Component, Input, Output, TemplateRef, Optional, OnInit, ChangeDetectionStrategy } from '@angular/core';
+import { Component, Input, Output, EventEmitter, TemplateRef, Optional, OnInit, ChangeDetectionStrategy } from '@angular/core';
 import { FormGroup, FormGroupName, AbstractControl, ControlContainer } from '@angular/forms';
 import { SuperForm } from 'angular-super-validator';
-import { EventEmitter } from 'events';
 
 export interface DynarowContext {
 	control: AbstractControl;
@@ -52,14 +51,15 @@ export class DynaformComponent implements OnInit {
 	debug = false;
 
 	@Output()
-	dynaval = new EventEmitter();
+	call: EventEmitter<string> = new EventEmitter<string>();
 
-	formMetaData: any; // TODO: Tighten up type
+	formMetaData: StringMap; // TODO: Tighten up type
 	controlNames: string[];
+	dynaFormRows: string[];
 	path: string[]; // path of current FormGroup - can be used to respond differently based on nesting level in template
 
 	constructor(
-		@Optional() private cc: ControlContainer
+		@Optional() private cc: ControlContainer,
 	) {}
 
 	ngOnInit() {
@@ -72,7 +72,6 @@ export class DynaformComponent implements OnInit {
 		}
 		this.controlNames = Object.keys(this.formGroup.controls);
 		this.path = this.getFormGroupPath();
-		// Object.freeze(this.path);
 
 		// If we're given a formGroupName or nested FormGroup, and the form's full (or partial but fuller) metadata tree,
 		// drill down to find the FormGroup's metadata
@@ -82,9 +81,15 @@ export class DynaformComponent implements OnInit {
 			const branch = path.pop();
 			this.formMetaData = this.formMetaData[branch].meta;
 		}
-		
+		this.dynaFormRows = Object.keys(this.formMetaData);
+
 		// Check we've got a "FormGroup <---> MetaData" match
-		const metaDataKeys = Object.keys(this.formMetaData).join(',');
+		//
+		// const metaDataKeys = Object.keys(this.formMetaData).join(','); // OLD VERSION - before we introduced entities like ButtonGroup that don't create FormControls
+		const metaDataKeys = Object.entries(this.formMetaData)
+							.filter(([key, val]) => !(<StringMap>val).noFormControls)
+							.reduce((acc, [key]) => [...acc, key], [])
+							.join(',');
 		if (metaDataKeys !== metaDataKeysExpected) {
 			throw new Error(`
 				Dynaform can't match FormGroup's controls with metadata
@@ -134,8 +139,8 @@ export class DynaformComponent implements OnInit {
 		return 'No Errors';
 	}
 
-	detected(val) {
-		console.log('DETECTED', val);
+	handle(val) {
+		this.call.emit(val);
 	}
 
 }

+ 5 - 1
src/app/dynaform/dynaform.module.ts

@@ -7,6 +7,7 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
 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 * as formFieldComponents from './components';
 const ffcArr = Object.values(formFieldComponents); // Array of all the Form Field Components
@@ -28,9 +29,12 @@ import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
 	],
 	entryComponents: ffcArr,
 	providers: [
-		DynaformService
+		DynaformService,
+		ModelMapperService
 	],
 	exports: [
+		FormsModule,
+		ReactiveFormsModule,
 		DynaformComponent,
 		ffcArr
 	]

+ 5 - 0
src/app/dynaform/index.ts

@@ -0,0 +1,5 @@
+export { DynaformComponent } from './dynaform.component';
+export { DynaformService } from './services/dynaform.service';
+export { ModelMapperService } from './services/model-mapper.service';
+export { standardModifiers, standardTransformer, arrayToMeta } from './utils';
+

+ 47 - 9
src/app/dynaform/models/field.model.ts

@@ -7,6 +7,7 @@
 import { TemplateRef } from '@angular/core'; 
 import { ValidatorFn, AsyncValidatorFn } from '@angular/forms';
 import { ValueTransformer } from './../interfaces';
+import { standardModifiers, standardTransformer } from './../utils';
 
 interface SimpleFieldMetaData {
 	name: string; 								// The FormControl name
@@ -83,10 +84,17 @@ abstract class SimpleField {
 }
 
 class Option {
-	constructor(opt: string | Option) {
+	// Can take a simple string value, a value-label pair [value, label],
+	// or an Option of the form { label: string, value: string }
+	constructor(opt: string | string[] | Option) {
 		if (typeof opt === 'object') {
-			this.label = opt.label;
-			this.value = opt.value;
+			if (Array.isArray(opt)) {
+				this.label = opt[1];
+				this.value = opt[0];
+			} else {
+				this.label = opt.label;
+				this.value = opt.value;
+			}
 		} else if (typeof opt === 'string') {
 			this.label = opt;
 			this.value = opt;
@@ -142,8 +150,8 @@ class CheckbuttonField extends SimpleField {
 
 class DropdownModifiedInputField extends SimpleField {
 	type = 'DropdownModifiedInput';
-	modifiers: string[];
-	transform: ValueTransformer;
+	modifiers: string[] = standardModifiers;
+	transform: ValueTransformer = standardTransformer;
 	constructor(meta: DropdownModifiedInputFieldMetaData) {
 		super(meta);
 	}
@@ -158,7 +166,8 @@ class CheckbuttonGroup {
 	label?: string;
 	groupName: string;
 	firstEnablesRest?;
-	meta: CheckbuttonField[];
+	showAllOrNone?;
+	meta: CheckbuttonField[] | { [key: string]: CheckbuttonField };
 	constructor(groupmeta: any) {
 		Object.assign(this, groupmeta);
 		if (!this.label) {
@@ -166,8 +175,16 @@ class CheckbuttonGroup {
 			// e.g. supervisorCardNumber --> Supervisor Card Number
 			this.label = unCamelCase(this.name);
 		}
-		const groupMembers = Array.isArray(groupmeta.meta) ? groupmeta.meta : Object.values(groupmeta.meta);
-		this.meta = groupMembers.map(cb => new CheckbuttonField(cb));
+		// Can render as a FormArray or FormGroup depending on input data
+		if (Array.isArray(groupmeta.meta)) {
+			const arrayMembers = groupmeta.meta;
+			this.meta = arrayMembers.map(cb => new CheckbuttonField(cb));
+		} else {
+			const groupMembers = groupmeta.meta;
+			this.meta = Object.entries(groupMembers)
+				.map( ([key, cb]) => [key, new CheckbuttonField(cb as SimpleFieldMetaData)] )
+				.reduce((res, [key, cbf]) => { res[<string>key] = cbf; return res; }, {});
+		}
 	}
 }
 
@@ -203,6 +220,27 @@ class Container {
 	}
 }
 
+// ---------------------------------------------------------------------------------------------------------------------
+// Button Group
+
+interface Button {
+	label: string;
+	fnId: string;
+	class: string;
+	icon: string;
+}
+
+class ButtonGroup {
+	type = 'ButtonGroup';
+	name: string;
+	label = '';
+	meta: Button[];
+	readonly noFormControls = true; // Indicates this has no FormControls associated with it
+	constructor(meta: StringMap) {
+		Object.assign(this, meta);
+	}
+}
+
 // ---------------------------------------------------------------------------------------------------------------------
 // ---------------------------------------------------------------------------------------------------------------------
 // ---------------------------------------------------------------------------------------------------------------------
@@ -214,6 +252,6 @@ export {
 	CheckbuttonField, DropdownModifiedInputField,
 	CheckbuttonGroup,
 	TimepickerField, DatepickerField,
-	Container
+	Container, ButtonGroup
 };
 

+ 14 - 7
src/app/dynaform/services/meta-utils.ts

@@ -17,7 +17,7 @@
  *
  */
 
-import { FormBuilder, FormGroup, FormControl, ValidatorFn, AsyncValidatorFn } from '@angular/forms';
+import { FormBuilder, FormGroup, FormArray, FormControl, ValidatorFn, AsyncValidatorFn } from '@angular/forms';
 import { reduce } from 'lodash/fp';
 import * as fmdModels from '../models/field.model';
 
@@ -31,17 +31,20 @@ interface AbstractControlOptions {
 
 
 // ---------------------------------------------------------------------------------------------------------------------
-// Raw Model (or Mapped Raw Model) to Automatic Metadata
+// AutoMeta: Generate Autimatic Metadata from a raw model (or mapped raw model)
 // ---------------------------------------------------------------------------------------------------------------------
 
 const isScalar = val => typeof val === 'boolean' || typeof val === 'number' || typeof val === 'string';
+const isArray = val => Array.isArray(val);
 
 const keyValPairToMeta = (val, key) => ({ name: key, [isScalar(val) ? 'value' : 'meta']: val });
 const keyValPairToMetaRecursive = ( [key, val] ) => {
-	const innerVal = isScalar(val) ? val : autoMeta(val);
+	const innerVal = isScalar(val) ? val : (isArray(val) ? arrayToMeta(val) : autoMeta(val));
 	const metaVal = keyValPairToMeta(innerVal, key);
 	return [key, metaVal];
 };
+const arrayToMeta = array => array.map(val => ({ name: val, 'value' : val }));
+
 const autoMeta = model => Object.entries(model)
 	.map(keyValPairToMetaRecursive)
 	.reduce((res, [key, val]) => addProp(res, key, val), {});
@@ -121,14 +124,18 @@ const buildFormGroupFunctionFactory = (fb: FormBuilder): (meta) => FormGroup =>
 	});
 	const buildFormControl = metaF => new FormControl(buildControlState(metaF), buildValidators(metaF));
 
-	// Build Form Group Member
+	// Build Form Array
+	const buildFormArray = (metaG): FormArray => fb.array(metaG.map(metaF => buildFormControl(metaF)));
+
+	// Build Form Group Member - builds a FormControl, FormArray, or another FormGroup which can contain any of these
 	const buildFormGroupMember = metaFoG => isGroup(metaFoG) ?
-		_buildFormGroup(metaFoG.meta) :
+		(isArray(metaFoG.meta) ? buildFormArray(metaFoG.meta) : _buildFormGroup(metaFoG.meta)) :
 		buildFormControl(metaFoG);
 
-	const buildFormGroupReducerIteree = (res, metaFoG) => Object.assign(res, { [metaFoG.name]: buildFormGroupMember(metaFoG) });
+	const buildFormGroupReducerIteree = (res, metaFoG) => metaFoG.noFormControls ? res : Object.assign(res, { [metaFoG.name]: buildFormGroupMember(metaFoG) });
 	const _buildFormGroup = _metaG => fb.group(reduce(buildFormGroupReducerIteree, {}, _metaG));
 
+	// 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);
@@ -161,7 +168,7 @@ const addNameIfMissing = (metaFoG, key) => metaFoG.name ? metaFoG : addProp(meta
 const addNameToSelfAndChildren = ( [key, metaFoG] ) => {
 	metaFoG = addNameIfMissing(metaFoG, key);
 	if (isGroup(metaFoG)) {
-		metaFoG.meta = Array.isArray(metaFoG.meta) ? Object.values(addMissingNames(metaFoG.meta)) : addMissingNames(metaFoG.meta); // Recursion
+		metaFoG.meta = isArray(metaFoG.meta) ? Object.values(addMissingNames(metaFoG.meta)) : addMissingNames(metaFoG.meta); // Recursion
 	}
 	return [key, metaFoG];
 };

+ 23 - 3
src/app/dynaform/services/dynaform.service.ts

@@ -42,7 +42,7 @@
  *
  * NOTES
  * -----
- * This class acts as an injectable wraper around the exports of meta-utils.ts,
+ * This class acts as an injectable wraper around the exports of _formdata-utils.ts,
  * as well as creating a buildFormGroup function using the injected FormBuilder singleton
  *
  *
@@ -53,23 +53,27 @@
  *
  */
 
-import { Injectable } from '@angular/core';
+import { Injectable, ComponentRef } from '@angular/core';
 import { FormBuilder, FormGroup } from '@angular/forms';
 import {
 	autoMeta, combineModelWithMeta, combineExtraMeta,
 	buildFieldSpecificMeta, buildFormGroupFunctionFactory
-} from './meta-utils';
+} from './_formdata-utils';
 
 export interface FormAndMeta {
 	form: FormGroup;
 	meta: StringMap;
 }
 
+export interface Callbacks {
+	[index: string]: () => void
+}
 
 @Injectable()
 export class DynaformService {
 
 	public buildFormGroup: (meta) => FormGroup;
+	private callbacks: Callbacks = {};
 
 	constructor(private fb: FormBuilder) {
 		this.buildFormGroup = buildFormGroupFunctionFactory(fb);
@@ -80,6 +84,22 @@ export class DynaformService {
 		return this.autoBuildFormGroupAndMeta(model, meta, createFromMeta);
 	}
 
+	register(callbacks: Callbacks, cref: ComponentRef<any>['instance']) {
+		// Bind the component instance to the callback, so that 'this' has the context of the component
+		Object.entries(callbacks).forEach(([key, fn]) => this.callbacks[key] = fn.bind(cref));
+	}
+	
+	call(fnId: string) {
+		// Handle callback events
+		try {
+			this.callbacks[fnId]();
+		}
+		catch(e) {
+			console.error('Dynaform has no registered callback for', fnId);
+			console.error(e);
+		}
+	}
+
 	// -----------------------------------------------------------------------------------------------------------------
 	// Convenience methods combining several steps
 

+ 303 - 0
src/app/dynaform/services/model-mapper.service.ts

@@ -0,0 +1,303 @@
+/*
+
+Model mapping class, exposing six public methods
+================================================
+
+setMapping(mapping)
+forwardMap(model, mapping?)
+lazyForwardMap(model, mapping?)
+reverseMap(model, mapping?)
+lazyReversMap(model, mapping?)
+getErrors()
+
+EXAMPLES
+--------
+
+Given the function 'isEven',
+and the following model and mapping:
+
+const isEven = v => { return { value: v, isEven: !(v % 2) } }
+
+const model = {
+	a: 555,
+	b: 777,
+	c: { r: 1000, s: 2000, t: 3000, lazy: "everybody's lazy" },
+	d: 22,
+	e: 33,
+	f: 2,
+	g: 3,
+	h: 100000,
+	i: 1,
+	y: { 'name' : 'lost in action' },
+	z: 'hello'
+}
+
+const mapping = {
+	a: 'b.c',
+	b: 'e.f.g',
+	c: {
+		r: 'r',
+		s: 's',
+		t: 't.u'
+	},
+	d: [ v => v * 2, v => v / 2 ],
+	e: [ 'b.e', v => v * 3, v => v / 3 ],
+	f: [ isEven, v => v.value ],
+	g: [ isEven, v => v.value ],
+	h: [ 'y.hhh', isEven, v => v.value ],
+	i: v => ['y.iii', isEven(v)] // Deliberately malformed mapping
+}
+
+(1) forwardMap gives:
+
+{ b: { c: 555, e: 99 },
+  e: { f: { g: 777 } },
+  r: 1000,
+  s: 2000,
+  t: { u: 3000 },
+  d: 44,
+  f: { value: 2, isEven: true },
+  g: { value: 3, isEven: false },
+  y: { hhh: { value: 100000, isEven: true } },
+  i: 'MAPPING ERROR: Unknown mapping type in mapping at i' }
+
+(2) lazyForwardMap (which also copies properties not explicitly specified in the mapping) gives:
+
+{ b: { c: 555, e: 99 },
+  e: { f: { g: 777 } },
+  r: 1000,
+  s: 2000,
+  t: { u: 3000 },
+  c: { lazy: 'everybody\'s lazy' },
+  d: 44,
+  f: { value: 2, isEven: true },
+  g: { value: 3, isEven: false },
+  y: { hhh: { value: 100000, isEven: true }, name: 'lost in action' },
+  i: 'MAPPING ERROR: Unknown mapping type in mapping at i',
+  z: 'hello' }
+
+(3) reverseMap (on either of the forwardMap results) regenerates the explicitly mapped parts of the original model:
+
+{ a: 555,
+  b: 777,
+  c: { r: 1000, s: 2000, t: 3000 },
+  d: 22,
+  e: 33,
+  f: 2,
+  g: 3,
+  h: 100000 }
+
+(4) lazyReverseMap also copies additional properties, if it can
+	(it will merge objects without overwriting exissting properties, and won't overwrite scalar properties)
+	
+{ a: 555,
+  b: 777,
+  c: { r: 1000, s: 2000, t: 3000, lazy: 'everybody\'s lazy' },
+  d: 22,
+  e: 33,
+  f: 2,
+  g: 3,
+  h: 100000,
+  y: { hhh: { value: 100000, isEven: true }, name: 'lost in action' },
+  i: 'MAPPING ERROR: Unknown mapping type in mapping at i',
+  z: 'hello' }
+
+*/
+
+import { Injectable } from '@angular/core';
+import * as _ from 'lodash';
+
+@Injectable()
+export class ModelMapperService {
+
+	mapping: StringMap;
+	errors: string[] = [];
+	
+	constructor() { }
+
+	public setMapping(mapping: StringMap) {
+		this.mapping = mapping;
+	}
+	
+	public forwardMap = (model: StringMap, mapping: StringMap = this.mapping, mapMissing = false, res = {}, stack = []): StringMap => {
+		Object.keys(model).forEach(key => {
+			let mappingPath = _.get(mapping, key, mapMissing ? [...stack, key].join('.') : false);
+			if (mappingPath) {
+				let mappingType = this.resolveMappingType(mappingPath);
+				switch(mappingType) {
+					case 'simple':
+						this.deepSet(res, mappingPath, model[key]);
+						break;
+					case 'nested':
+						this.forwardMap(model[key], mappingPath, mapMissing, res, [...stack, key]);
+						break;
+					case 'functional':
+						try {
+							let result = mappingPath.find(m => typeof m === 'function')(model[key]);
+							key = mappingPath.length === 3 ? mappingPath[0] : key;
+							this.deepSet(res, key, result);
+						} catch (e) {
+							this.errors.push('Error in mapping function at', mappingPath, ':::', e.message);
+						}
+						break;
+					default:
+						this.deepSet(res, key, `MAPPING ERROR: Unknown mapping type in mapping at ${key}`);
+				}
+			}
+		});
+		return res;
+	}
+	
+	public lazyForwardMap = (model: StringMap, mapping: StringMap = this.mapping): StringMap => this.forwardMap(model, mapping, true);
+	
+	public reverseMap = (model: StringMap, mapping: StringMap = this.mapping, mapMissing = false, pathsToDelete = [], stack = []): StringMap => {
+		// pathToDelete contains a list of source paths to delete from the model, leaving the missing to be straight-mapped
+		let res = {};
+		let modelClone = stack.length ? model : _.cloneDeep(model); // Clone the model unless inside a recursive call
+		Object.keys(mapping).forEach(key => { 
+			let mappingType = this.resolveMappingType(mapping[key]);
+			let destinationPath = mapping[key];
+			let value;
+			switch(mappingType) {
+				case 'simple':
+					value = _.get(modelClone, mapping[key]);
+					if (typeof value !== 'undefined') {
+						this.deepSet(res, key, value);
+					}
+					break;
+				case 'nested':
+					value = this.reverseMap(modelClone, mapping[key], mapMissing, pathsToDelete, [...stack, key]);
+					if (Object.keys(value).length) {
+						this.deepSet(res, key, value);
+					}
+					break;
+				case 'functional':
+					if (typeof mapping[key][0] === 'string') {
+						destinationPath = mapping[key][0];
+					} else {
+						destinationPath = key;
+					}
+					let arg = _.get(modelClone, destinationPath);
+					if (typeof arg !== 'undefined') {
+						let func = _.findLast(mapping[key], m => typeof m === 'function');
+						try {
+							value = func(arg);
+							this.deepSet(res, key, value);
+						} catch(e) {
+							this.errors.push('Error in mapping function at', key, ':::', e.message);
+						}
+					}
+					break;
+				default:
+					this.errors.push(`MAPPING ERROR: Unknown mapping type in mapping at ${key}`);
+			}
+			if (mappingType && mappingType !== 'nested') {
+				pathsToDelete.push(destinationPath);
+			}
+		});
+	
+		if (mapMissing && !stack.length) {
+			// Delete the paths we have already reverse mapped (if scalar value - not objects), to leave the rest
+			// Sort pathsToDelete by depth, shallowest to deepest
+			const deepestPathsLast = this.sortByPathDepth(pathsToDelete);
+			while(deepestPathsLast.length) {
+				const path = deepestPathsLast.pop();
+				const t = typeof _.get(modelClone, path);
+				if (t === 'number' || t === 'string' || t === 'boolean') {
+					_.unset(modelClone, path);
+				}
+			}
+			const modelRemainder = this.deepCleanse(modelClone);
+			Object.keys(modelRemainder).forEach(key => {
+				this.deepSetIfEmpty(res, key, modelRemainder[key]);
+			})
+		}
+		return res;
+	}
+	
+	public lazyReverseMap = (model: StringMap, mapping: StringMap = this.mapping): StringMap => this.reverseMap(model, mapping, true);
+	
+	public getErrors() {
+		return this.errors;
+	}
+	
+	// -----------------------------------------------------------------------------------------------------------------
+
+	private resolveMappingType = mappingPath => {
+		let mappingType;
+		let t = typeof mappingPath;
+		if (t === 'number' || t === 'string') { // CHECK WHAT HAPPENS with number types
+			mappingType = 'simple';
+		} else if (t === 'object') {
+			if (
+				Array.isArray(mappingPath)
+				&& mappingPath.length === 2
+				&& _.every(mappingPath, m => typeof m === 'function')
+				||
+				Array.isArray(mappingPath)
+				&& mappingPath.length === 3
+				&& (typeof mappingPath[0] === 'number' || typeof mappingPath[0] === 'string')
+				&& _.every(mappingPath.slice(1), m => typeof m === 'function')
+			) {
+				mappingType = 'functional';
+			} else {
+				mappingType = 'nested';
+			}
+		}
+		return mappingType;
+	}
+	
+	private deepSet = (obj, mappingPath, valueToSet, overwrite = true) => {
+		// NOTE: This mutates the incoming object at the moment, so doesn't reed to return a value
+		// Maybe rewrite with a more functional approach?
+		// Will deep merge where possible
+		let currentVal = _.get(obj, mappingPath);
+		let t = typeof currentVal;
+		if (t === 'undefined') {
+			_.set(obj, mappingPath, valueToSet);
+		} else if (t === 'number' || t === 'string' || t === 'boolean') {
+			// We can only overwrite existing scalar values, not deep merge
+			if (overwrite) {
+				this.errors.push('WARNING: Overwriting scalar value at', mappingPath);
+				_.set(obj, mappingPath, valueToSet);
+			} else {
+				this.errors.push('WARNING: Discarding scalar value at', mappingPath, 'as exisiting vale would be overwritten');
+			}
+		} else if (t === 'object' && typeof valueToSet === 'object') {
+			// Deep merge
+			let merged = _.merge(currentVal, valueToSet);
+			if (!overwrite) {
+				merged = _.merge(merged, currentVal); // Is there a better way?
+			}
+			_.set(obj, mappingPath, merged);
+		} else {
+			this.errors.push('WARNING: Could not merge', typeof valueToSet, 'with object')
+		}
+	}
+	
+	private deepSetIfEmpty = (obj, mappingPath, valueToSet) => this.deepSet(obj, mappingPath, valueToSet, false);
+	
+	private deepCleanse = obj => {
+		let cleansedObj = Object.keys(obj)
+			.filter(k => obj[k] !== null && obj[k] !== undefined)  // Remove undefined and null
+			.reduce((newObj, k) => {
+				typeof obj[k] === 'object' ?
+					Object.assign(newObj, { [k]: this.deepCleanse(obj[k]) }) : // Recurse if value is non-empty object
+					Object.assign(newObj, { [k]: obj[k] }) // Copy value
+				return newObj;
+			}, {});
+		Object.keys(cleansedObj).forEach(k => {
+			if (typeof cleansedObj[k] === 'object' && !Object.keys(cleansedObj[k]).length) {
+				delete cleansedObj[k];
+			}
+		});
+		return cleansedObj;
+	}
+	
+	private sortByPathDepth = pathArr => _.sortBy(pathArr, p => p.split('.').length);
+
+	private clearErrors() {
+		this.errors = [];
+	}
+
+}

+ 172 - 0
src/app/dynaform/testdata/testset.1.ts

@@ -0,0 +1,172 @@
+import { Validators } from '@angular/forms';
+import { ValueTransformer } from './../interfaces';
+
+// ---------------------------------------------------------------------------------------------------------------------
+// Native
+
+const basicTextField = {
+	type: 'Text',
+	label: 'Field One',
+	placeholder: 'Type a value here'
+};
+
+const styledTextField = {
+	type: 'Text',
+	placeholder: 'With a DOM id and CSS classes applied',
+	class: ['red', 'bgPaleBlue'],
+	id: 'yoyo'
+};
+
+const textareaField = {
+	type: 'Textarea',
+	placeholder: 'Type your long-winded comments here'
+};
+
+const passwordField = {
+	type: 'Password',
+	placeholder: 'It\'s a secret'
+};
+
+const selectField = {
+	type: 'Select',
+	options: ['', 'Apples', 'Oranges', 'Pears', 'Gorgonzola']
+};
+
+const radioField = {
+	type: 'radio',
+	options: ['Tea', 'Coffee', 'Cocoa', 'Yerba Maté'],
+	validators: [ Validators.required ],
+};
+
+const disabledTextField = {
+	type: 'Text',
+	placeholder: 'You can\'t touch this',
+	isDisabled: true
+};
+
+const radioFieldHorizontal = {
+	type: 'radio',
+	options: ['Fish', 'Fowl', 'Neither'],
+	horizontal: true
+};
+
+
+// ---------------------------------------------------------------------------------------------------------------------
+// Custom
+
+const checkbutton = {
+	type: 'checkbutton',
+	value: '456'
+};
+
+const checkbutton2 = {
+	type: 'checkbutton',
+	value: 'Yowsa'
+};
+
+const modifiers = ['Matches', 'Starts With', 'Contains'];
+const transformerFunctions: ValueTransformer = {
+	inputFn: val => {
+		let modifier = 'Starts With';
+		if (/^%.*?%$/.test(val)) {
+			modifier = 'Contains';
+		} else if (/^[^%].*?[^%]$/.test(val)) {
+			modifier = 'Matches';
+		} else if (/^%.*/.test(val)) {
+			modifier = 'Starts With';
+		}
+		const transformedVal = val.replace(/%/g, '').trim();
+		return { modifier: modifier, value: transformedVal };
+	},
+	outputFn: (mod, val) => {
+		let transformedValue;
+		switch (mod) {
+			case 'Starts With':
+				transformedValue = `%${val}`;
+				break;
+			case 'Contains':
+				transformedValue = `%${val}%`;
+				break;
+			case 'Matches':
+			default:
+				transformedValue = val;
+				break;
+		}
+		return transformedValue;
+	}
+};
+const dropdownModifiedInput = {
+	type: 'dropdownModifiedInput',
+	value: 'lovely',
+	modifiers,
+	transform: transformerFunctions
+};
+
+const checkbuttonGroup = {
+	type: 'CheckbuttonGroup',
+	firstEnablesRest: true,
+	meta: { iMaskTheOthers: {}, groupMemberTwo: {}, groupMemberThree: {} }
+};
+
+
+
+// ---------------------------------------------------------------------------------------------------------------------
+// Kendo
+
+const timepicker = {
+	type: 'timepicker'
+};
+
+const datepicker = {
+	type: 'datepicker'
+};
+
+// ---------------------------------------------------------------------------------------------------------------------
+// Container
+
+const basicTextField2 = {
+	type: 'Text',
+	label: 'Required Field',
+	validators: [ Validators.required, Validators.minLength(4) ],
+};
+
+const checkbuttonGroupArray = {
+	type: 'CheckbuttonGroup',
+	firstEnablesRest: false,
+	showAllOrNone: true,
+	meta: [
+		{name: 'One', value: 111}, {name: 'Two', value: 222}, {name: 'Three', value: 333}, {name: 'Four', value: 444},
+		{name: 'Five', value: 555}, {name: 'Six', value: 666}, {name: 'Seven', value: 777}, {name: 'Eight', value: 888}
+	]
+};
+
+const container = {
+	type: 'Container',
+	meta: {
+		basicTextField2,
+		checkbuttonGroupArray
+	}
+};
+
+// ---------------------------------------------------------------------------------------------------------------------
+
+const model = {};
+
+const meta = {
+	basicTextField,
+	styledTextField,
+	textareaField,
+	passwordField,
+	selectField,
+	radioField,
+	disabledTextField,
+	radioFieldHorizontal,
+	checkbutton,
+	dropdownModifiedInput,
+	checkbuttonGroup,
+	timepicker,
+	datepicker,
+	container
+};
+
+export { model, meta };

+ 48 - 0
src/app/dynaform/testdata/testset.2.ts

@@ -0,0 +1,48 @@
+// TESTS: Combination of model with lazy-metadata + Validation
+
+import { Validators } from '@angular/forms';
+
+const model = {
+	field: '',
+	fieldTwo: '',
+	nestedGroup: {
+		fieldA: '',
+		validationTest: '',
+		fieldB: 'Value 2',
+		fieldC: 'Maybe',
+		anotherNestedGroup: {
+			fieldE: '',
+			fieldF: 555,
+			yetAnotherNestedGroup: {
+				fieldH: true,
+				OooOooAhhAhh: false
+			}
+		},
+		z: 'THE END'
+	}
+};
+
+const meta = {
+	nestedGroup: {
+		meta: {
+			validationTest: {
+				validators: [ Validators.required, Validators.minLength(4) ],
+				// perhaps have some standard messages for standard failures in the Object.prototype ??
+				valFailureMessages: {
+					'required': 'This field is required',
+					'minlength': 'Please type something longer'
+				}
+			},
+			fieldB: { type: 'checkbutton' },
+			fieldC: { label: 'Property Three', type: 'radio', options: ['Yes', 'No', 'Maybe'], horizontal: 1 },
+			anotherNestedGroup: {
+				meta: {
+					fieldE: { type: 'radio', 'label': 'Does it work yet?', validators: Validators.required },
+					fieldF: { type: 'datepicker' }
+				}
+			}
+		}
+	}
+};
+
+export { model, meta };

+ 42 - 0
src/app/dynaform/utils.ts

@@ -0,0 +1,42 @@
+// Some standard utility functions for Dyynaform consumers
+
+import { ValueTransformer } from './interfaces';
+
+// Dropdown Modified Input - Starts With / Contains / Matches
+const standardModifiers = ['Starts with', 'Contains', 'Matches'];
+const standardTransformer: ValueTransformer = {
+	inputFn: val => {
+		let modifier = 'Starts with';
+		if (/^%.*?%$/.test(val)) {
+			modifier = 'Contains'
+		} else if (/^[^%].*?[^%]$/.test(val)) {
+			modifier = 'Matches'
+		} else if (/^%.*/.test(val)) {
+			modifier = 'Starts with';
+		}
+		const transformedVal = val.replace(/%/g, '').trim();
+		return { modifier: modifier, value: transformedVal };
+	},
+	outputFn: (mod, val) => {
+		let transformedValue;
+		switch(mod) {
+			case 'Starts with':
+				transformedValue = `%${val}`;
+				break;
+			case 'Contains':
+				transformedValue = `%${val}%`;
+				break;
+			case 'Matches':
+			default:
+				transformedValue = val;
+				break;
+		}
+		return transformedValue;
+	}
+};
+
+
+// Utility function for casting an array to metadata (useful for components that render FormGroups)
+const arrayToMeta = array => array.map(val => ({ name: val, 'value' : val }));
+
+export { standardModifiers, standardTransformer, arrayToMeta };