diff --git a/dist/aurelia-templating-binding.d.ts b/dist/aurelia-templating-binding.d.ts index a0cf2d1..07a86b8 100644 --- a/dist/aurelia-templating-binding.d.ts +++ b/dist/aurelia-templating-binding.d.ts @@ -16,24 +16,25 @@ import { } from 'aurelia-binding'; import { BehaviorInstruction, - BindingLanguage + BindingLanguage, + ViewResources } from 'aurelia-templating'; export declare class AttributeMap { static inject: any; elements: any; allElements: any; constructor(svg?: any); - + /** * Maps a specific HTML element attribute to a javascript property. */ register(elementName?: any, attributeName?: any, propertyName?: any): any; - + /** * Maps an HTML attribute to a javascript property. */ registerUniversal(attributeName?: any, propertyName?: any): any; - + /** * Returns the javascript property name for a particlar HTML attribute. */ @@ -76,12 +77,45 @@ export declare class SyntaxInterpreter { 'two-way'(resources?: any, element?: any, info?: any, existingInstruction?: any): any; 'to-view'(resources?: any, element?: any, info?: any, existingInstruction?: any): any; 'from-view'(resources?: any, element?: any, info?: any, existingInstruction?: any): any; + 'one-way'(resources?: any, element?: any, info?: any, existingInstruction?: any): any; 'one-time'(resources?: any, element?: any, info?: any, existingInstruction?: any): any; } + +export declare class LetExpression { + createBinding(): LetBinding +} + +export declare class LetBinding { + constructor(); + updateSource(): any; + call(context): any; + bind(source?: any): any; + unbind(): any; + connect(): any; +} + +export declare class LetInterpolationBindingExpression { + createBinding(): LetInterpolationBinding +} + +export declare class LetInterpolationBinding { + constructor(); + updateSource(): any; + call(context): any; + bind(source?: any): any; + unbind(): any; + connect(): any; +} + export declare class TemplatingBindingLanguage extends BindingLanguage { static inject: any; constructor(parser?: any, observerLocator?: any, syntaxInterpreter?: any, attributeMap?: any); inspectAttribute(resources?: any, elementName?: any, attrName?: any, attrValue?: any): any; + createLetExpressions( + resources: ViewResources, + letElement: HTMLElement, + existingLetExpressions: (LetExpression | LetInterpolationBindingExpression)[] + ): (LetExpression | LetInterpolationBindingExpression)[] createAttributeInstruction(resources?: any, element?: any, theInfo?: any, existingInstruction?: any, context?: any): any; inspectTextContent(resources?: any, value?: any): any; parseInterpolation(resources?: any, value?: any): any; diff --git a/package.json b/package.json index 89b03b8..df7bbc9 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "type": "git", "url": "http://github.com/aurelia/templating-binding" }, + "scripts": { + "test": "karma start" + }, "jspm": { "registry": "npm", "jspmPackage": true, diff --git a/src/binding-language.js b/src/binding-language.js index 1d4e4c0..72abdc1 100644 --- a/src/binding-language.js +++ b/src/binding-language.js @@ -1,9 +1,12 @@ /*eslint indent:0*/ import {BindingLanguage, BehaviorInstruction} from 'aurelia-templating'; -import {Parser, ObserverLocator, NameExpression, bindingMode} from 'aurelia-binding'; +import {Parser, ObserverLocator, NameExpression, bindingMode, camelCase, LiteralString} from 'aurelia-binding'; import {InterpolationBindingExpression} from './interpolation-binding-expression'; import {SyntaxInterpreter} from './syntax-interpreter'; import {AttributeMap} from './attribute-map'; +import {LetExpression} from './let-expression'; +import {LetInterpolationBindingExpression} from './let-interpolation-expression'; +import * as LogManager from 'aurelia-logging'; let info = {}; @@ -18,6 +21,7 @@ export class TemplatingBindingLanguage extends BindingLanguage { this.emptyStringExpression = this.parser.parse('\'\''); syntaxInterpreter.language = this; this.attributeMap = attributeMap; + this.toBindingContextAttr = 'to-binding-context'; } inspectAttribute(resources, elementName, attrName, attrValue) { @@ -86,6 +90,75 @@ export class TemplatingBindingLanguage extends BindingLanguage { return instruction; } + /** + * @param {ViewResources} resources + * @param {Element} letElement + */ + createLetExpressions(resources, letElement) { + let expressions = []; + let attributes = letElement.attributes; + /**@type {Attr} */ + let attr; + /**@type {string[]} */ + let parts; + let attrName; + let attrValue; + let command; + let toBindingContextAttr = this.toBindingContextAttr; + let toBindingContext = letElement.hasAttribute(toBindingContextAttr); + for (let i = 0, ii = attributes.length; ii > i; ++i) { + attr = attributes[i]; + attrName = attr.name; + attrValue = attr.nodeValue; + parts = attrName.split('.'); + + if (attrName === toBindingContextAttr) { + continue; + } + + if (parts.length === 2) { + command = parts[1]; + if (command !== 'bind') { + LogManager.getLogger('templating-binding-language') + .warn(`Detected invalid let command. Expected "${parts[0]}.bind", given "${attrName}"`); + continue; + } + expressions.push(new LetExpression( + this.observerLocator, + camelCase(parts[0]), + this.parser.parse(attrValue), + resources.lookupFunctions, + toBindingContext + )); + } else { + attrName = camelCase(attrName); + parts = this.parseInterpolation(resources, attrValue); + if (parts === null) { + LogManager.getLogger('templating-binding-language') + .warn(`Detected string literal in let bindings. Did you mean "${ attrName }.bind=${ attrValue }" or "${ attrName }=\${${ attrValue }}" ?`); + } + if (parts) { + expressions.push(new LetInterpolationBindingExpression( + this.observerLocator, + attrName, + parts, + resources.lookupFunctions, + toBindingContext + )); + } else { + expressions.push(new LetExpression( + this.observerLocator, + attrName, + new LiteralString(attrValue), + resources.lookupFunctions, + toBindingContext + )); + } + } + } + return expressions; + } + inspectTextContent(resources, value) { const parts = this.parseInterpolation(resources, value); if (parts === null) { diff --git a/src/let-expression.js b/src/let-expression.js new file mode 100644 index 0000000..0c64834 --- /dev/null +++ b/src/let-expression.js @@ -0,0 +1,112 @@ +import { + connectable, + enqueueBindingConnect, + sourceContext +} from 'aurelia-binding'; + +export class LetExpression { + /** + * @param {ObserverLocator} observerLocator + * @param {string} targetProperty + * @param {Expression} sourceExpression + * @param {any} lookupFunctions + * @param {boolean} toBindingContext indicates let binding result should be assigned to binding context + */ + constructor(observerLocator, targetProperty, sourceExpression, lookupFunctions, toBindingContext) { + this.observerLocator = observerLocator; + this.sourceExpression = sourceExpression; + this.targetProperty = targetProperty; + this.lookupFunctions = lookupFunctions; + this.toBindingContext = toBindingContext; + } + + createBinding() { + return new Let( + this.observerLocator, + this.sourceExpression, + this.targetProperty, + this.lookupFunctions, + this.toBindingContext + ); + } +} + +@connectable() +export class Let { + /** + * @param {ObserverLocator} observerLocator + * @param {Expression} sourceExpression + * @param {Function | Element} target + * @param {string} targetProperty + * @param {*} lookupFunctions + * @param {boolean} toBindingContext indicates let binding result should be assigned to binding context + */ + constructor(observerLocator, sourceExpression, targetProperty, lookupFunctions, toBindingContext) { + this.observerLocator = observerLocator; + this.sourceExpression = sourceExpression; + this.targetProperty = targetProperty; + this.lookupFunctions = lookupFunctions; + this.source = null; + this.target = null; + this.toBindingContext = toBindingContext; + } + + updateTarget() { + const value = this.sourceExpression.evaluate(this.source, this.lookupFunctions); + this.target[this.targetProperty] = value; + } + + call(context) { + if (!this.isBound) { + return; + } + if (context === sourceContext) { + this.updateTarget(); + return; + } + throw new Error(`Unexpected call context ${context}`); + } + + /** + * @param {Scope} source Binding context + */ + bind(source) { + if (this.isBound) { + if (this.source === source) { + return; + } + this.unbind(); + } + + this.isBound = true; + this.source = source; + this.target = this.toBindingContext ? source.bindingContext : source.overrideContext; + + if (this.sourceExpression.bind) { + this.sourceExpression.bind(this, source, this.lookupFunctions); + } + + enqueueBindingConnect(this); + } + + unbind() { + if (!this.isBound) { + return; + } + this.isBound = false; + if (this.sourceExpression.unbind) { + this.sourceExpression.unbind(this, this.source); + } + this.source = null; + this.target = null; + this.unobserve(true); + } + + connect() { + if (!this.isBound) { + return; + } + this.updateTarget(); + this.sourceExpression.connect(this, this.source); + } +} diff --git a/src/let-interpolation-expression.js b/src/let-interpolation-expression.js new file mode 100644 index 0000000..0a211c5 --- /dev/null +++ b/src/let-interpolation-expression.js @@ -0,0 +1,103 @@ +import {bindingMode} from 'aurelia-binding'; +import { + InterpolationBinding, + ChildInterpolationBinding +} from './interpolation-binding-expression'; + +export class LetInterpolationBindingExpression { + /** + * @param {ObserverLocator} observerLocator + * @param {string} targetProperty + * @param {string[]} parts + * @param {Lookups} lookupFunctions + * @param {boolean} toBindingContext indicates let binding result should be assigned to binding context + */ + constructor(observerLocator, targetProperty, parts, lookupFunctions, toBindingContext) { + this.observerLocator = observerLocator; + this.targetProperty = targetProperty; + this.parts = parts; + this.lookupFunctions = lookupFunctions; + this.toBindingContext = toBindingContext; + } + + createBinding() { + return new LetInterpolationBinding( + this.observerLocator, + this.parts, + this.targetProperty, + this.lookupFunctions, + this.toBindingContext + ); + } +} + +export class LetInterpolationBinding { + /** + * @param {ObserverLocator} observerLocator + * @param {strign} targetProperty + * @param {string[]} parts + * @param {Lookups} lookupFunctions + * @param {boolean} toBindingContext indicates let binding result should be assigned to binding context + */ + constructor(observerLocator, targetProperty, parts, lookupFunctions, toBindingContext) { + this.observerLocator = observerLocator; + this.parts = parts; + this.targetProperty = targetProperty; + this.lookupFunctions = lookupFunctions; + this.toBindingContext = toBindingContext; + this.target = null; + } + + /** + * @param {Scope} source + */ + bind(source) { + if (this.isBound) { + if (this.source === source) { + return; + } + this.unbind(); + } + + this.isBound = true; + this.source = source; + this.target = this.toBindingContext ? source.bindingContext : source.overrideContext; + + this.interpolationBinding = this.createInterpolationBinding(); + this.interpolationBinding.bind(source); + } + + unbind() { + if (!this.isBound) { + return; + } + this.isBound = false; + this.source = null; + this.target = null; + this.interpolationBinding.unbind(); + this.interpolationBinding = null; + } + + createInterpolationBinding() { + if (this.parts.length === 3) { + return new ChildInterpolationBinding( + this.target, + this.observerLocator, + this.parts[1], + bindingMode.oneWay, + this.lookupFunctions, + this.targetProperty, + this.parts[0], + this.parts[2] + ); + } + return new InterpolationBinding( + this.observerLocator, + this.parts, + this.target, + this.targetProperty, + bindingMode.oneWay, + this.lookupFunctions + ); + } +} diff --git a/test/binding-language.spec.js b/test/binding-language.spec.js index 219ad0a..96d173b 100644 --- a/test/binding-language.spec.js +++ b/test/binding-language.spec.js @@ -2,11 +2,23 @@ import './setup'; import {TemplatingBindingLanguage} from '../src/binding-language'; import {Container} from 'aurelia-dependency-injection'; import {NameExpression} from 'aurelia-binding'; +import {Logger} from 'aurelia-logging'; + +import { + LetExpression, + Let +} from '../src/let-expression'; + +import { + LetInterpolationBindingExpression, + LetInterpolationBinding +} from '../src/let-interpolation-expression'; describe('BindingLanguage', () => { + /**@type {TemplatingBindingLanguage} */ let language; - beforeAll(() => { + beforeEach(() => { language = new Container().get(TemplatingBindingLanguage); }); @@ -28,4 +40,59 @@ describe('BindingLanguage', () => { expect(expression instanceof NameExpression).toBe(true); expect(expression.lookupFunctions).toBe(resources.lookupFunctions); }); + + describe('createLetExpressions', () => { + let resources; + + beforeEach(() => { + resources = { lookupFunctions: {} }; + }); + + it('works with .bind command', () => { + let el = div(); + el.setAttribute('foo.bind', 'bar'); + const expressions = language.createLetExpressions(resources, el); + expect(expressions[0] instanceof LetExpression).toBe(true); + }); + + it('warns when binding command is not bind', () => { + const loggerSpy = spyOn(Logger.prototype, 'warn').and.callThrough(); + let callCount = 0; + ['one-way', 'two-way', 'one-time', 'from-view'].forEach(cmd => { + let el = div(); + el.setAttribute(`foo.${cmd}`, 'bar'); + language.createLetExpressions(resources, el); + expect(loggerSpy.calls.count()).toBe(++callCount, `It should have had been called ${callCount} times`); + }); + }); + + it('works with interpolation', () => { + let el = div(); + el.setAttribute('foo', '${bar}'); + const expressions = language.createLetExpressions(resources, el); + expect(expressions.length).toBe(1, 'It should have had 1 instruction'); + expect(expressions[0] instanceof LetInterpolationBindingExpression).toBe(true); + }); + + it('creates correct let expressions', () => { + let el = div(); + el.setAttribute('foo', 'bar'); + expect(language.createLetExpressions(resources, el)[0] instanceof LetExpression).toBe(true); + }); + + it('understands to-binding-context', () => { + let el = div(); + el.setAttribute('foo.bind', 'bar'); + el.setAttribute(language.toBindingContextAttr, ''); + + const expressions = language.createLetExpressions(resources, el); + expect(expressions.length).toBe(1, 'It should have not created expression from to-binding-context'); + expect(expressions[0].toBindingContext).toBe(true); + }); + + function div() { + return document.createElement('div'); + } + }); + }); diff --git a/test/let-expression.spec.js b/test/let-expression.spec.js new file mode 100644 index 0000000..bc0d007 --- /dev/null +++ b/test/let-expression.spec.js @@ -0,0 +1,216 @@ +import { + ObserverLocator, + Parser, + createOverrideContext, + createScopeForTest +} from 'aurelia-binding'; + +import {Container} from 'aurelia-dependency-injection'; + +import {TemplatingBindingLanguage} from '../src/binding-language'; +import { + Let, + LetExpression +} from '../src/let-expression'; +import { + LetInterpolationBindingExpression, + LetInterpolationBinding +} from '../src/let-interpolation-expression'; +import { + InterpolationBinding, + ChildInterpolationBinding +} from '../src/interpolation-binding-expression'; + +describe('Let', () => { + /**@type {ObserverLocator} */ + let observerLocator; + /**@type {Parser} */ + let parser; + /**@type {TemplatingBindingLanguage} */ + let language; + let LookupFunctions = {}; + let checkDelay = 40; + + beforeEach(() => { + let ct = new Container(); + language = ct.get(TemplatingBindingLanguage); + observerLocator = ct.get(ObserverLocator); + parser = ct.get(Parser); + }); + + describe('LetExpression', () => { + it('creates binding', () => { + let letExpression = new LetExpression(observerLocator, 'foo', parser.parse('bar'), LookupFunctions); + let binding = letExpression.createBinding(); + expect(binding instanceof Let).toBe(true); + }); + }); + + describe('Let binding', () => { + it('binds to overrideContext', done => { + let vm = { foo: 'bar', baz: { foo: 'baz' } }; + let scope = createScopeForTest(vm); + let binding = new Let(observerLocator, parser.parse('baz.foo'), 'bar', LookupFunctions); + + binding.bind(scope); + + expect(binding.target).toBe(scope.overrideContext); + + expect(scope.overrideContext.bar).toBe('baz'); + + vm.baz.foo = 'bar'; + expect(scope.overrideContext.bar).toBe('baz'); + + setTimeout(() => { + expect(scope.overrideContext.bar).toBe('bar'); + + binding.unbind(); + expect(binding.source).toBe(null); + + done(); + }, checkDelay * 2); + }); + + it('binds to bindingContext', done => { + let vm = { foo: 'bar', baz: { foo: 'baz' } }; + let scope = createScopeForTest(vm); + let binding = new Let(observerLocator, parser.parse('baz.foo'), 'bar', LookupFunctions, true); + + binding.bind(scope); + + expect(binding.target).toBe(scope.bindingContext); + expect(binding.target).toBe(vm); + + expect(scope.bindingContext.bar).toBe('baz'); + + vm.baz.foo = 'bar'; + expect(scope.bindingContext.bar).toBe('baz'); + + setTimeout(() => { + expect(scope.bindingContext.bar).toBe('bar'); + + binding.unbind(); + expect(binding.source).toBe(null); + + done(); + }, checkDelay * 2); + }); + }); + + describe('[interpolation]', () => { + it('gets created correctly', () => { + let letInterExpression = new LetInterpolationBindingExpression(observerLocator, 'foo', ['', 'bar', ''], LookupFunctions); + let letInterBinding = letInterExpression.createBinding(); + + expect(letInterBinding instanceof LetInterpolationBinding).toBe(true); + }); + + describe('ChildInterpolationBinding', () => { + + it('binds to overrideContext', done => { + let vm = { foo: 'bar', baz: { foo: 'baz' } }; + let scope = createScopeForTest(vm); + + let binding = new LetInterpolationBinding(observerLocator, 'bar', ['', parser.parse('baz.foo'), ''], LookupFunctions); + binding.bind(scope); + + expect(binding.target).toBe(scope.overrideContext); + expect(binding.interpolationBinding instanceof ChildInterpolationBinding).toBe(true); + + expect(scope.overrideContext.bar).toBe('baz'); + vm.baz.foo = 'bar'; + expect(scope.overrideContext.bar).toBe('baz'); + + setTimeout(() => { + expect(scope.overrideContext.bar).toBe('bar'); + + binding.unbind(); + expect(binding.interpolationBinding).toBe(null); + expect(binding.target).toBe(null); + + done(); + }, checkDelay * 2); + }); + + it('binds to bindingContext', done => { + let vm = { foo: 'bar', baz: { foo: 'baz' } }; + let scope = createScopeForTest(vm); + + let binding = new LetInterpolationBinding(observerLocator, 'bar', ['', parser.parse('baz.foo'), ''], LookupFunctions, true); + binding.bind(scope); + + expect(binding.target).toBe(scope.bindingContext); + expect(binding.target).toBe(vm); + + expect(binding.interpolationBinding instanceof ChildInterpolationBinding).toBe(true); + expect(scope.bindingContext.bar).toBe('baz'); + vm.baz.foo = 'bar'; + expect(scope.bindingContext.bar).toBe('baz'); + + setTimeout(() => { + expect(scope.bindingContext.bar).toBe('bar'); + + binding.unbind(); + expect(binding.interpolationBinding).toBe(null); + expect(binding.target).toBe(null); + + done(); + }, checkDelay * 2); + }); + }); + + describe('InterpolationBinding', () => { + it('binds to overrideContext', done => { + let vm = { foo: 'bar', baz: { foo: 'baz' } }; + let scope = createScopeForTest(vm); + let binding = new LetInterpolationBinding(observerLocator, 'bar', ['foo is: ', parser.parse('foo'), '. And baz.foo is ', parser.parse('baz.foo'), ''], LookupFunctions); + binding.bind(scope); + + expect(binding.target).toBe(scope.overrideContext); + expect(binding.interpolationBinding instanceof InterpolationBinding).toBe(true); + + expect(scope.overrideContext.bar).toBe('foo is: bar. And baz.foo is baz'); + vm.foo = 'foo'; + expect(scope.overrideContext.bar).toBe('foo is: bar. And baz.foo is baz'); + + setTimeout(() => { + expect(scope.overrideContext.bar).toBe('foo is: foo. And baz.foo is baz'); + + binding.unbind(); + expect(binding.interpolationBinding).toBe(null); + + done(); + }, checkDelay * 2); + }); + + it('binds to bindingContext', done => { + let vm = { foo: 'bar', baz: { foo: 'baz' } }; + let scope = createScopeForTest(vm); + let binding = new LetInterpolationBinding( + observerLocator, + 'bar', + ['foo is: ', parser.parse('foo'), '. And baz.foo is ', parser.parse('baz.foo'), ''], + LookupFunctions, + true + ); + binding.bind(scope); + + expect(binding.target).toBe(vm); + expect(binding.interpolationBinding instanceof InterpolationBinding).toBe(true); + expect(vm.bar).toBe('foo is: bar. And baz.foo is baz'); + vm.foo = 'foo'; + expect(vm.bar).toBe('foo is: bar. And baz.foo is baz'); + + setTimeout(() => { + expect(vm.bar).toBe('foo is: foo. And baz.foo is baz'); + + binding.unbind(); + expect(binding.interpolationBinding).toBe(null); + + done(); + }, checkDelay * 2); + }); + + }); + }); +});