Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions features.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions packages/ember-htmlbars/lib/helpers/component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
@module ember
@submodule ember-htmlbars
*/
import Ember from "ember-metal/core"; // Ember.warn, Ember.assert
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe flagged aswell so we don't pull in the extra bytes

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't follow. Do you have an example of this, @stefanpenner?

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 `<body>` 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);
}
4 changes: 4 additions & 0 deletions packages/ember-htmlbars/lib/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
167 changes: 167 additions & 0 deletions packages/ember-htmlbars/tests/helpers/component_test.js
Original file line number Diff line number Diff line change
@@ -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"./);
});
}
9 changes: 9 additions & 0 deletions packages/ember-views/lib/streams/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
50 changes: 50 additions & 0 deletions packages/ember-views/lib/views/bound_component_view.js
Original file line number Diff line number Diff line change
@@ -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, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we only have a single child we do not need the power of ContainerView (to manage arrays of children). I think this can be done with a normal Ember.View (instead of a Ember.ContainerView).

I created a JSBin here with a tiny demo (would still need the _boundComponentProps stuff you have). The basic gist is that adding a child view in render is cleared for every rerender. You could extend packages/ember-views/lib/mixins/normalized_rerender_if_needed.js and set the componentNameStream at lazyValue (like BoundPartialView).

@mmun / @mixonic - Does you agree?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rwjblue I'm open to this approach. I'm curious about what the expected gain is, though. Is it faster or cheaper (in terms of memory)? It seems to me that what we are doing here is very similar what OutletView does, which is implemented as ContainerView.extend(_Metamorph).

On that note, I wonder what consideration we should give to extension points for animation (e.g. LiquidFire). @ef4 or others familiar with it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the JSBin to share the same morph (thanks to @mmun for pointing out the extra morhps being created in the first one).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukemelia - Ember.ContainerView does much more than what we need (and I think that OutletView should likely be updated as well). For example ContainerView calls defineProperty on itself during init (which is generally known to be a perf issue).

I'm not unhappy with your current implementation, I just think it could be better/simpler. I'm happy to wait for the real view experts (obviously not me) to chime in...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rwjblue makes sense. I'll stay tuned.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will using a View instead of ContainerView fix the parentView problem?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@igorT I don't think there is a parentView problem in the current implementation, unless I am misunderstanding. BoundComponentView is virtual, so it is skipped in the parentView getter.

Console

I'll add an assertion to make sure this remains true.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a new test to confirm this in the latest revision. Let me know if this is not what you meant @igorT

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

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);
}
});