diff --git a/packages/ember-extension-support/lib/main.js b/packages/ember-extension-support/lib/main.js index aaa6008c5bd..bf983511efd 100644 --- a/packages/ember-extension-support/lib/main.js +++ b/packages/ember-extension-support/lib/main.js @@ -6,6 +6,9 @@ import Ember from 'ember-metal/core'; import DataAdapter from 'ember-extension-support/data_adapter'; import ContainerDebugAdapter from 'ember-extension-support/container_debug_adapter'; +import { getTopLevelNode } from 'ember-extension-support/render_debug'; Ember.DataAdapter = DataAdapter; Ember.ContainerDebugAdapter = ContainerDebugAdapter; +Ember.Debug = Ember.Debug || {}; +Ember.Debug.RenderDebug = { getTopLevelNode }; diff --git a/packages/ember-extension-support/lib/models/inspected_node.js b/packages/ember-extension-support/lib/models/inspected_node.js new file mode 100644 index 00000000000..dab6077cfe4 --- /dev/null +++ b/packages/ember-extension-support/lib/models/inspected_node.js @@ -0,0 +1,281 @@ +import Component from 'ember-views/views/component'; +import { get } from 'ember-metal/property_get'; +import EmberAttrMorph from 'ember-htmlbars/morphs/attr-morph'; +const { keys } = Object; + +/** + Wraps a render node to provide debugging API for external services + such as the Ember Inspector. + + Ideally public methods on this class should completely hide + the internal implementations of render nodes. + + @public + @class InspectedNode + @param {Object} renderNode The Htmlbars render node + @param {Object} parent The parent InspectedNode instance + */ +function InspectedNode(renderNode, parent) { + this._renderNode = renderNode; + this.parent = parent; +} + +InspectedNode.prototype = { + /** + Useful for subclassing. + + @property constructor + @type {Function} + @public + */ + constructor: InspectedNode, + + /** + The render node we are representing and adding + debugging functionality to. + + This property is set upon creation of the instance. + + @property _renderNode + @type {Object} + @private + */ + _renderNode: null, + + /** + The parent inspected node. This property is set + when building the a node's children. This allows + us to walk upward in the render hierarchy. + + @property parent + @type {InspectedNode} + @public + */ + parent: null, + + /** + Fetches the render node's children and returns + an array of InspectedNode instances (an instance is created + for each child). + + Consecutive calls to this method do not return the same + instances (each call creates new instances). This is why + the method name is `buildChildren` as opposed to `getChildren`. + + @method buildChildren + @return {Array} array of inspected nodes wrapping the render node's children. + @public + */ + buildChildren() { + let children = this._getChildRenderNodes() || []; + return children.map(renderNode => new this.constructor(renderNode, this)) + .filter(inspectedNode => inspectedNode._isInspectable()); + }, + + /** + Skip non-inspectable render nodes such as + attr morphs. + + @private + @method _isInspectable + @return {Boolean} + */ + _isInspectable() { + return !(this._renderNode instanceof EmberAttrMorph); + }, + + /** + Gather the children assigned to the render node. + + @private + @method _getChildRenderNodes + @return {Array} children renderNodes + @public + */ + _getChildRenderNodes() { + if (this._renderNode.morphMap) { + // each helper + return keys(this._renderNode.morphMap).map(key => this._renderNode.morphMap[key]); + } else { + return this._renderNode.childNodes; + } + }, + + + /** + Not all nodes are actually views/components. + Nodes can be attributes for example. This method allows + us to identify component nodes. + + Not to be confused with `isEmberComponent`. `Ember.View` instances + also return `true`. + + @method isComponentNode + @return {Boolean} + @public + */ + isComponentNode() { + return !!this._renderNode.state.manager; + }, + + /** + Check if a node has its own controller (as opposed to sharing + its parent's controller). + + Useful to identify route views from other views. Views that have + their own controller are shown by default in the Ember Inspector + view tree. + + @method hasOwnController + @return {Boolean} + @public + */ + hasOwnController() { + return !this.parent || (this.getController() !== this.parent.getController()); + }, + + /** + Check if the node has a view or component instance. + Virtual nodes don't have a view/component instance. + + @method hasComponentInstance + @return {Boolean} + @public + */ + hasComponentInstance() { + return !!this.getComponentInstance(); + }, + + /** + Return a node's view/component instance. + + @method getComponentInstance + @return {Ember.View|Ember.Component} + @public + */ + getComponentInstance() { + return this._renderNode.emberView; + }, + + /** + Returns the node's controller. + + @method getController + @return {Ember.Controller} + @public + */ + getController() { + return this._renderNode.lastResult && this._renderNode.lastResult.scope.locals.controller.value(); + }, + + /** + Get the node's template name. Relies on an htmlbars + feature that adds the module name as a meta property + to compiled templates. + + When compiling a template, pass the template's name + as a the `moduleName` property on the options argument. + This should be done automatically by tools that pre-compile + templates (ex: ember-cli does this in version >= 0.2.4) + + @method getTemplateName + @return {String} The template name + @public + */ + getTemplateName() { + let template = this._renderNode.lastResult && this._renderNode.lastResult.template; + if (template && template.meta && template.meta.moduleName) { + return template.meta.moduleName.replace(/\.hbs$/, ''); + } + }, + + /** + Returns whether the node is an Ember.Component or not. + *Not* to be confused with `isComponent` which returns true + for both views and components. + + @return {Boolean} + @public + */ + isEmberComponent() { + let componentInstance = this.getComponentInstance(this._renderNode); + return !!(componentInstance && (componentInstance instanceof Component)); + }, + + /** + The node's model. If the view has a controller, + it will be the controller's `model` property. + + @return {Object} the model + @public + */ + getModel() { + let controller = this.getController(); + if (controller) { + return get(controller, 'model'); + } + }, + + /** + The name of the component/view instance. + + @return {String} + @public + */ + getComponentInstanceName() { + let name; + let componentInstance = this.getComponentInstance(); + if (componentInstance) { + // Has a component instance - take the component's name + name = get(componentInstance, '_debugContainerKey'); + if (name) { + name = name.replace(/.*(view|component):/, '').replace(/:$/, ''); + } + return name; + } + }, + + /** + The node's name. Should be anything that the user + can use to identity what node we are talking about. + + Usually either the view instance name, or the template name. + + @return {String} The node's name + @public + */ + getName() { + let name; + let componentInstanceName = this.getComponentInstanceName(); + if (componentInstanceName) { + name = componentInstanceName; + // If application view was not defined, it uses a `toplevel` view + if (name === 'toplevel') { + name = 'application'; + } + } else { + // Virtual - no component/view instance + let templateName = this.getTemplateName(); + if (templateName) { + name = templateName.replace(/^.*templates\//, '').replace(/\//g, '.'); + } + } + return name; + }, + + /** + Get the node's bounding client rect. + Can be used to get the node's position. + + @return {DOMRect} + @public + */ + getBoundingClientRect() { + let range = document.createRange(); + range.setStartBefore(this._renderNode.firstNode); + range.setEndAfter(this._renderNode.lastNode); + return range.getBoundingClientRect(); + } +}; + +export default InspectedNode; diff --git a/packages/ember-extension-support/lib/render_debug.js b/packages/ember-extension-support/lib/render_debug.js new file mode 100644 index 00000000000..c24e0c956f0 --- /dev/null +++ b/packages/ember-extension-support/lib/render_debug.js @@ -0,0 +1,19 @@ +import InspectedNode from 'ember-extension-support/models/inspected_node'; +import $ from 'ember-views/system/jquery'; + +/** + Returns the top level node in an application. + The returned object will be an `InspectedNode` instance + which will wrap the htmlbars `renderNode`. + + @public + @param {Ember.Application} application + @return {InspectedNode} The top level node + */ +export function getTopLevelNode(application) { + let topViewId = $(application.rootElement).find('> .ember-view').attr('id'); + let rootView = application.__container__.lookup('-view-registry:main')[topViewId]; + if (rootView) { + return new InspectedNode(rootView._renderNode); + } +} diff --git a/packages/ember-extension-support/tests/render_debug_test.js b/packages/ember-extension-support/tests/render_debug_test.js new file mode 100644 index 00000000000..b39794ff4a4 --- /dev/null +++ b/packages/ember-extension-support/tests/render_debug_test.js @@ -0,0 +1,105 @@ +import EmberApplication from 'ember-application/system/application'; +import run from 'ember-metal/run_loop'; +import { getTopLevelNode } from 'ember-extension-support/render_debug'; +import compile from 'ember-template-compiler/system/compile'; +import Route from 'ember-routing/system/route'; +import Component from 'ember-views/views/component'; +import Controller from 'ember-runtime/controllers/controller'; + +var app; + +function lookup(name) { + return app.__container__.lookup(name); +} + +function flattenComponentNodes(node) { + var array = []; + if (node.isComponentNode()) { + array.push(node); + } + node.buildChildren().forEach(child => { + array = array.concat(flattenComponentNodes(child)); + }); + return array; +} + +QUnit.module('Debug - Render Debug', { + setup() { + run(() => { + app = EmberApplication.create(); + app.deferReadiness(); + }); + }, + + teardown() { + run(app, 'destroy'); + app = null; + } +}); + +QUnit.test('Simple render tree', function() { + let model = {}; + app.register('template:application', compile('{{outlet}}', { moduleName: 'application' })); + app.register('template:index', compile('

Index Page

', { moduleName: 'index' })); + app.register('route:application', Route.extend({ + model() { + return model; + } + })); + + run(app, 'advanceReadiness'); + + var nodes = flattenComponentNodes(getTopLevelNode(app)); + var node = nodes[0]; + equal(node.isComponentNode(), true, 'correctly identified as a component node'); + equal(node.hasOwnController(), true, 'top level always has its own controller'); + equal(node.hasComponentInstance(), true, 'Top level always has its own View instance'); + equal(node.getController(), lookup('controller:application'), 'returns the correct controller'); + equal(node.getTemplateName(), 'application', 'Gets the correct template name'); + equal(node.isEmberComponent(), false, 'top level is a view not a component'); + equal(node.getModel(), model, 'returns the correct model'); + equal(node.getComponentInstanceName(), 'toplevel', 'returns the correct view instance name'); + equal(node.getName(), 'application', 'returns the correct name'); + ok(node.getBoundingClientRect()); + + node = nodes[1]; + equal(node.isComponentNode(), true, 'index correctly identified as a component node'); + equal(node.hasOwnController(), true, 'route nodes always have their own controller'); + equal(node.hasComponentInstance(), false, 'index is virtual - no view instance'); + equal(node.getController(), lookup('controller:index'), 'returns the correct controller'); + equal(node.getTemplateName(), 'index', 'index gets the correct template name'); + equal(node.isEmberComponent(), false, 'index is a view not a component'); + equal(node.getModel(), model, 'index has the same model as application'); + equal(node.getName(), 'index', 'returns the correct name'); + ok(node.getBoundingClientRect()); +}); + +QUnit.test('Ember component node', function() { + app.register('template:application', compile('{{foo-bar}}', { moduleName: 'application' })); + app.register('template:components/foo-bar', compile('Foo bar', { moduleName: 'fooBar' })); + app.register('component:foo-bar', Component); + + run(app, 'advanceReadiness'); + + var nodes = flattenComponentNodes(getTopLevelNode(app)); + var component = nodes[1]; + + equal(component.isComponentNode(), true, 'correctly identified as a component node'); + equal(component.hasComponentInstance(), true, 'Ember component instance detected'); + equal(component.getTemplateName(), 'fooBar', 'has the correct template name'); + equal(component.isEmberComponent(), true, 'correctly identified as an Ember Component'); + equal(component.getName(), 'fooBar', 'returns the correct name'); +}); + +QUnit.test('Components in each helper', function() { + app.register('controller:application', Controller.extend({ + model: [1, 2, 3] + })); + app.register('template:application', compile('{{#each model as |item|}}{{foo-bar}}{{/each}}', { moduleName: 'application' })); + app.register('template:components/foo-bar', compile('Foo bar', { moduleName: 'fooBar' })); + + run(app, 'advanceReadiness'); + + var nodes = flattenComponentNodes(getTopLevelNode(app)); + equal(nodes.length, 4, 'components inside each helper are detected'); +});