diff --git a/src/coerce-functions.js b/src/coerce-functions.js new file mode 100644 index 00000000..689225fb --- /dev/null +++ b/src/coerce-functions.js @@ -0,0 +1,51 @@ +import * as LogManager from 'aurelia-logging'; + +export const coerceFunctions = { + none(a) { + return a; + }, + number(a) { + const val = Number(a); + return !isNaN(val) && isFinite(val) ? val : 0; + }, + string(a) { + return '' + a; + }, + boolean(a) { + return !!a; + }, + date(val) { + // Invalid date instances are quite problematic + // so we need to deal with it properly by default + if (val === null || val === undefined) { + return null; + } + const d = new Date(val); + return isNaN(d.getTime()) ? null : d; + } +}; + +export const coerceFunctionMap: Map<{new(): any}, string> = new Map([ + [Number, 'number'], + [String, 'string'], + [Boolean, 'boolean'], + [Date, 'date'] +]); + +/** + * Map a class to a string for typescript property coerce + * @param type the property class to register + * @param strType the string that represents class in the lookup + * @param coerceFunction coerce function to register with param strType + */ +export function mapCoerceFunction(type: {new(): any, coerce?: (val: any) => any}, strType: string, coerceFunction: (val: any) => any) { + coerceFunction = coerceFunction || type.coerce; + if (typeof strType !== 'string' || typeof coerceFunction !== 'function') { + LogManager + .getLogger('map-coerce-function') + .warn(`Bad attempt at mapping coerce function for type: ${type.name} to: ${strType}`); + return; + } + coerceFunctions[strType] = coerceFunction; + coerceFunctionMap.set(type, strType); +} \ No newline at end of file diff --git a/src/decorator-observable.js b/src/decorator-observable.js index 71683eaf..7073a73a 100644 --- a/src/decorator-observable.js +++ b/src/decorator-observable.js @@ -1,6 +1,56 @@ -export function observable(targetOrConfig: any, key: string, descriptor?: PropertyDescriptor) { - function deco(target, key, descriptor, config) { // eslint-disable-line no-shadow - // class decorator? +import { coerceFunctions, coerceFunctionMap } from './coerce-functions'; +import { metadata } from 'aurelia-metadata'; +import * as LogManager from 'aurelia-logging'; + +/** + * @typedef ObservableConfig + * @prop {string} name + * @prop {string} changeHandler + * @prop {string | {(val: any): any}} coerce + */ + +const observableLogger = LogManager.getLogger('aurelia-observable-decorator'); + +export function observable(targetOrConfig: string | Function | ObservableConfig, key?: string, descriptor?: PropertyDescriptor) { + /** + * @param target The class decorated + * @param key The target class field of the decorator + * @param descriptor class field descriptor + * @param config user's config + */ + function deco(target: Function, key?: string, descriptor?: PropertyDescriptor & { initializer(): any }, config?: ObservableConfig) { // eslint-disable-line no-shadow + // Used to check if we should pickup the type from metadata + const userDidDefineCoerce = config !== undefined && config.coerce !== undefined; + let propType; + let coerceFunction; + + if (userDidDefineCoerce) { + switch (typeof config.coerce) { + case 'string': + coerceFunction = coerceFunctions[config.coerce]; break; + case 'function': + coerceFunction = config.coerce; break; + default: break; + } + if (coerceFunction === undefined) { + observableLogger.warn(`Invalid coerce instruction. Should be either one of ${Object.keys(coerceFunctions)} or a function.`); + } + } else if (_usePropertyType) { + propType = metadata.getOwn(metadata.propertyType, target, key); + if (propType) { + coerceFunction = coerceFunctions[coerceFunctionMap.get(propType)]; + if (coerceFunction === undefined) { + observableLogger.warn(`Unable to find coerce function for type ${propType.name}.`); + } + } + } + + /** + * class decorator? + * @example + * @observable('firstName') MyClass {} + * @observable({ name: 'firstName' }) MyClass {} + */ const isClassDecorator = key === undefined; if (isClassDecorator) { target = target.prototype; @@ -8,7 +58,7 @@ export function observable(targetOrConfig: any, key: string, descriptor?: Proper } // use a convention to compute the inner property name - let innerPropertyName = `_${key}`; + const innerPropertyName = `_${key}`; const innerPropertyDescriptor: PropertyDescriptor = { configurable: true, enumerable: false, @@ -22,8 +72,10 @@ export function observable(targetOrConfig: any, key: string, descriptor?: Proper // babel passes in the property descriptor with a method to get the initial value. // set the initial value of the property if it is defined. + // also make sure it's coerced if (typeof descriptor.initializer === 'function') { - innerPropertyDescriptor.value = descriptor.initializer(); + const initValue = descriptor.initializer(); + innerPropertyDescriptor.value = coerceFunction === undefined ? initValue : coerceFunction(initValue); } } else { // there is no descriptor if the target was a field in TS (although Babel provides one), @@ -48,16 +100,17 @@ export function observable(targetOrConfig: any, key: string, descriptor?: Proper descriptor.get = function() { return this[innerPropertyName]; }; descriptor.set = function(newValue) { let oldValue = this[innerPropertyName]; - if (newValue === oldValue) { + let coercedValue = coerceFunction === undefined ? newValue : coerceFunction(newValue); + if (coercedValue === oldValue) { return; } // Add the inner property on the instance and make it nonenumerable. - this[innerPropertyName] = newValue; + this[innerPropertyName] = coercedValue; Reflect.defineProperty(this, innerPropertyName, { enumerable: false }); if (this[callbackName]) { - this[callbackName](newValue, oldValue, key); + this[callbackName](coercedValue, oldValue, key); } }; @@ -72,10 +125,26 @@ export function observable(targetOrConfig: any, key: string, descriptor?: Proper } } + /** + * Decorating with parens + * @example + * @observable MyClass {} <----- this breaks, but will go into this condition + * @observable('firstName') MyClass {} + * @observable({ name: 'firstName' }) MyClass {} + * class MyClass { + * @observable() prop + * } + */ if (key === undefined) { - // parens... return (t, k, d) => deco(t, k, d, targetOrConfig); } + /** + * Decorating on class field + * @example + * class MyClass { + * @observable prop + * } + */ return deco(targetOrConfig, key, descriptor); } @@ -91,3 +160,63 @@ no parens | n/a | n/a class | config | config | target | target */ + +/** + * Internal flag to turn on / off auto pickup property type from metadata + */ +let _usePropertyType = false; + +/** + * Toggle the flag for observable to auto pickup property type from metadata + * The reason is sometimes we may want to use prop type on bindable, but not observable + * and vice versa + */ +observable.usePropertyType = (shouldUsePropType: boolean) => { + _usePropertyType = shouldUsePropType; +}; + +/** + * Decorator: Creates a new observable decorator that can be used for fluent syntax purpose + * @param type the type name that will be assign to observable decorator. `createTypedObservable('point') -> observable.point` + */ +export function createTypeObservable(type: string) { + return observable[type] = function(targetOrConfig: string | Function | ObservableConfig, key?: string, descriptor?: PropertyDescriptor & {initializer():any}) { + if (targetOrConfig === undefined) { + /** + * MyClass { + * @observable.number() num + * } + * + * This will breaks so need to check for proper error + * @observable.number() + * class MyClass {} + */ + return observable({ coerce: type }); + } + if (key === undefined) { + /** + * @observable.number('num') + * class MyClass {} + * + * @observable.number({...}) + * class MyClass + * + * class MyClass { + * @observable.number({...}) + * num + * } + */ + targetOrConfig = typeof targetOrConfig === 'string' ? { name: targetOrConfig } : targetOrConfig; + targetOrConfig.coerce = type; + return observable(targetOrConfig); + } + /** + * class MyClass { + * @observable.number num + * } + */ + return observable({ coerce: type })(targetOrConfig, key, descriptor); + }; +} + +['string', 'number', 'boolean', 'date'].forEach(createTypeObservable); diff --git a/test/decorator-observable.spec.js b/test/decorator-observable.spec.js index 967057cc..626c3681 100644 --- a/test/decorator-observable.spec.js +++ b/test/decorator-observable.spec.js @@ -1,8 +1,10 @@ import './setup'; import {observable} from '../src/decorator-observable.js'; +import {mapCoerceFunction, coerceFunctionMap} from '../src/coerce-functions'; import {decorators} from 'aurelia-metadata'; import {SetterObserver} from '../src/property-observation'; import {Logger} from 'aurelia-logging'; +import {metadata} from 'aurelia-metadata'; describe('observable decorator', () => { const oldValue = 'old'; @@ -247,4 +249,186 @@ describe('observable decorator', () => { expect(instance2.value).toBe(oldValue); }); }); + + describe('coerce', () => { + it('initializes value correctly', () => { + class MyClass { + @observable({ coerce: 'string' }) value = 5 + } + expect(new MyClass()._value).toBe('5'); + }); + + it('coerces value correctly', () => { + @observable({ name: 'value1', coerce: 'boolean' }) + class MyClass { + @observable({ coerce: 'number' }) value2 + } + const instance = new MyClass(); + instance.value1 = 0; + expect(instance.value1).toBe(false); + instance.value2 = '123'; + expect(instance.value2).toBe(123); + }); + + it('warns when using unknown coerce', () => { + spyOn(Logger.prototype, 'warn'); + class MyClass { + @observable({ coerce: 'name' }) prop + } + expect(Logger.prototype.warn).toHaveBeenCalled(); + }); + + describe('with built in fluent syntax', () => { + const cases = [ + { type: 'number', baseValue: '123', satisfy: val => val === 123 }, + { type: 'boolean', baseValue: 1, satisfy: val => val === true }, + { type: 'date', baseValue: '2017-09-26', satisfy: val => val instanceof Date && val.getDate() === 26 && val.getMonth() === 8 && val.getFullYear() === 2017 }, + { type: 'string', baseValue: 123, satisfy: val => val === '123' } + ]; + + it('initializes value correctly', () => { + cases.forEach(test => { + class MyClass { + @observable[test.type] prop = test.baseValue + } + expect(test.satisfy(new MyClass()._prop)).toBe(true); + }); + }); + + it('sets value correctly', () => { + cases.forEach(test => { + class MyClass { + @observable[test.type] prop + } + + const instance = new MyClass(); + instance.prop = test.baseValue; + + expect(test.satisfy(instance.prop)).toBe(true); + }); + }); + + it('works with inheritance', () => { + cases.forEach(test => { + const deco = observable[test.type]; + class MyClassBase { + @deco prop + } + + class MyClass extends MyClassBase {} + + const instance = new MyClass(); + instance.prop = test.baseValue; + + expect(test.satisfy(instance.prop)).toBe(true); + }); + }); + }); + + describe('with property type via metadata', () => { + const cases = [ + { propType: Number, baseValue: '123', satisfy: val => val === 123 }, + { propType: Boolean, baseValue: 1, satisfy: val => val === true }, + { propType: Date, baseValue: '2017-09-26', satisfy: val => val instanceof Date && val.getDate() === 26 && val.getMonth() === 8 && val.getFullYear() === 2017 }, + { propType: String, baseValue: 123, satisfy: val => val === '123' } + ]; + + it('respects the property type flag to intialize value correctly', () => { + observable.usePropertyType(true); + cases.forEach(test => { + class MyClass { + @observable + @Reflect.metadata(metadata.propertyType, test.propType) + prop = test.baseValue + } + expect(test.satisfy(new MyClass()._prop)).toBe(true); + }); + + observable.usePropertyType(false); + cases.forEach(test => { + class MyClass { + @observable + @Reflect.metadata(metadata.propertyType, test.propType) + prop = test.baseValue + } + expect(test.satisfy(new MyClass()._prop)).toBe(false); + }); + }); + + it('respects the property type flag to set value correctly', () => { + observable.usePropertyType(true); + cases.forEach(test => { + class MyClass { + @observable + @Reflect.metadata(metadata.propertyType, test.propType) + prop + } + + const instance = new MyClass(); + instance.prop = test.baseValue; + + expect(test.satisfy(instance.prop)).toBe(true); + }); + + observable.usePropertyType(false); + cases.forEach(test => { + class MyClass { + @observable + @Reflect.metadata(metadata.propertyType, test.propType) + prop + } + + const instance = new MyClass(); + instance.prop = test.baseValue; + + expect(test.satisfy(instance.prop)).toBe(false); + }); + }); + + it('should warn when using unknown property type', () => { + observable.usePropertyType(true); + spyOn(Logger.prototype, 'warn'); + + class MyClass { + @observable + @Reflect.metadata(metadata.propertyType, class PropertyType {}) + prop + } + expect(Logger.prototype.warn).toHaveBeenCalled(); + }); + + it('should not warn when using registered property type', () => { + class PropertyType {} + coerceFunctionMap.set(PropertyType, 'string'); + + observable.usePropertyType(true); + spyOn(Logger.prototype, 'warn'); + + class MyClass { + @observable + @Reflect.metadata(metadata.propertyType, PropertyType) + prop + } + expect(Logger.prototype.warn).not.toHaveBeenCalled(); + }); + + it('works with inheritance when using property type', () => { + cases.forEach(test => { + observable.usePropertyType(true); + class MyClassBase { + @observable + @Reflect.metadata(metadata.propertyType, test.propType) + prop + } + + class MyClass extends MyClassBase {} + + const instance = new MyClass(); + instance.prop = test.baseValue; + + expect(test.satisfy(instance.prop)).toBe(true); + }); + }); + }); + }); });