Browse Source

Updating standalone version with changes from lmp

Richard Knight 6 years ago
parent
commit
aeb0c650a1
28 changed files with 979 additions and 138 deletions
  1. 1 1
      src/app/dynaform/components/_abstract/custom-input.component.ts
  2. 4 3
      src/app/dynaform/components/custom/checkbutton/checkbutton.component.ts
  3. 8 0
      src/app/dynaform/components/custom/multiline/multiline.component.html
  4. 0 0
      src/app/dynaform/components/custom/multiline/multiline.component.scss
  5. 25 0
      src/app/dynaform/components/custom/multiline/multiline.component.spec.ts
  6. 50 0
      src/app/dynaform/components/custom/multiline/multiline.component.ts
  7. 1 1
      src/app/dynaform/components/group/checkbutton-group/checkbutton-group.component.ts
  8. 14 13
      src/app/dynaform/components/index.ts
  9. 0 1
      src/app/dynaform/components/kendo/timepicker/timepicker.component.html
  10. 1 2
      src/app/dynaform/components/kendo/timepicker/timepicker.component.ts
  11. 1 2
      src/app/dynaform/components/native/select/select.component.html
  12. 11 0
      src/app/dynaform/components/native/select/select.component.ts
  13. 1 0
      src/app/dynaform/components/nocontrol/heading/heading.component.html
  14. 0 0
      src/app/dynaform/components/nocontrol/heading/heading.component.scss
  15. 25 0
      src/app/dynaform/components/nocontrol/heading/heading.component.spec.ts
  16. 21 0
      src/app/dynaform/components/nocontrol/heading/heading.component.ts
  17. 14 4
      src/app/dynaform/dynaform.component.html
  18. 4 4
      src/app/dynaform/dynaform.component.ts
  19. 5 5
      src/app/dynaform/dynaform.module.ts
  20. 1 1
      src/app/dynaform/index.ts
  21. 61 21
      src/app/dynaform/models/field.model.ts
  22. 248 7
      src/app/dynaform/services/_formdata-utils.ts
  23. 63 10
      src/app/dynaform/services/dynaform.service.ts
  24. 128 59
      src/app/dynaform/services/model-mapper.service.ts
  25. 13 3
      src/app/dynaform/testdata/testset.1.ts
  26. 139 0
      src/app/dynaform/testdata/testset.3.ts
  27. 124 0
      src/app/dynaform/testdata/testset.4.ts
  28. 16 1
      src/app/dynaform/utils.ts

+ 1 - 1
src/app/dynaform/components/_abstract/custom-input.component.ts

@@ -5,7 +5,7 @@ export abstract class CustomInputComponent extends NativeInputComponent implemen
 
 	propagateChange = (_: any) => {};
 
-	public writeValue(value: any): void {}
+	public writeValue(value: any): void {};
 
 	public registerOnChange(fn: any): void {
 		this.propagateChange = fn;

+ 4 - 3
src/app/dynaform/components/custom/checkbutton/checkbutton.component.ts

@@ -16,12 +16,13 @@ import { CustomInputComponent } from './../../_abstract/custom-input.component';
 })
 export class CheckbuttonComponent extends CustomInputComponent implements OnChanges {
 
-	exposeMetaInTemplate: string[] = ['label', 'value', 'isDisabled'];
+	exposeMetaInTemplate: string[] = ['label', 'value', 'isDisabled', 'checkedValue'];
 
+	value?: string | boolean = true;
 	isChecked: boolean;
 	isDisabled = false;
-	value?: string | boolean = true;
 	currentValue: string | boolean;
+	checkedValue: string | boolean = true;
 
 	ngOnChanges() {
 		this.isDisabled = this.meta.isDisabled;
@@ -32,7 +33,7 @@ export class CheckbuttonComponent extends CustomInputComponent implements OnChan
 		e.preventDefault();
 		if (this.isDisabled) { return; }
 		this.isChecked = !this.isChecked;
-		this.currentValue = this.isChecked ? this.value : false;
+		this.currentValue = this.isChecked ? this.value || this.checkedValue : false;
 		this.propagateChange(this.currentValue);
 	}
 

+ 8 - 0
src/app/dynaform/components/custom/multiline/multiline.component.html

@@ -0,0 +1,8 @@
+<ng-container *ngFor="let line of linesArr; let i = index;  trackBy: trackByFn">
+	<input type="text"
+		class="form-control form-control-sm"
+		[(ngModel)]="linesArr[i]"
+		[maxlength]="maxLineLength"
+		(blur)="updateValue()"
+	>
+</ng-container>

+ 0 - 0
src/app/dynaform/components/custom/multiline/multiline.component.scss


+ 25 - 0
src/app/dynaform/components/custom/multiline/multiline.component.spec.ts

@@ -0,0 +1,25 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { MultilineComponent } from '@modules/dynaform/components/custom/multiline/multiline.component';
+
+describe('MultilineComponent', () => {
+  let component: MultilineComponent;
+  let fixture: ComponentFixture<MultilineComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ MultilineComponent ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(MultilineComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should be created', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 50 - 0
src/app/dynaform/components/custom/multiline/multiline.component.ts

@@ -0,0 +1,50 @@
+import { Component, forwardRef } from '@angular/core';
+import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { CustomInputComponent } from '@modules/dynaform/components/_abstract/custom-input.component';
+
+@Component({
+	selector: 'app-multiline',
+	templateUrl: './multiline.component.html',
+	styleUrls: ['./multiline.component.scss'],
+	providers: [
+		{
+			provide: NG_VALUE_ACCESSOR,
+			useExisting: forwardRef(() => MultilineComponent),
+			multi: true
+		}
+	]
+})
+export class MultilineComponent extends CustomInputComponent {
+
+	exposeMetaInTemplate: string[] = ['maxLineLength'];
+	linesArr: string[];
+	value: string;
+
+	public writeValue(value: any): void {
+		this.value = value;
+		this.splitIntoLines(value, this.meta.lines || 5);
+	}
+
+	private updateValue(): void {
+		this.value = this.recombineLines(this.linesArr);
+		this.propagateChange(this.value);
+	}
+	
+	private splitIntoLines(value: string, numLines: number): void {
+		this.linesArr = this.control.value.split(/\n/);
+		if (this.linesArr.length < numLines) {
+			const shortfall = numLines - this.linesArr.length;
+			this.linesArr = [...this.linesArr, ...Array(shortfall).fill('')];
+		} else {
+			this.linesArr = this.linesArr.slice(0, numLines);
+		}
+	}
+	
+	private recombineLines = (linesArr: string[]): string => {
+		return linesArr.join("\n");
+	}
+
+	private trackByFn(index: any, item: any) {
+		return index;
+	}
+}

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

@@ -39,7 +39,7 @@ export class CheckbuttonGroupComponent extends GroupInputComponent implements On
 					// 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.childMetaArray[i] = {
-						...this.childMetaArray[1],
+						...this.childMetaArray[i],
 						isDisabled: !val
 					};
 				}

+ 14 - 13
src/app/dynaform/components/index.ts

@@ -2,16 +2,17 @@
 // See https://basarat.gitbooks.io/typescript/docs/tips/barrel.html
 
 // export { DynaformComponent } from './../dynaform.component';
-export { TextComponent } from './native/text/text.component';
-export { TextareaComponent } from './native/textarea/textarea.component';
-export { PasswordComponent } from './native/password/password.component';
-export { SelectComponent } from './native/select/select.component';
-export { RadioComponent } from './native/radio/radio.component';
-export { HiddenComponent } from './native/hidden/hidden.component';
-export { DropdownModifiedInputComponent } from './custom/dropdown-modified-input/dropdown-modified-input.component';
-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';;
-
+export { TextComponent } from '@modules/dynaform/components/native/text/text.component';
+export { TextareaComponent } from '@modules/dynaform/components/native/textarea/textarea.component';
+export { PasswordComponent } from '@modules/dynaform/components/native/password/password.component';
+export { SelectComponent } from '@modules/dynaform/components/native/select/select.component';
+export { RadioComponent } from '@modules/dynaform/components/native/radio/radio.component';
+export { HiddenComponent } from '@modules/dynaform/components/native/hidden/hidden.component';
+export { CheckbuttonComponent } from '@modules/dynaform/components/custom/checkbutton/checkbutton.component';
+export { DropdownModifiedInputComponent } from '@modules/dynaform/components/custom/dropdown-modified-input/dropdown-modified-input.component';
+export { MultilineComponent } from '@modules/dynaform/components/custom/multiline/multiline.component';
+export { CheckbuttonGroupComponent } from '@modules/dynaform/components/group/checkbutton-group/checkbutton-group.component';
+export { TimepickerComponent } from '@modules/dynaform/components/kendo/timepicker/timepicker.component';
+export { DatepickerComponent } from '@modules/dynaform/components/kendo/datepicker/datepicker.component';
+export { ButtonGroupComponent } from '@modules/dynaform/components/nocontrol/button-group/button-group.component';
+export { HeadingComponent } from '@modules/dynaform/components/nocontrol/heading/heading.component';

+ 0 - 1
src/app/dynaform/components/kendo/timepicker/timepicker.component.html

@@ -2,7 +2,6 @@
 	[formControl]="control"
 	[format]="format"
 	[steps]="steps"
-	[value]="value"
 	[placeholder]="placeholder"
 	>
 </kendo-timepicker>

+ 1 - 2
src/app/dynaform/components/kendo/timepicker/timepicker.component.ts

@@ -8,6 +8,5 @@ import { NativeInputComponent } from '../../_abstract/native-input.component';
 })
 export class TimepickerComponent extends NativeInputComponent {
 
-	exposeMetaInTemplate: string[] = ['format', 'steps', 'value', 'placeholder'];
-
+	exposeMetaInTemplate: string[] = ['format', 'steps', 'placeholder'];
 }

