diff --git a/features.json b/features.json index ae8afc23869..c8ff958ddea 100644 --- a/features.json +++ b/features.json @@ -7,6 +7,7 @@ "ember-htmlbars": true, "ember-htmlbars-block-params": true, "ember-htmlbars-component-generation": null, + "ember-htmlbars-component-helper": null, "ember-htmlbars-inline-if-helper": true, "ember-htmlbars-attribute-syntax": null, "ember-routing-transitioning-classes": true diff --git a/packages/ember-htmlbars/lib/helpers/component.js b/packages/ember-htmlbars/lib/helpers/component.js new file mode 100644 index 00000000000..7a5a2f5d4b8 --- /dev/null +++ b/packages/ember-htmlbars/lib/helpers/component.js @@ -0,0 +1,92 @@ +/** +@module ember +@submodule ember-htmlbars +*/ +import Ember from "ember-metal/core"; // Ember.warn, Ember.assert +import { isStream, read } from "ember-metal/streams/utils"; +import { readComponentFactory } from "ember-views/streams/utils"; +import EmberError from "ember-metal/error"; +import BoundComponentView from "ember-views/views/bound_component_view"; +import mergeViewBindings from "ember-htmlbars/system/merge-view-bindings"; +import appendTemplatedView from "ember-htmlbars/system/append-templated-view"; + +/** + The `{{component}}` helper lets you add instances of `Ember.Component` to a + template. See [Ember.Component](/api/classes/Ember.Component.html) for + additional information on how a `Component` functions. + + `{{component}}`'s primary use is for cases where you want to dynamically + change which type of component is rendered as the state of your application + changes. + + The provided block will be applied as the template for the component. + + Given an empty `` the following template: + + ```handlebars + {{! application.hbs }} + {{component infographicComponentName}} + ``` + + And the following application code + + ```javascript + App = Ember.Application.create(); + App.ApplicationController = Ember.Controller.extend({ + infographicComponentName: function(){ + if (this.get('isMarketOpen')) { + return "live-updating-chart"; + } else { + return "market-close-summary"; + } + }.property('isMarketOpen') + }); + ``` + + The `live-updating-chart` component will be appended when `isMarketOpen` is + `true`, and the `market-close-summary` component will be appended when + `isMarketOpen` is `false`. If the value changes while the app is running, + the component will be automatically swapped out accordingly. + + Note: You should not use this helper when you are consistently rendering the same + component. In that case, use standard component syntax, for example: + + ```handlebars + {{! application.hbs }} + {{live-updating-chart}} + ``` + + @method component + @for Ember.Handlebars.helpers +*/ +export function componentHelper(params, hash, options, env) { + Ember.assert( + "The `component` helper expects exactly one argument, plus name/property values.", + params.length === 1 + ); + + var componentNameParam = params[0]; + var container = this.container || read(this._keywords.view).container; + + var props = { + helperName: options.helperName || 'component' + }; + if (options.template) { + props.template = options.template; + } + + var viewClass; + if (isStream(componentNameParam)) { + viewClass = BoundComponentView; + props = { _boundComponentOptions: Ember.merge(hash, props) }; + props._boundComponentOptions.componentNameStream = componentNameParam; + } else { + viewClass = readComponentFactory(componentNameParam, container); + if (!viewClass) { + throw new EmberError('HTMLBars error: Could not find component named "' + componentNameParam + '".'); + } + mergeViewBindings(this, props, hash); + } + + appendTemplatedView(this, options.morph, viewClass, props); +} diff --git a/packages/ember-htmlbars/lib/main.js b/packages/ember-htmlbars/lib/main.js index 9264c8c15bf..cd26ead7f80 100644 --- a/packages/ember-htmlbars/lib/main.js +++ b/packages/ember-htmlbars/lib/main.js @@ -27,6 +27,7 @@ import { default as helpers } from "ember-htmlbars/helpers"; import { viewHelper } from "ember-htmlbars/helpers/view"; +import { componentHelper } from "ember-htmlbars/helpers/component"; import { yieldHelper } from "ember-htmlbars/helpers/yield"; import { withHelper } from "ember-htmlbars/helpers/with"; import { logHelper } from "ember-htmlbars/helpers/log"; @@ -59,6 +60,9 @@ import "ember-htmlbars/system/bootstrap"; import "ember-htmlbars/compat"; registerHelper('view', viewHelper); +if (Ember.FEATURES.isEnabled('ember-htmlbars-component-helper')) { + registerHelper('component', componentHelper); +} registerHelper('yield', yieldHelper); registerHelper('with', withHelper); registerHelper('if', ifHelper); diff --git a/packages/ember-htmlbars/tests/helpers/component_test.js b/packages/ember-htmlbars/tests/helpers/component_test.js new file mode 100644 index 00000000000..5b331d01c44 --- /dev/null +++ b/packages/ember-htmlbars/tests/helpers/component_test.js @@ -0,0 +1,167 @@ +import ComponentLookup from "ember-views/component_lookup"; +import Registry from "container/registry"; +import EmberView from "ember-views/views/view"; +import compile from "ember-template-compiler/system/compile"; +import { runAppend, runDestroy } from "ember-runtime/tests/utils"; + +var set = Ember.set; +var get = Ember.get; +var view, registry, container; + +if (Ember.FEATURES.isEnabled('ember-htmlbars-component-helper')) { + QUnit.module("ember-htmlbars: {{#component}} helper", { + setup: function() { + registry = new Registry(); + container = registry.container(); + + registry.optionsForType('template', { instantiate: false }); + registry.register('component-lookup:main', ComponentLookup); + }, + + teardown: function() { + runDestroy(view); + runDestroy(container); + registry = container = view = null; + } + }); + + test("component helper with unquoted string is bound", function() { + registry.register('template:components/foo-bar', compile('yippie! {{location}} {{yield}}')); + registry.register('template:components/baz-qux', compile('yummy {{location}} {{yield}}')); + + view = EmberView.create({ + container: container, + dynamicComponent: 'foo-bar', + location: 'Caracas', + template: compile('{{#component view.dynamicComponent location=view.location}}arepas!{{/component}}') + }); + + runAppend(view); + equal(view.$().text(), 'yippie! Caracas arepas!', 'component was looked up and rendered'); + + Ember.run(function() { + set(view, "dynamicComponent", 'baz-qux'); + set(view, "location", 'Loisaida'); + }); + equal(view.$().text(), 'yummy Loisaida arepas!', 'component was updated and re-rendered'); + }); + + test("component helper with actions", function() { + registry.register('template:components/foo-bar', compile('yippie! {{yield}}')); + registry.register('component:foo-bar', Ember.Component.extend({ + classNames: 'foo-bar', + didInsertElement: function() { + // trigger action on click in absence of app's EventDispatcher + var self = this; + this.$().on('click', function() { + self.sendAction('fooBarred'); + }); + }, + willDestroyElement: function() { + this.$().off('click'); + } + })); + + var actionTriggered = 0; + var controller = Ember.Controller.extend({ + dynamicComponent: 'foo-bar', + actions: { + mappedAction: function() { + actionTriggered++; + } + } + }).create(); + view = EmberView.create({ + container: container, + controller: controller, + template: compile('{{#component dynamicComponent fooBarred="mappedAction"}}arepas!{{/component}}') + }); + + runAppend(view); + Ember.run(function() { + view.$('.foo-bar').trigger('click'); + }); + equal(actionTriggered, 1, 'action was triggered'); + }); + + test('component helper maintains expected logical parentView', function() { + registry.register('template:components/foo-bar', compile('yippie! {{yield}}')); + var componentInstance; + registry.register('component:foo-bar', Ember.Component.extend({ + didInsertElement: function() { + componentInstance = this; + } + })); + + view = EmberView.create({ + container: container, + dynamicComponent: 'foo-bar', + template: compile('{{#component view.dynamicComponent}}arepas!{{/component}}') + }); + + runAppend(view); + equal(get(componentInstance, 'parentView'), view, 'component\'s parentView is the view invoking the helper'); + }); + + test("nested component helpers", function() { + registry.register('template:components/foo-bar', compile('yippie! {{location}} {{yield}}')); + registry.register('template:components/baz-qux', compile('yummy {{location}} {{yield}}')); + registry.register('template:components/corge-grault', compile('delicious {{location}} {{yield}}')); + + view = EmberView.create({ + container: container, + dynamicComponent1: 'foo-bar', + dynamicComponent2: 'baz-qux', + location: 'Caracas', + template: compile('{{#component view.dynamicComponent1 location=view.location}}{{#component view.dynamicComponent2 location=view.location}}arepas!{{/component}}{{/component}}') + }); + + runAppend(view); + equal(view.$().text(), 'yippie! Caracas yummy Caracas arepas!', 'components were looked up and rendered'); + + Ember.run(function() { + set(view, "dynamicComponent1", 'corge-grault'); + set(view, "location", 'Loisaida'); + }); + equal(view.$().text(), 'delicious Loisaida yummy Loisaida arepas!', 'components were updated and re-rendered'); + }); + + test("component helper can be used with a quoted string (though you probably would not do this)", function() { + registry.register('template:components/foo-bar', compile('yippie! {{location}} {{yield}}')); + + view = EmberView.create({ + container: container, + location: 'Caracas', + template: compile('{{#component "foo-bar" location=view.location}}arepas!{{/component}}') + }); + + runAppend(view); + + equal(view.$().text(), 'yippie! Caracas arepas!', 'component was looked up and rendered'); + }); + + test("component with unquoted param resolving to non-existent component", function() { + view = EmberView.create({ + container: container, + dynamicComponent: 'does-not-exist', + location: 'Caracas', + template: compile('{{#component view.dynamicComponent location=view.location}}arepas!{{/component}}') + }); + + throws(function() { + runAppend(view); + }, /HTMLBars error: Could not find component named "does-not-exist"./); + }); + + test("component with quoted param for non-existent component", function() { + view = EmberView.create({ + container: container, + location: 'Caracas', + template: compile('{{#component "does-not-exist" location=view.location}}arepas!{{/component}}') + }); + + throws(function() { + runAppend(view); + }, /HTMLBars error: Could not find component named "does-not-exist"./); + }); +} diff --git a/packages/ember-views/lib/streams/utils.js b/packages/ember-views/lib/streams/utils.js index 2740dbdfaf1..7cf346e2bdf 100644 --- a/packages/ember-views/lib/streams/utils.js +++ b/packages/ember-views/lib/streams/utils.js @@ -27,6 +27,15 @@ export function readViewFactory(object, container) { return viewClass; } +export function readComponentFactory(nameOrStream, container) { + var name = read(nameOrStream); + var componentLookup = container.lookup('component-lookup:main'); + Ember.assert("Could not find 'component-lookup:main' on the provided container," + + " which is necessary for performing component lookups", componentLookup); + + return componentLookup.lookupFactory(name, container); +} + export function readUnwrappedModel(object) { if (isStream(object)) { var result = object.value(); diff --git a/packages/ember-views/lib/views/bound_component_view.js b/packages/ember-views/lib/views/bound_component_view.js new file mode 100644 index 00000000000..ce333fc53ff --- /dev/null +++ b/packages/ember-views/lib/views/bound_component_view.js @@ -0,0 +1,50 @@ +/** +@module ember +@submodule ember-views +*/ + +import { _Metamorph } from "ember-views/views/metamorph_view"; +import { read, chain, subscribe, unsubscribe } from "ember-metal/streams/utils"; +import { readComponentFactory } from "ember-views/streams/utils"; +import mergeViewBindings from "ember-htmlbars/system/merge-view-bindings"; +import EmberError from "ember-metal/error"; + +export default Ember.ContainerView.extend(_Metamorph, { + init: function() { + this._super(); + var componentNameStream = this._boundComponentOptions.componentNameStream; + var container = this.container; + this.componentClassStream = chain(componentNameStream, function() { + return readComponentFactory(componentNameStream, container); + }); + + subscribe(this.componentClassStream, this._updateBoundChildComponent, this); + this._updateBoundChildComponent(); + }, + willDestroy: function() { + unsubscribe(this.componentClassStream, this._updateBoundChildComponent, this); + this._super(); + }, + _updateBoundChildComponent: function() { + this.replace(0, 1, [this._createNewComponent()]); + }, + _createNewComponent: function() { + var componentClass = read(this.componentClassStream); + if (!componentClass) { + throw new EmberError('HTMLBars error: Could not find component named "' + read(this._boundComponentOptions.componentNameStream) + '".'); + } + var hash = this._boundComponentOptions; + var ignore = ["_boundComponentOptions", "componentClassStream"]; + var hashForComponent = {}; + + var prop; + for (prop in hash) { + if (ignore.indexOf(prop) !== -1) { continue; } + hashForComponent[prop] = hash[prop]; + } + + var props = {}; + mergeViewBindings(this, props, hashForComponent); + return this.createChildView(componentClass, props); + } +});