Bläddra i källkod

Syncing Dynaform

Richard Knight 6 år sedan
förälder
incheckning
54f1d0edab

+ 1 - 1
src/app/_mock/testfields.v7.ts

@@ -13,7 +13,7 @@ const testAsyncValidator = (fc: FormControl): Observable<ValidationErrors> => {
 		console.log('Async validator got', fc.value);
 		return { is42: fc.value === '42' };
 	});
-}
+};
 
 const model = {
 	nonValidatedField: '',

+ 23 - 0
src/app/_mock/testfields.v8.ts

@@ -0,0 +1,23 @@
+// TESTS: FormBuilder FormArray's and CheckButtomGroups (with Select All / Select Non option)
+
+/*
+const checkbuttonGroup = {
+	type: 'CheckbuttonGroup',
+	firstEnablesRest: true,
+	meta: [{name: 'iMaskTheOthers'}, {name: 'groupMemberTwo'}, {name: 'groupMemberThree'}]
+};
+*/
+
+const model = {
+	standardField: '',
+	cbFormArray: ['A', 'B', 'C', 'D', 'E']
+};
+
+// Create a CB array for each member of the model
+// But when to render as a FormArray and when as a FormGroup?
+const meta = {
+	standardField: { type: 'textarea' },
+	cbFormArray: { type: 'checkButtonGroup' }
+};
+
+export { model, meta };

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

@@ -9,8 +9,11 @@ import * as test4 from './_mock/testfields.v4';
 import * as test5 from './_mock/testfields.v5';
 import * as test6 from './_mock/testfields.v6';
 import * as test7 from './_mock/testfields.v7';
+import * as test8 from './_mock/testfields.v8';
 
-const testdata = [ test1, test2, test3, test4, test5, test6, test7 ];
+const testdata = [ test1, test2, test3, test4, test5, test6, test7, test8 ];
+
+const defatltTest = 8;
 
 @Component({
 	selector: 'app-root',
@@ -36,7 +39,7 @@ export class AppComponent implements OnInit, OnChanges {
 	ngOnInit() {
 
 		// Optionally supply the test to run in the query string e.g. ?test=3
-		const testcase = parseInt(new URLSearchParams(document.location.search).get('test'), 10) || 2;
+		const testcase = parseInt(new URLSearchParams(document.location.search).get('test'), 10) || defatltTest;
 		const { model, meta } = testdata[testcase - 1];
 
 		console.log('%c *** TEST DATA *** ', this.hCssRed);

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

@@ -7,10 +7,8 @@
      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>
 		</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>
+

+ 3 - 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,7 +29,8 @@ import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
 	],
 	entryComponents: ffcArr,
 	providers: [
-		DynaformService
+		DynaformService,
+		ModelMapperService
 	],
 	exports: [
 		DynaformComponent,

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

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

+ 13 - 5
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);
 	}

src/app/dynaform/services/meta-utils.ts → src/app/dynaform/services/_formdata-utils.ts


+ 2 - 2
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
  *
  *
@@ -58,7 +58,7 @@ import { FormBuilder, FormGroup } from '@angular/forms';
 import {
 	autoMeta, combineModelWithMeta, combineExtraMeta,
 	buildFieldSpecificMeta, buildFormGroupFunctionFactory
-} from './meta-utils';
+} from './_formdata-utils';
 
 export interface FormAndMeta {
 	form: FormGroup;

+ 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 = [];
+	}
+
+}

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

@@ -0,0 +1,167 @@
+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: [{name: 'iMaskTheOthers'}, {name: 'groupMemberTwo'}, {name: '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 checkbuttonGroup2 = {
+	type: 'CheckbuttonGroup',
+	firstEnablesRest: true,
+	meta: [{name: 'One'}, {name: 'Two'}, {name: 'Three'}]
+};
+
+const container = {
+	type: 'Container',
+	meta: {
+		basicTextField2,
+		checkbuttonGroup2,
+	}
+};
+
+// ---------------------------------------------------------------------------------------------------------------------
+
+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 };

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

@@ -0,0 +1,38 @@
+// 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;
+	}
+};
+
+export { standardModifiers, standardTransformer };

+ 8 - 5
src/styles.scss

@@ -9,17 +9,20 @@ h1 {
 	margin-bottom: 1em;
 }
 
+
+.form-group {
+	margin-bottom: 4px;
+}
+
 div.col-sm-8 {
-	border-top: 3px white solid;
-	padding-top: 4px;
+	margin-bottom: 2px;
 }
 
-div.col-sm-4 {
+label.col-sm-4 {
 	background-color: lavenderblush;
-	border-top: 3px white solid;
 	border-left: 15px white solid;
 	padding-top: 4px;
-	margin-bottom: 3px;
+	margin-bottom: 2px;
 }
 
 div.col-sm-8 {