+ 1 - 2
src/app/dynaform/components/native/select/select.component.html

@@ -8,8 +8,7 @@
 			<option *ngFor="let opt of options" [value]="opt.value">{{ opt.label }}</option>
 		</select>
 		<div class="input-group-append">
-			<button class="btn btn-outline-primary" type="button"
-				[routerLink]="[ link.route, field.value ]">{{ link.label || 'Details' }}</button>
+			<button class="btn btn-outline-primary" type="button" (click)="navigate(field)">{{ link.label || 'Details' }}</button>
 		</div>
 	</div>
 </ng-template>

+ 11 - 0
src/app/dynaform/components/native/select/select.component.ts

@@ -1,5 +1,6 @@
 import { Component } from '@angular/core';
 import { NativeInputComponent } from '../../_abstract/native-input.component';
+import { Router, ActivatedRoute } from '@angular/router';
 
 @Component({
 	selector: 'app-select',
@@ -10,4 +11,14 @@ export class SelectComponent extends NativeInputComponent {
 
 	exposeMetaInTemplate: string[] = ['options', 'link'];
 
+	constructor(private router: Router, private route: ActivatedRoute) {
+		super();
+	}
+	
+	navigate(field: HTMLSelectElement) {
+		const base = Array.isArray(this.meta.link.route) ? this.meta.link.route : [this.meta.link.route];
+		const destination = [...base, field[field.selectedIndex].value];
+		this.router.navigate(destination, { relativeTo: this.route });
+	}
+
 }

+ 1 - 0
src/app/dynaform/components/nocontrol/heading/heading.component.html

@@ -0,0 +1 @@
+<h3 class="col-sm-12 h-dyna" [ngClass]="'h-dyna-' + (level)">{{ text }}</h3>

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


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

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

+ 21 - 0
src/app/dynaform/components/nocontrol/heading/heading.component.ts

@@ -0,0 +1,21 @@
+import { Component, Input, OnInit } from '@angular/core';
+
+@Component({
+	selector: 'app-heading',
+	templateUrl: './heading.component.html',
+	styleUrls: ['./heading.component.scss']
+})
+export class HeadingComponent implements OnInit {
+
+	@Input()
+	meta: StringMap;
+
+	text: string;
+	level: number;
+
+	ngOnInit() {
+		this.text = this.meta.text;
+		this.level = this.meta.level || 2;
+	}
+
+}

+ 14 - 4
src/app/dynaform/dynaform.component.html

@@ -8,9 +8,11 @@
 <ng-template #default let-control="control" let-meta="meta">
 	<ng-container *ngIf="meta.type !== 'Hidden'">
 
-		<div class="form-group row" *ngIf="meta.type !== 'Container'; else recursiveDynaform" [ngClass]="getRowClass(control, meta.type)">
+		<ng-container *ngIf="!meta.noLabel; else fullWidth">
+
+			<div class="form-group row" *ngIf="meta.type !== 'Container'; else recursiveDynaform" [ngClass]="getRowClass(control, meta.type)">
 				<label class="col-sm-4" [for]="meta.name" title="">
-					{{ meta.label }}
+					{{ meta.rowLabel !== mull ? meta.rowLabel : meta.label }}
 					<span *ngIf="control && control.touched && control.invalid" class="fas fa-exclamation-triangle"
 						[ngbTooltip]="getValidationFailureMessage(control, meta)"
 					></span>
@@ -21,11 +23,19 @@
 			</div>
 			
 			<ng-template #recursiveDynaform>
-				<div class="row pt-3">
-					<h3 class="col-sm-12" [ngClass]="'h-dyna-' + (path.length + 2)">{{ meta.label }}</h3>
+				<div *ngIf="meta.label" class="row">
+					<h3 class="col-sm-12 h-dyna" [ngClass]="'h-dyna-' + (path.length + 2)">{{ meta.label }}</h3>
 				</div>
 				<app-dynaform [formGroup]="control" [meta]="meta.meta" [template]="template" (call)="handleCallback($event)"></app-dynaform>
 			</ng-template>
+
+		</ng-container>
+
+		<ng-template #fullWidth>
+			<div class="row" [ngClass]="getRowClass(control, meta.type)">
+				<ng-container dynafield [control]="control" [meta]="meta" (call)="handleCallback($event)"></ng-container>
+			</div>
+		</ng-template>
 		
 	</ng-container>
 </ng-template>

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

@@ -48,7 +48,7 @@ export class DynaformComponent implements OnInit {
 	template?: TemplateRef<any>;
 
 	@Input()
-	debug = true;
+	debug = false;
 
 	@Output()
 	call: EventEmitter<string> = new EventEmitter<string>();
@@ -69,7 +69,7 @@ export class DynaformComponent implements OnInit {
 	ngOnInit() {
 		// Get the formGroup from the formGroupName if necessary
 		if (!this.formGroup && this.formGroupName) {
-			this.formGroup = <FormGroup>this.cc.control; // Get theFormGroup from the inhected ControlContainer
+			this.formGroup = <FormGroup>this.cc.control; // Get theFormGroup from the injected ControlContainer
 		}
 		if (!this.formGroup) {
 			throw new Error('Dynaform Component initialised without [formGroup] or formGroupName');
@@ -122,7 +122,7 @@ export class DynaformComponent implements OnInit {
 		}
 		return path;
 	}
-	
+
 	getTemplateContext(controlName: string): DynarowContext {
 		return {
 			control: this.formGroup.get(controlName),
@@ -146,7 +146,7 @@ export class DynaformComponent implements OnInit {
 	getValidationErrors() {
 		if (!this.formGroup.valid) {
 			const errorsFlat = SuperForm.getAllErrorsFlat(this.formGroup);
-			return errorsFlat;		
+			return errorsFlat;
 		}
 		return 'No Errors';
 	}

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

@@ -5,12 +5,12 @@ import { RouterModule } from '@angular/router';
 
 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 { DynaformComponent } from '@modules/dynaform/dynaform.component';
+import { DynafieldDirective } from '@modules/dynaform/directives/dynafield.directive';
+import { DynaformService } from '@modules/dynaform/services/dynaform.service';
+import { ModelMapperService } from '@modules/dynaform/services/model-mapper.service';
 
-import * as formFieldComponents from './components';
+import * as formFieldComponents from '@modules/dynaform/components';
 const ffcArr = Object.values(formFieldComponents); // Array of all the Form Field Components
 
 import { DateInputsModule } from '@progress/kendo-angular-dateinputs';

+ 1 - 1
src/app/dynaform/index.ts

@@ -2,5 +2,5 @@ export { DynaformComponent } from './dynaform.component';
 // export { DynafieldDirective } from './directives/dynafield.directive';
 export { DynaformService } from './services/dynaform.service';
 export { ModelMapperService } from './services/model-mapper.service';
-export { standardModifiers, standardTransformer, arrayToMeta } from './utils';
+export { standardModifiers, standardTransformer, toArrTag, arrayPad, arrayToMeta, excludeFields } from './utils';
 

+ 61 - 21
src/app/dynaform/models/field.model.ts

@@ -6,12 +6,12 @@
 
 import { TemplateRef } from '@angular/core'; 
 import { ValidatorFn, AsyncValidatorFn } from '@angular/forms';
-import { ValueTransformer } from './../interfaces';
-import { standardModifiers, standardTransformer } from './../utils';
+import { ValueTransformer } from '@modules/dynaform/interfaces';
+import { standardModifiers, standardTransformer } from '@modules/dynaform/utils';
 
 interface SimpleFieldMetaData {
 	name: string; 								// The FormControl name
-	origin?: string;							// Location in API-returned model - defaults to name
+	source?: string;							// Location in API-returned model - defaults to name
 	type?: string; 								// The component type e.g. BasicInput, Checkbutton, Timepicker, etc
 	label?: string;								// The field label - defaults to unCamelCased name if not supplied
 	value?: any;								// The field value - defaults to empty string if not supplied
@@ -19,6 +19,8 @@ interface SimpleFieldMetaData {
 	placeholder?: string;						// Optional placeholder text
 	class?: string | Array<string>;				// CSS classes to apply
 	id?: string;								// CSS id to apply
+	before?: string;							// Ordering instruction - move before <name of another key in group>
+	after?: string;								// Ordering instruction - move after <name of another key in group>
 	isDisabled?: boolean;						// Whether field is initially disabled
 	validators?: Array<ValidatorFn>;			// Array of validator functions - following Angular FormControl API
 	asyncValidators?: Array<AsyncValidatorFn>;	// Array of async validator functions - following Angular FormControl API
@@ -47,8 +49,7 @@ interface DropdownModifiedInputFieldMetaData extends SimpleFieldMetaData {
 }
 
 interface TimePickerFieldMetaData extends SimpleFieldMetaData {
-	// TODO: Tighhten up on types
-	value: Date;
+	value: Date | string;
 	format: string;
 	steps: StringMap;
 }
@@ -56,7 +57,10 @@ interface TimePickerFieldMetaData extends SimpleFieldMetaData {
 // Utility to unCamelCase
 const unCamelCase = (str: string): string => str.replace(/([A-Z])/g, ' $1')
 												.replace(/(\w)(\d)/g, '$1 $2')
-												.replace(/^./, s => s.toUpperCase());
+												.replace(/^./, s => s.toUpperCase())
+												.replace(/([A-Z]) /g, '$1')
+												// .replace(/ ([A-Z][a-z])/g, s => s.toLowerCase()) lowercase all but first word and acronyms
+												;
 
 // ---------------------------------------------------------------------------------------------------------------------
 // Form Field MetaData Models
@@ -66,7 +70,7 @@ const unCamelCase = (str: string): string => str.replace(/([A-Z])/g, ' $1')
 abstract class SimpleField {
 	type: string;
 	name: string;
-	origin?: string;
+	source?: string;
 	label?: string;
 	value;
 	default = '';
@@ -79,12 +83,15 @@ abstract class SimpleField {
 	valFailureMsgs: StringMap = {};
 
 	constructor(meta: SimpleFieldMetaData) {
+		if (meta.type === 'Multiline') {
+			console.log(meta);
+		}
 		Object.assign(this, meta);
-		if (!this.origin) {
-			// If origin is not supplied it's the same as the name
-			this.origin = this.name;
+		if (!this.source) {
+			// If source is not supplied it's the same as the name
+			this.source = this.name;
 		}
-		if (!this.label) {
+		if (typeof this.label === 'undefined') {
 			// If label is not supplied set it to the unCamelCased'n'Spaced name
 			// e.g. supervisorCardNumber --> Supervisor Card Number
 			this.label = unCamelCase(this.name);
@@ -162,6 +169,9 @@ class HiddenField extends SimpleField {
 class CheckbuttonField extends SimpleField {
 	type = 'Checkbutton';
 	default: any = false;
+	isChecked: boolean;
+	checkedValue: boolean | string = true;
+	rowLabel: null;
 }
 
 class DropdownModifiedInputField extends SimpleField {
@@ -173,6 +183,12 @@ class DropdownModifiedInputField extends SimpleField {
 	}
 }
 
+class MultilineField extends SimpleField {
+	type = 'Multiline';
+	lines: number;
+	maxLineLength: number;
+}
+
 // ---------------------------------------------------------------------------------------------------------------------
 // Concrete Implementations - Custom FormGroup Components (which render a group of FormControls)
 
@@ -181,12 +197,12 @@ class CheckbuttonGroup {
 	name: string;
 	label?: string;
 	groupName: string;
-	firstEnablesRest?;
-	showAllOrNone?;
+	firstEnablesRest?: boolean;
+	showAllOrNone?: boolean;
 	meta: CheckbuttonField[] | { [key: string]: CheckbuttonField };
 	constructor(groupmeta: any) {
 		Object.assign(this, groupmeta);
-		if (!this.label) {
+		if (typeof this.label === 'undefined') {
 			// If label is not supplied set it to the unCamelCased'n'Spaced name
 			// e.g. supervisorCardNumber --> Supervisor Card Number
 			this.label = unCamelCase(this.name);
@@ -209,9 +225,20 @@ class CheckbuttonGroup {
 
 class TimepickerField<TimePickerFieldMetaData> extends SimpleField {
 	type = 'Timepicker';
-	value: Date = new Date();
+	value: Date | string;
 	format = 'hh:mm a';
 	steps = { hour: 1, minute: 15 };
+	constructor(meta) {
+		super(meta);
+		if (typeof this.value === 'string') {
+			const [hh, mm, ss] = this.value.split(':');
+			this.value = new Date(2000, 6, 1, +hh | 0, +mm | 0, +ss | 0);
+		}
+		if (!(this.value instanceof Date)) {
+			this.value = new Date();
+		}
+	}
+
 }
 
 class DatepickerField extends SimpleField {
@@ -228,10 +255,9 @@ class Container {
 	label = '';
 	template?: TemplateRef<any>;
 	meta: StringMap; // TODO: Tighten up on type with union type
-	constructor(meta: StringMap) {
-		console.log(meta);
-		meta.meta ? Object.assign(this, meta) : this.meta = meta;
-		if (!this.label) {
+	constructor(containerMeta: StringMap) {
+		Object.assign(this, containerMeta);
+		if (typeof this.label === 'undefined') {
 			this.label = unCamelCase(this.name);
 		}
 	}
@@ -268,6 +294,20 @@ class ButtonGroup {
 	}
 }
 
+// ---------------------------------------------------------------------------------------------------------------------
+// Heading
+
+class Heading {
+	type = 'Heading';
+	text: string = 'Missing Heading Text';
+	level: number = 3;
+	readonly noFormControls = true; // Indicates this has no FormControls associated with it
+	readonly noLabel = true; // Indicates this has no label, so don't create normal form row
+	constructor(meta) {
+		Object.assign(this, meta);
+	}
+}
+
 // ---------------------------------------------------------------------------------------------------------------------
 // ---------------------------------------------------------------------------------------------------------------------
 // ---------------------------------------------------------------------------------------------------------------------
@@ -276,9 +316,9 @@ class ButtonGroup {
 export {
 	SimpleField,
 	TextField, TextareaField, PasswordField, SelectField, RadioField, HiddenField,
-	CheckbuttonField, DropdownModifiedInputField,
+	CheckbuttonField, DropdownModifiedInputField, MultilineField,
 	CheckbuttonGroup,
 	TimepickerField, DatepickerField,
-	Container, ButtonGroup
+	Container, ButtonGroup, Heading
 };
 

+ 248 - 7
src/app/dynaform/services/_formdata-utils.ts

@@ -6,19 +6,22 @@
  * autoMeta(model) - generate basic metadata from a raw or mapped model, recursively if necessary
  * combineExtraMeta(metadata, extraMeta) - combine extra metadata into metatdata, lazyly and recursively
  * combineModelWithMeta(model, extraMeta) - automatically generate metadata from model then combine extra metadata
+ * execMetaReorderingInstructions(meta) - reorder metadata id ant 'before' or 'after' instructions are found
  * buildFieldSpecificMeta(metadata) - use field-type-specific metadata models to expand metadata
+ * extractFieldMappings(metadata) - extrtact mappings from metadata 'source' attributes
  * buildFormGroupFunctionFactory(fb) - return a function to buildFormGroups using the supplied FormBuilder singleton
+ * generateNewModel(originalModel, updates) - returns an updated copy of the model
  *
  * Variable names
  * --------------
  * metaF   = metadata for Field
  * metaG   = metadata for Group (possibly nested)
- * metaFoG = metadata for Field Or Group
+ * metaFoG = metadata for Field or Group
  *
  */
 
 import { FormBuilder, FormGroup, FormArray, FormControl, ValidatorFn, AsyncValidatorFn } from '@angular/forms';
-import { reduce } from 'lodash/fp';
+import { reduce, cloneDeep } from 'lodash/fp';
 import * as fmdModels from '../models/field.model';
 
 // REMINDER: Import this directly from @angular/forms when we upgrade to Angular 6
@@ -49,7 +52,8 @@ const keyValPairToMetaRecursive = ( [key, val] ) => {
 const arrayMemberAutoName = val => isScalar(val) ? val : val.__typename || val.constructor.name;
 const arrayToMeta = array => array.map(val => ({ name: arrayMemberAutoName(val), 'value' : val }));
 
-const autoMeta = model =>  Object.entries(model)
+const autoMeta = model => Object.entries(model)
+	.filter(kvPair => kvPair[0] !== '__')
 	.map(keyValPairToMetaRecursive)
 	.reduce((res, [key, val]) => addProp(res, key, val), {});
 
@@ -64,7 +68,7 @@ const combineExtraMeta = (metaG, extraMeta, createFromExtra = false) => {
 	Object.entries(extraMeta).forEach(([key, val]) => {
 		if (typeof metaG[key] === 'object' || createFromExtra) {
 			combinedMeta[key] = metaG[key] && (<any>val).meta ?
-				combineMetaForField(metaG[key], { meta: combineExtraMeta(metaG[key].meta, (<any>val).meta, createFromExtra) }) :
+				combineMetaForField(metaG[key], { ...val, meta: combineExtraMeta(metaG[key].meta, (<any>val).meta, createFromExtra) }) :
 				combineMetaForField(metaG[key] || {}, val);
 		}
 	});
@@ -81,7 +85,7 @@ const combineModelWithMeta = (model, extraMeta, createFromExtra = false) => comb
 
 const buildFieldClassName = (t = 'text') => {
 	const start = t[0].toUpperCase() + t.slice(1);
-	if (start === 'Container' || t.slice(-5) === 'Group') {
+	if (start === 'Container' || start === 'Heading' || t.slice(-5) === 'Group') {
 		return start;
 	}
 	return start + 'Field';
@@ -111,6 +115,131 @@ const _buildFieldSpecificMeta = metaG => reduce(buildModeledFieldGroupReducerIte
 const buildFieldSpecificMeta = metaG => _buildFieldSpecificMeta(addMissingNames(metaG));
 
 
+// ---------------------------------------------------------------------------------------------------------------------
+// Generate mapping from source attributes
+// (used to grab data from model when using METAFIRST form generation)
+// ---------------------------------------------------------------------------------------------------------------------
+
+// Each container CAN have a datasource instead of the original model
+// SO ... If container source is a functional mapping, store the mapping to get datasource object later
+
+const isAbsPath = path => typeof path === 'string' && path[0] === '/';
+const isRootPath = path => path === '/';
+const processPath = (parentPath, path) => isAbsPath(path) ? path : `${parentPath}.${path}`;
+
+const prependParentPathRecursive = (parentPath: string, obj: StringMap) => {
+	return Object.entries(obj)
+		.map( ([key, mapping] ) => {
+			let mappingRes;
+			switch(typeof mapping) {
+				case 'string':
+					mappingRes = processPath(parentPath, mapping);
+					break;
+				case 'object':
+					if (Array.isArray(mapping)) {
+						// A functional mapping of the form [fn, fn] or [source, fn, fn]
+						if (typeof mapping[0] === 'string') {
+							const source = processPath(parentPath, mapping[0]);
+							mappingRes = [source, ...mapping.slice(1)];
+						} else {
+							const source = processPath(parentPath, mapping);
+							mappingRes = [source, ...mapping];
+						}
+					} else {
+						mappingRes = prependParentPathRecursive(parentPath, mapping)
+					}
+					break;
+			}
+			return [key, mappingRes];
+		})
+		.reduce((acc, [key, val]) => addProp(acc, key, val), {});
+}
+
+const _extractFieldMapping = ( [key, metaFoG] ) => {
+	let source;
+	if (isGroup(metaFoG)) {
+		if (Array.isArray(metaFoG.source)) {
+			source = extractFieldMappings(metaFoG.meta);
+			source.__ = metaFoG.source; // Store the functional mapping (including function executed later to provide container's data)
+		} else {
+			source = extractFieldMappings(metaFoG.meta, metaFoG.source || key);
+		}
+	} else {
+		source = metaFoG.source || key;
+	}
+	return [key, source];
+};
+
+const extractFieldMappings = (metaG, parentPath = '') => Object.entries(metaG)
+	.map(_extractFieldMapping)
+	.reduce((res, [key, mapping]) => {
+		
+		// Work out the path prefix
+		let prefix;
+		if (parentPath) {
+			if (isRootPath(parentPath) || isAbsPath(metaG[key].source) || Array.isArray(parentPath)) {
+				 // If the parentPath is the root of the data structure, or the source is an absolute path or functional datasource,
+				 // then there's no path prefix
+				prefix = ''
+			} else {
+				// Otherwise create a prefix from the parentPath
+				prefix = parentPath ? (parentPath[0] === '/' ? parentPath.slice(1) : parentPath) : '';
+			}
+		}
+
+		// Work out the mapping result
+		let	mappingRes;
+		if (typeof mapping === 'string') {
+
+			// DIRECT MAPPING
+			if (mapping[0] === '/') {
+				mappingRes = mapping.slice(1);
+			} else if (prefix) {
+				mappingRes = `${prefix}.${mapping}`;
+			} else {
+				mappingRes = mapping;
+			}
+
+		} else if (Array.isArray(mapping)) {
+
+			// FUNCTIONAL MAPPING
+			// of the form [fn, fn] or [source, fn, fn]
+			if (prefix) {
+				if (typeof mapping[0] === 'function') {
+					// Mapping of form [fn, fn] with existing parent path
+					mappingRes = [`${prefix}.${key}`, ...mapping];
+				} else if (typeof mapping[0] === 'string') {
+					// Mapping of form [source, fn, fn] with existing parent path
+					const source = mapping[0][0] === '/' ? mapping[0].slice(1) : `${prefix}.${mapping[0]}`;
+					mappingRes = [source, ...mapping.slice(1)];
+				}
+			} else {
+				if (typeof mapping[0] === 'function') {
+					// Mapping of form [fn, fn] with NO parent path
+					mappingRes = [key, ...mapping];
+				} else if (typeof mapping[0] === 'string') {
+					// Mapping of form [source, fn, fn] with NO parent path
+					const source = mapping[0][0] === '/' ? mapping[0].slice(1) : mapping[0];
+					mappingRes = [source, ...mapping.slice(1)];
+				}
+			}
+
+		} else if (typeof mapping === 'object' && prefix && !mapping.__) {
+
+			// CONTAINER with parentPath, and WITHOUT its own functional datasource stored in __
+			// For every contained value recursively prepend the parentPath to give an absolute path
+			mappingRes = prependParentPathRecursive(prefix, mapping);
+
+		} else {
+			
+			mappingRes = mapping;
+
+		}
+		
+		return addProp(res, key, mappingRes);
+	}, {});
+
+
 // ---------------------------------------------------------------------------------------------------------------------
 // Build Form Group Function Factory
 // returns a function to build FormGroups containing FormControls, FormArrays and other FormGroups
@@ -125,7 +254,7 @@ const buildFormGroupFunctionFactory = (fb: FormBuilder): (meta) => FormGroup =>
 	const buildValidators = (metaF): AbstractControlOptions => ({
 		validators: metaF.validators,
 		asyncValidators: metaF.asyncValidators,
-		updateOn: 'change' // blur not working for custom components - maybe use change for custom and blur for text
+		updateOn: metaF.type === 'text' || metaF.type === 'textarea' ? 'blur' : 'change' // blur not working for custom components, so use change for custom and blur for text
 	});
 	const buildFormControl = metaF => new FormControl(buildControlState(metaF), buildValidators(metaF));
 
@@ -152,6 +281,114 @@ const buildFormGroupFunctionFactory = (fb: FormBuilder): (meta) => FormGroup =>
 };
 
 
+// ---------------------------------------------------------------------------------------------------------------------
+// Reordering ( support for before / after instructions in metadata )
+// ---------------------------------------------------------------------------------------------------------------------
+
+// Object slice function
+const slice = (obj, start, end = null) => Object.entries(obj)
+	.slice(start, end !== null ? end : Object.keys(obj).length)
+	.reduce((res, [key, val]) => addProp(res, key, val), {});
+
+const objConcat = (obj, pos, key, val = null) => {
+	const existsAlready = Object.keys(obj).indexOf(key);
+	if (existsAlready) {
+		val = obj[key];
+	}
+	const start  = slice(obj, 0, pos);
+	const finish = slice(obj, pos);
+	delete start[key];
+	delete finish[key];
+	return { ...start, [key]: val, ...finish };
+}
+
+const insertBefore = (obj, beforeKey, key, val = null) => {
+	const targetPosition = Object.keys(obj).indexOf(beforeKey);
+	return objConcat(obj, targetPosition, key, val);
+}
+
+const insertAfter = (obj, afterKey, key, val = null) => {
+	const targetPosition = Object.keys(obj).indexOf(afterKey) + 1;
+	return objConcat(obj, targetPosition, key, val);
+}
+
+// Process reordeing instructions recursively
+const _execMetaReorderingInstructions = (metaG: StringMap) => {
+	let reorderedGroup = { ...metaG };
+	Object.entries(metaG).forEach(([key, metaFoG]) => {
+		if (metaFoG.before) {
+			reorderedGroup = insertBefore(reorderedGroup, metaFoG.before, key);
+		} else if (metaFoG.after) {
+			reorderedGroup = insertAfter(reorderedGroup, metaFoG.after, key);
+		}
+		if (isContainer(metaFoG)) {
+			reorderedGroup[key].meta = execMetaReorderingInstructions(metaFoG.meta);
+		}
+	});
+	return reorderedGroup;
+}
+
+const execMetaReorderingInstructions = (metaG: StringMap) => {
+	return _execMetaReorderingInstructions(cloneDeep(metaG));
+}
+
+
+// ---------------------------------------------------------------------------------------------------------------------
+// Generate new model, without mutating original
+// (used to produce an updated copy of a model when form values are changed - will not create new keys)
+// ---------------------------------------------------------------------------------------------------------------------
+
+const generateNewModel = (originalModel, updates) => {
+	return updateObject(originalModel, updates);
+}
+
+const updateObject = (obj, updates, createAdditionalKeys = false) => {
+	// THIS DOES NOT MUTATE obj, instead returning a new object
+	const shallowClone = { ...obj };
+	Object.entries(updates).forEach(([key, val]) => safeSet(shallowClone, key, val, createAdditionalKeys));
+	return shallowClone;
+};
+
+const safeSet = (obj, key, val, createAdditionalKeys = false) => {
+	// THIS MUTATES obj - consider the wisdom of this
+	if (!createAdditionalKeys && !obj.hasOwnProperty(key)) {
+		return;
+	}
+	let currentVal = obj[key];
+	if (val === currentVal) {
+		return;
+	}
+	if (nullOrScaler(currentVal)) {
+		console.log('safeSet nullOrScaler', key, val);
+		obj[key] = val;
+	} else {
+		if (Array.isArray(currentVal)) {
+			if (typeof val === 'object') {
+				// Replace array
+				console.log('safeSet array', key, val);
+				obj[key] = Array.isArray(val) ? val : Object.values(val);
+			} else {
+				// Append to end of existing array? Create a single element array?
+				throw new Error(`safeSet Error: Expected array or object @key ${key} but got scalar
+				Rejected update was ${val}`);
+			}
+		} else if (typeof val === 'object') {
+			// Deep merge
+			obj[key] = updateObject(obj[key], val, createAdditionalKeys);
+		} else {
+			throw new Error(`safeSet Error: Can't deep merge object into scalar @key ${key}
+							Rejected update was ${val}`);
+		}
+	}
+}
+
+const nullOrScaler = val => {
+	if (val === null) return true;
+	const t = typeof val;
+	return t === 'number' || t === 'string' || t === 'boolean';
+}
+
+
 // ---------------------------------------------------------------------------------------------------------------------
 // Helper Functions
 // ---------------------------------------------------------------------------------------------------------------------
@@ -199,4 +436,8 @@ const addMissingFieldSpecificMeta = metaG => Object.entries(metaG)
 // Exports
 // ---------------------------------------------------------------------------------------------------------------------
 
-export { autoMeta, combineModelWithMeta, combineExtraMeta, buildFieldSpecificMeta, buildFormGroupFunctionFactory };
+export { 
+	autoMeta, combineModelWithMeta, combineExtraMeta, execMetaReorderingInstructions,
+	buildFieldSpecificMeta, extractFieldMappings, buildFormGroupFunctionFactory,
+	generateNewModel
+};

+ 63 - 10
src/app/dynaform/services/dynaform.service.ts

@@ -1,5 +1,5 @@
 /* *********************************************************************************************************************
- * Dynaform Service, exposing 10 public methods
+ * Dynaform Service
  * *********************************************************************************************************************
  * 
  * BUILD
@@ -31,6 +31,20 @@
  * build(model, meta, true)	- build by combining model with metadata, creating new fields from metadata points that don't occur in the model
  *
  *
+ * BUILD STRATEGYS
+ * ---------------
+ * 
+ * MODELFIRST - The model determinies the shape of the form
+ * METAFIRST  - The metadata determines the shape of the form
+ * 
+ * Set the build strategy using the setBuildStrategy method e.g.
+ * 
+ * setBuildStrategy('MODELFIRST') - the default
+ * setBuildStrategy('NETAFIRST')
+ * 
+ * then use the build function in the normal way
+ *
+ *
  * REGISTER
  * --------
  * Registers callbacks attached to the form (e.g. to buttons), identified by strings.
@@ -73,10 +87,12 @@
 
 import { Injectable, ComponentRef } from '@angular/core';
 import { FormBuilder, FormGroup } from '@angular/forms';
+import { ModelMapperService } from './model-mapper.service';
+
 import {
-	autoMeta, combineModelWithMeta, combineExtraMeta,
-	buildFieldSpecificMeta, buildFormGroupFunctionFactory
-} from './_formdata-utils';
+	autoMeta, combineModelWithMeta, combineExtraMeta, execMetaReorderingInstructions,
+	buildFieldSpecificMeta, extractFieldMappings, buildFormGroupFunctionFactory, generateNewModel
+} from '@modules/dynaform/services/_formdata-utils';
 
 export interface FormAndMeta {
 	form: FormGroup;
@@ -91,12 +107,26 @@ export interface Callbacks {
 export class DynaformService {
 
 	public buildFormGroup: (meta) => FormGroup;
+	private buildStrategy: 'MODELFIRST' | 'METAFIRST' = 'MODELFIRST'; // Make ENUM type
 	private callbacks: Callbacks = {};
 
-	constructor(private fb: FormBuilder) {
+	private conGreen = 'color: white; background-color: green; font-weight: bold;';
+
+	constructor(private fb: FormBuilder, private modelMapper: ModelMapperService) {
 		this.buildFormGroup = buildFormGroupFunctionFactory(fb);
 	}
 
+	setBuildStrategy(str): void {
+		switch(str.toUpperCase()) {
+			case 'MODELFIRST':	// Build from model, combining optional extra metadata
+			case 'METAFIRST':	// Build from metadata, sourcing values from the model (usindg meta's 'source' attribute)
+				this.buildStrategy = str.toUpperCase();
+				break;
+			default:
+				throw new Error(`DYNAFORM: Unknown Build Strategy ${str} - should be MODELFIRST or METAFIRST`);
+		}
+	}
+	
 	build(model: StringMap, meta = {}, createFromMeta = false): FormAndMeta {
 		// Short name for autoBuildFormGroupAndMeta
 		return this.autoBuildFormGroupAndMeta(model, meta, createFromMeta);
@@ -122,23 +152,46 @@ export class DynaformService {
 		}
 	}
 
+	buildNewModel(originalModel: StringMap, formVal: StringMap, meta: StringMap): StringMap {
+		console.log('%c *** buildNewModel *** ', this.conGreen);
+		const mapping = extractFieldMappings(meta); // Memoize
+		console.dir(mapping);
+		const updates = this.modelMapper.forwardMap(formVal, mapping);
+		console.log('%c *** Updates *** ', this.conGreen);
+		console.dir(updates);
+		return generateNewModel(originalModel, updates);
+	}
+
 	// -----------------------------------------------------------------------------------------------------------------
 	// Convenience methods combining several steps
 
 	autoBuildFormGroupAndMeta(model: StringMap, meta = {}, createFromMeta = false): FormAndMeta {
-		if (Object.keys(model).length === 0) {
+		let _model;
+		if (this.buildStrategy === 'MODELFIRST') {
+			_model = model;
+		} else if (this.buildStrategy === 'METAFIRST') {
+			// Generate mapping from the 'source' attributes in the metadata (compatible with the model-mapper service)
+			// Then grab values from the model
+			const mapping = extractFieldMappings(meta);
+			_model = this.modelMapper.reverseMap(model, mapping, false); // <-- false - excludes bits of model not explicitly referenced in meta
+			console.log('%c *** Mapping and Mapped Model *** ', this.conGreen);
+			console.dir(mapping);
+			console.dir(_model);
+		}
+		if (this.buildStrategy === 'METAFIRST' || Object.keys(model).length === 0) {
 			createFromMeta = true;
 		}
-		const modelWithMeta = this.autoBuildModeledMeta(model, meta, createFromMeta);
+		const fullMeta = this.autoBuildModeledMeta(_model, meta, createFromMeta);
 		return {
-			form: this.buildFormGroup(modelWithMeta),
-			meta: modelWithMeta
+			form: this.buildFormGroup(fullMeta),
+			meta: fullMeta
 		};
 	}
 
 	autoBuildModeledMeta(model: StringMap, meta = {}, createFromMeta = false) {
 		const modelWithMeta = this.combineModelWithMeta(model, meta, createFromMeta);
-		return this.buildFieldSpecificMeta(modelWithMeta);
+		const reorderedMeta = execMetaReorderingInstructions(modelWithMeta);
+		return this.buildFieldSpecificMeta(reorderedMeta);
 	}
 
 	// -----------------------------------------------------------------------------------------------------------------

+ 128 - 59
src/app/dynaform/services/model-mapper.service.ts

@@ -89,7 +89,7 @@ const mapping = {
 
 (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' },
@@ -112,93 +112,139 @@ export class ModelMapperService {
 
 	mapping: StringMap;
 	errors: string[] = [];
-	
+
+	debug = false;
+	private clog = (...args) => this.debug ? console.log(...args) : {}; // Wrapper for console log - silent if debug = false
+
 	constructor() { }
 
 	public setMapping(mapping: StringMap) {
 		this.mapping = mapping;
 	}
-	
-	public forwardMap = (model: StringMap, mapping: StringMap = this.mapping, mapMissing = false, res = {}, stack = []): StringMap => {
+
+	public forwardMap = (
+		model: StringMap,
+		mapping: StringMap = this.mapping,
+		mapMissing = false,
+		res = {},
+		stack = []
+	): StringMap => {
+		// Map the input model onto res using the supplied mapping
 		Object.keys(model).forEach(key => {
-			let mappingPath = _.get(mapping, key, mapMissing ? [...stack, key].join('.') : false);
-			if (mappingPath) {
-				let mappingType = this.resolveMappingType(mappingPath);
+			const absPath = [...stack, key].join('.');
+			const _mapping = _.get(mapping, key, mapMissing ? key : false);
+			if (_mapping) {
+				const mappingType = this.resolveMappingType(_mapping);
 				switch(mappingType) {
 					case 'simple':
-						this.deepSet(res, mappingPath, model[key]);
+						this.deepSet(res, _mapping, model[key]);
 						break;
 					case 'nested':
-						this.forwardMap(model[key], mappingPath, mapMissing, res, [...stack, key]);
+						// Container
+						if (_mapping.__) {
+							// Container with functional mapping (stored in __)
+							// of the form [fn,fn] or [source, fn, fn]
+							this.clog('-------------------------------------------');
+							this.clog('CONTAINER WITH STORED FUNCTIONAL MAPPING', absPath);
+							try {
+								const storedContainerMapping = _mapping.__
+								let targetPath = typeof storedContainerMapping[0] === 'string' ? storedContainerMapping[0] : absPath;
+								if (targetPath[0] === '/') {
+									targetPath = targetPath.slice(1);
+								}
+								this.clog('Target Path', targetPath);
+								const func = storedContainerMapping.find(m => typeof m === 'function');
+								const funcRes = func(model[key]);
+								const fullRes = targetPath ? _.set({}, targetPath, funcRes) : funcRes; // Construct an update object from the model's root
+								this.forwardMap(fullRes, {}, true, res);
+							} catch (e) {
+								this.clog(e);
+								this.errors.push('Error in *container* mapping function at', _mapping, ':::', e.message);
+							}
+							this.clog('-------------------------------------------');
+						} else {
+							this.forwardMap(model[key], _mapping, 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;
+							const result = _mapping.find(m => typeof m === 'function')(model[key]);
+							key = _mapping.length === 3 ? _mapping[0] : key;
 							this.deepSet(res, key, result);
 						} catch (e) {
-							this.errors.push('Error in mapping function at', mappingPath, ':::', e.message);
+							this.errors.push('Error in mapping function at', _mapping, ':::', e.message);
 						}
 						break;
 					default:
-						this.deepSet(res, key, `MAPPING ERROR: Unknown mapping type in mapping at ${key}`);
+						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 => {
+
+	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
 		if (!mapping) {
 			throw new Error('Attempting to use Model Mapper without mapping');
 		}
-		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;
+		const res = {};
+		const modelClone = stack.length ? model : _.cloneDeep(model); // Clone the model unless inside a recursive call
+		Object.keys(mapping).filter(key => key !== '__').forEach(key => { 
+			const dataMapping = mapping[key];
+			const mappingType = this.resolveMappingType(dataMapping);
 			switch(mappingType) {
 				case 'simple':
-					value = _.get(modelClone, mapping[key]);
-					if (typeof value !== 'undefined') {
-						this.deepSet(res, key, value);
+					{
+						// A simple path
+						const value = _.get(modelClone, dataMapping);
+						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);
+					{
+						let alternativeModel = null;
+						if (dataMapping.__) {
+							// Evaluate the functional mapping stored in __ and use the result as an alternative model for the container
+							const absKey = [...stack, key].join('.');
+							alternativeModel = this.evaluateReverseFunctionalMapping(model, absKey, dataMapping.__);
+							this.clog('Setting alternative model for container', alternativeModel);
+						}
+						const value = this.reverseMap(alternativeModel || modelClone, dataMapping, 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);
-						}
+					{
+						// Evaluate the functional mapping stored in dataMapping
+						const absKey = [...stack, key].join('.');
+						const value = this.evaluateReverseFunctionalMapping(model, absKey, dataMapping);
+						this.deepSet(res, key, value);
 					}
 					break;
 				default:
 					this.errors.push(`MAPPING ERROR: Unknown mapping type in mapping at ${key}`);
 			}
 			if (mappingType && mappingType !== 'nested') {
-				pathsToDelete.push(destinationPath);
+				pathsToDelete.push(dataMapping);
 			}
 		});
-	
+
 		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
@@ -213,22 +259,25 @@ export class ModelMapperService {
 			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 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;
+		const t = typeof mappingPath;
 		if (t === 'number' || t === 'string') { // CHECK WHAT HAPPENS with number types
 			mappingType = 'simple';
 		} else if (t === 'object') {
@@ -249,13 +298,33 @@ export class ModelMapperService {
 		}
 		return mappingType;
 	}
-	
+
+	private evaluateReverseFunctionalMapping = (model, absKey, fnMapping) => {
+		this.clog('evaluateReverseFunctionalMapping');
+		this.clog(model, absKey, fnMapping);
+		let arg;
+		let result;
+		const path = typeof fnMapping[0] === 'string' ? fnMapping[0] : absKey;
+		if (path === '/') {
+			arg = model; // '/' indicates use the entire model
+		} else {
+			arg = _.get(model, path.replace(/^\//, ''));
+		}
+		const func = _.findLast(fnMapping, m => typeof m === 'function');
+		try {
+			result = func(arg);
+		} catch(e) {
+			this.errors.push('Error in mapping function at', absKey, ':::', e.message);
+		}
+		return result;
+	}
+
 	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;
+		const currentVal = _.get(obj, mappingPath);
+		const t = typeof currentVal;
 		if (t === 'undefined') {
 			_.set(obj, mappingPath, valueToSet);
 		} else if (t === 'number' || t === 'string' || t === 'boolean') {
@@ -264,7 +333,7 @@ export class ModelMapperService {
 				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');
+				this.errors.push('WARNING: Discarding scalar value at', mappingPath, 'as exisiting non-scalar value would be overwritten');
 			}
 		} else if (t === 'object' && typeof valueToSet === 'object') {
 			// Deep merge
@@ -277,11 +346,11 @@ export class ModelMapperService {
 			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)
+		const cleansedObj = Object.keys(obj)
 			.filter(k => obj[k] !== null && obj[k] !== undefined)  // Remove undefined and null
 			.reduce((newObj, k) => {
 				typeof obj[k] === 'object' ?
@@ -296,7 +365,7 @@ export class ModelMapperService {
 		});
 		return cleansedObj;
 	}
-	
+
 	private sortByPathDepth = pathArr => _.sortBy(pathArr, p => p.split('.').length);
 
 	private clearErrors() {

+ 13 - 3
src/app/dynaform/testdata/testset.1.ts

@@ -108,13 +108,22 @@ const checkbuttonGroup = {
 	meta: { iMaskTheOthers: {}, groupMemberTwo: {}, groupMemberThree: {} }
 };
 
-
+const multiline = {
+	type: 'multiline',
+	class: 'centered',
+	value: `This is
+a multiline message
+for all the testers
+in the house`
+}
 
 // ---------------------------------------------------------------------------------------------------------------------
 // Kendo
 
 const timepicker = {
-	type: 'timepicker'
+	type: 'timepicker',
+	// value: new Date(1999, 10, 11, 12, 30, 45)
+	value: "12:45"
 };
 
 const datepicker = {
@@ -166,7 +175,8 @@ const meta = {
 	checkbuttonGroup,
 	timepicker,
 	datepicker,
-	container
+	container,
+	multiline
 };
 
 export { model, meta };

+ 139 - 0
src/app/dynaform/testdata/testset.3.ts

@@ -0,0 +1,139 @@
+// TESTS: Metafirst form generation, with data grabbed from model using 'source'
+
+const model = {
+	fieldOne: 'fieldOne',
+	fieldTwo: 'fieldTwo',
+	fieldThree: 'I am fieldThree',
+	double: '4',
+	nest: {
+		test: 'I am from nest.test',
+		funcMap2: '3'
+	},
+	nestedGroup: {
+		fieldA: 'nestedGroup.fieldA',
+		fieldB: 'nestedGroup.fieldB',
+		fieldC: 'nestedGroup.fieldC',
+		anotherNestedGroup: {
+			fieldE: 'nestedGroup.anotherNestedGroup.fieldE',
+			fieldF: 'nestedGroup.anotherNestedGroup.fieldF',
+			yetAnother: {
+				fieldH: 'nestedGroup.anotherNestedGroup.yesAnother.fieldH',
+				jay: 'I AM DEEPLY NESTED jay'
+			}
+		},
+		z: 'THE END',
+		testforSourcedNest: {
+			soNestA: 'I am soNestA',
+			soNestB: 'And this is soNestB',
+			testC: 'CCC',
+			e: {
+				f: {
+					g: {
+						h: {
+							i: 'DID YOU GO DEEPER?',
+							deeperAgain: {
+								j: 'DEEPEST Jay'
+							}
+						}
+					}
+				}
+			}
+		}
+	},
+	fucntionalSourcedNest: {
+		aaa: '122',
+		bbb: '455',
+		ccc: '788'
+	}
+};
+
+const meta = {
+	a: { label: 'a should be fieldOne', source: 'fieldOne', class: 'medium-field' },
+	b: { label: 'b should end with jay', source: 'nestedGroup.anotherNestedGroup.yetAnother.jay' },
+	functionalMapping: {
+		source: [
+			'double',
+			n => (parseFloat(n) / 2).toString(),
+			n => (parseFloat(n) * 2).toString()
+		]
+	},
+	nest: {
+		label: 'Nested values',
+		meta: {
+			c: { label: 'c should be ...fieldC', source: '/nestedGroup.fieldC' },
+			d: { label: 'd should be THE END', source: '/nestedGroup.z' },
+			e: { label: 'e should be fieldThree', source: '/fieldThree', type: 'checkbutton' },
+			test: { label: 'Test of default source' },
+			funcMap2: {
+				source: [
+					n => (parseFloat(n) / 3).toString(),
+					n => (parseFloat(n) * 3).toString()
+				]
+			}
+		}
+	},
+	head: { type: 'heading', text: 'Default mappings (no \'source\')', before: 'fieldTwo' },
+	fieldTwo: { type: 'textarea' },
+	sourcedNest: {
+		label: 'Nested values from source data tree',
+		source: '/nestedGroup.testforSourcedNest',
+		meta: {
+			testA: { source: 'soNestA' },
+			testB: { source: 'soNestB' },
+			testC: { label: 'should be CCC' },
+			deeper: {
+				source: 'e.f.g.h',
+				meta: {
+					depthRetrivelWithinParentTree: { source: 'i' },
+					deeperAgain: {
+						meta: {
+							veryDeep: { source: 'j', label: 'Should be DEEPEST Jay', class: 'medium-field' }
+						}
+					}
+				}
+			}
+		}
+	},
+	fucntionalSourcedNest: {
+		label: 'Nested values using function as datasource',
+		source: [
+			n => n,
+			n => Object.entries(n)
+				.map( ([key, val]) => [key, parseInt(<string>val) + 1] )
+				.reduce( (acc, [key, val]) => { 
+					acc[<string>key] = val;
+					return acc;
+				}, {})
+		],
+		meta: {
+			aaa: {},
+			bbb: {},
+			ccc: {},
+			ddd: {
+				label: 'Nested functional mapping',
+				source: [
+					'aaa',
+					n => (parseFloat(n) / 2).toString(),
+					n => (parseFloat(n) * 2).toString()
+				]
+			}
+		}
+	},
+	dfns: {
+		label: 'Another functional mapping test, with a nested parent function',
+		meta: {
+			general: {
+				source: [
+					'/',
+					n => n,
+					n => ({ val: 'Zippy Zap', tal: 'Zero' })
+				],
+				meta: {
+					val: { source: 'val' }
+				}
+			}
+		}
+	}
+};
+
+export { model, meta };

+ 124 - 0
src/app/dynaform/testdata/testset.4.ts

@@ -0,0 +1,124 @@
+// TESTS: Metafirst form generation, with data grabbed from model using 'source'
+// test of special source characters
+
+const dumbFlatten = obj => {
+	return Object.assign( {}, ...function _flatten(objectBit) {  
+		return [].concat(                                                       
+			...Object.keys(objectBit).map(                                      
+				key => typeof objectBit[key] === 'object' ? _flatten(objectBit[key]) : { [key]: objectBit[key] }              
+			)
+		)
+	}(obj));
+};
+
+const testReturnImplicitSource = obj => ({
+	bbb: obj.bbb,
+	ccc: obj.ccc
+});
+
+const testReturnRoot = obj => ({
+	a: obj.a,
+	b: obj.b,
+	f: {
+		g: obj.g,
+		h: {
+			i: obj.i
+		}
+	},
+	nest3: {
+		aaa: {
+			bbb: obj.bbb,
+			ccc: obj.ccc
+		}
+	}
+});
+
+const testReturnFH = obj => ({
+	i: obj.i
+});
+  
+const model = {
+	a: 1,
+	b: 2,
+	c: 3,
+	d: 4,
+	e: 5,
+	f: {
+		g: 6,
+		h: {
+			i: 7,
+			j: 8, 
+			k: {
+				l: {
+					m: 'Mmmmm...'
+				}
+			}
+		}
+	},
+	nest3: {
+		aaa: {
+			bbb: 1,
+			ccc: 3
+		}
+	}
+};
+
+const meta = {
+	a: {},
+	nest: {
+		label: 'Nest Test - source /',
+		source: '/',
+		meta: {
+			b: {},
+			c: {},
+			z: { label: 'Z (with no model value)' }
+		}
+	},
+	nest2: {
+		label: 'Nest Test 2 - source f.h',
+		source: 'f.h',
+		meta: {
+			i: {},
+			j: {},
+			g: { source: '/f.g' },
+			nestedNest: {
+				label: 'Nested nest with source reset to /',
+				source: '/',
+				meta: {
+					d: {},
+					fg: { source: 'f.g' }
+				}
+			},
+			nestedNestTwo: {
+				label: 'Nested nest with source reset to /f.h.k.l',
+				source: '/f.h.k.l',
+				meta: {
+					m: {},
+					zzz: {}
+				}
+			}
+		}
+	},
+	nest3: {
+		label: 'Test of nested functional mappings',
+		meta: {
+			aaa: {
+				source: [
+					obj => testReturnImplicitSource(obj),
+					obj => dumbFlatten(obj)
+				],
+				meta: {
+					a: {},
+					b: {},
+					g: {},
+					bbb: {},
+					ccc: {},
+					i: {}
+				}
+			}
+		}
+	}
+}
+
+export { model, meta };
+

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

@@ -35,8 +35,23 @@ const standardTransformer: ValueTransformer = {
 	}
 };
 
+// Generate array from CSV string
+const toArrTag = (str: TemplateStringsArray): string[] => str[0].split(',').map(key => key.trim());
+
+// Pad array
+const arrayPad = (length: number) => (arr: string[]): any[] => [...arr, ...Array(length - arr.length).fill('')];
 
 // 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 };
+// Exclude 'fieldsToExclude' from obj, returning a new object
+// fieldsToExclude can be an array of field keys or a CSV of field keys
+const excludeFields = (obj: StringMap, fieldsToExclude: string | string[]) => {
+	const ex = Array.isArray(fieldsToExclude) ? fieldsToExclude : fieldsToExclude.split(',').map(key => key.trim());
+	return Object.entries(obj).reduce(
+		(res, [key, val]) => ex.includes(key) ? res : { ...res, [key]: val },
+		{}
+	);
+}
+
+export { standardModifiers, standardTransformer, toArrTag, arrayPad, arrayToMeta, excludeFields };