diff --git a/app/components/mixin-property.js b/app/components/mixin-property.js index 27954c2dc1..92af19ceb3 100644 --- a/app/components/mixin-property.js +++ b/app/components/mixin-property.js @@ -1,6 +1,6 @@ import { computed } from '@ember/object'; import Component from '@ember/component'; -import { equal, alias } from '@ember/object/computed'; +import { equal, alias, and } from '@ember/object/computed'; export default Component.extend({ isEdit: false, @@ -17,19 +17,31 @@ export default Component.extend({ txtValue: null, dateValue: null, - isCalculated: computed('model.value.type', function() { - return this.get('model.value.type') !== 'type-descriptor'; + isCalculated: computed('valueType', function() { + return this.get('valueType') !== 'type-descriptor'; }), - isEmberObject: equal('model.value.type', 'type-ember-object'), + valueType: alias('model.value.type'), + + isService: alias('model.isService'), + + isOverridden: alias('model.overridden'), + + isEmberObject: equal('valueType', 'type-ember-object'), isComputedProperty: alias('model.value.computed'), - isFunction: equal('model.value.type', 'type-function'), + isFunction: equal('valueType', 'type-function'), + + isArray: equal('valueType', 'type-array'), - isArray: equal('model.value.type', 'type-array'), + isDate: equal('valueType', 'type-date'), - isDate: equal('model.value.type', 'type-date'), + isDepsExpanded: false, + + hasDependentKeys: and('model.dependentKeys.length', 'isCalculated'), + + showDependentKeys: and('isDepsExpanded', 'hasDependentKeys'), _parseTextValue(value) { let parsedValue; @@ -48,6 +60,9 @@ export default Component.extend({ }, actions: { + toggleDeps() { + this.toggleProperty('isDepsExpanded'); + }, valueClick() { if (this.get('isEmberObject') || this.get('isArray')) { this.get('mixin').send('digDeeper', this.get('model')); @@ -64,7 +79,7 @@ export default Component.extend({ } let value = this.get('model.value.inspect'); - let type = this.get('model.value.type'); + let type = this.get('valueType'); if (type === 'type-string') { value = `"${value}"`; } diff --git a/app/styles/base.scss b/app/styles/base.scss index 8178774d4d..5deb108ecc 100644 --- a/app/styles/base.scss +++ b/app/styles/base.scss @@ -1,3 +1,9 @@ +:root { + --font-sans-serif: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Cantarell', 'Oxygen', 'Ubuntu', 'Helvetica Neue', helvetica, arial, sans-serif; + --font-monospace: 'SF Mono', Menlo, Monaco, monospace; +} + + h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; @@ -10,7 +16,7 @@ html, body { } body, input { - font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Cantarell', 'Oxygen', 'Ubuntu', 'Helvetica Neue', helvetica, arial, sans-serif; + font-family: var(--font-sans-serif); color: var(--base26); } @@ -24,5 +30,5 @@ a { } code { - font-family: Menlo, Monaco, monospace; + font-family: var(--font-monospace); } diff --git a/app/styles/colors.scss b/app/styles/colors.scss index 87dfc99008..a85b55e452 100644 --- a/app/styles/colors.scss +++ b/app/styles/colors.scss @@ -48,6 +48,10 @@ --spec12: #7c027c; --spec13: #4977d2; --spec14: #ecf1fa; + --spec15: #cd61a7; + --spec16: #7fb56d; + --spec17: #328509; + --spec18: #9c0f9c; } .theme--dark { diff --git a/app/styles/mixin.scss b/app/styles/mixin.scss index 8983a060bd..32b4c602c0 100644 --- a/app/styles/mixin.scss +++ b/app/styles/mixin.scss @@ -39,15 +39,17 @@ $mixin-left-padding: 22px; .mixin__name:before { position: absolute; top: 5px; - left: 7px; + left: 6px; content: "\25B6"; color: var(--base18); margin-right: 5px; font-size: 90%; + transform: rotate(0deg); + transition: all 0.1s; } .mixin_state_expanded .mixin__name:before { - content: "\25BC"; + transform: rotate(90deg); } .mixin__properties { @@ -56,7 +58,7 @@ $mixin-left-padding: 22px; margin: 0; padding: 3px 0 5px 0; font-size: 12px; - font-family: 'SF Mono', Menlo, Monaco, monospace; + font-family: var(--font-monospace); } .mixin__property { @@ -68,6 +70,25 @@ $mixin-left-padding: 22px; flex-direction: row; align-items: center; min-height: 19px; + padding-top: 1px; + padding-bottom: 1px; + + .mixin__cp-toggle { + width: $mixin-left-padding; + outline: none; + + svg { + transition: all 0.2s; + transform: + rotate(-90deg) + translateY(1px); + } + } + .mixin__cp-toggle--expanded { + svg { + transform: rotate(0deg); + } + } } /* Errors */ @@ -97,16 +118,9 @@ $mixin-left-padding: 22px; } .pad { width: $mixin-left-padding; } - .mixin__calc-btn { - width: $mixin-left-padding; - - img { - max-height: 16px; - } - } - .mixin__calc-btn--calculated { - opacity: 0.4; + .mixin__calc-btn svg { + path, circle { fill: var(--spec18); } } .mixin__send-btn { @@ -120,7 +134,7 @@ $mixin-left-padding: 22px; } .mixin__property-name { - color: var(--spec04); + color: var(--base26); } .mixin__property-value { @@ -149,7 +163,7 @@ $mixin-left-padding: 22px; } .mixin__property .type-object { - color: var(--base15); + color: var(--base20); } .mixin__property .type-ember-object, @@ -157,12 +171,8 @@ $mixin-left-padding: 22px; cursor: pointer; } -.mixin__property-calculated-value { - flex: 1; - color: var(--base15); -} - -.mixin__property .type-null, .mixin__property .type-boolean { +.mixin__property .type-null, +.mixin__property .type-boolean { color: var(--spec10); } @@ -171,6 +181,9 @@ $mixin-left-padding: 22px; color: var(--spec05); } +.mixin__property .type-service { + color: var(--spec17); +} .mixin__property_state_overridden { diff --git a/app/styles/object_inspector.scss b/app/styles/object_inspector.scss index 5e889bd960..ffb421a497 100644 --- a/app/styles/object_inspector.scss +++ b/app/styles/object_inspector.scss @@ -10,3 +10,94 @@ margin-left: 39px; word-break: break-all; } + +.mixin__property-dependency-list { + position: relative; + margin-bottom: 5px; + margin-top: 2px; + padding-top: 2px; + + & > svg { + position: absolute; + top: -4px; + left: 28px; + width: 19px; + height: 10px; + } + + ul { + list-style: none; + margin: 0; + padding: 0; + } +} + +.mixin__property-dependency-item { + position: relative; + margin-bottom: 2px; + margin-left: 55px; + font-size: 11px; + color: var(--base21); + + &::before { + content: ''; + position: absolute; + top: -5px; + left: -9px; + width: 1px; + height: 8px; + background-color: #888; + } + + svg { + position: absolute; + bottom: 2px; + left: -13px; + + ellipse { + stroke: #888; + fill: #fff; + } + } +} + +.mixin__property-dependency-item:first-child::before { display: none; } + +.mixin__property-icon-container { + width: $mixin-left-padding; +} + +.mixin__property-icon { + display: inline-block; + width: 17px; + height: 17px; + border-radius: 3px; + color: #fff; + font-size: 13px; + line-height: 17px; + font-family: var(--font-monospace); + text-align: center; + user-select: none; + -webkit-user-select: none; +} + +.mixin__property-icon--service { + background-color: var(--spec16); + &::before { + content: "S"; + } +} + +.mixin__property-icon--computed { + background-color: var(--spec15); + &::before { + content: "C"; + } +} + +.mixin__property-icon--property { + background-color: var(--base11); + &::before { + content: "P"; + } +} \ No newline at end of file diff --git a/app/templates/components/mixin-details.hbs b/app/templates/components/mixin-details.hbs index 071b97619a..67e546524e 100644 --- a/app/templates/components/mixin-details.hbs +++ b/app/templates/components/mixin-details.hbs @@ -1,20 +1,21 @@ {{#if model.errors.length}} -
-

- Errors - - Trace in the console - -

-
- {{#each model.errors as |error|}} -
- Error while computing: {{error.property}} +
+

+ Errors + + Trace in the console + +

+
+ {{#each model.errors as |error|}} +
+ Error while computing: {{error.property}} +
+ {{/each}}
- {{/each}}
-
{{/if}} + {{#each model.mixins as |item|}} {{#mixin-detail model=item mixinDetails=this as |mixin|}}
@@ -27,27 +28,119 @@
    {{#each mixin.sortedProperties as |prop|}} {{#mixin-property model=prop mixin=mixin as |property|}} -
  • - {{#if property.model.value.computed}} - +
  • + + {{#if property.hasDependentKeys}} + {{else}} {{/if}} - {{property.model.name}}: + + + {{#if property.isService}} + + + + {{else if property.isComputedProperty}} + + + + {{else}} + + + + {{/if}} + + + + {{#if property.isService}} + + {{property.model.name}} + + {{else}} + {{#if property.hasDependentKeys}} + + {{property.model.name}} + + {{else}} + {{property.model.name}} + {{/if}} + {{/if}} + + + : + + {{#unless property.isEdit}} - {{property.model.value.inspect}} + {{#if (and property.isComputedProperty (not property.isCalculated))}} + + {{else}} + + {{property.model.value.inspect}} + + {{/if}} {{else}} {{#unless property.isDate}} - {{property-field value=property.txtValue finished-editing="finishedEditing" save-property="saveProperty" propertyComponent=property - class="mixin__property-value-txt js-object-property-value-txt"}} + {{property-field + value=property.txtValue + finished-editing="finishedEditing" + save-property="saveProperty" + propertyComponent=property + class="mixin__property-value-txt js-object-property-value-txt"}} {{else}} - {{date-property-field value=property.dateValue format="YYYY-MM-DD" - class="mixin__property-value-txt js-object-property-value-date" onSelection=(action "dateSelected" target=property) cancel=(action "finishedEditing" target=property)}} + {{date-property-field + value=property.dateValue + format="YYYY-MM-DD" + class="mixin__property-value-txt js-object-property-value-date" + onSelection=(action "dateSelected" target=property) + cancel=(action "finishedEditing" target=property)}} {{/unless}} {{/unless}} - (Overridden by {{property.model.overridden}}) - -
  • + + (Overridden by {{property.model.overridden}}) + + + + + {{#if property.showDependentKeys}} +
  • + {{svg-jar "dependent-key-connection" width="20px" height="10px"}} +
      + {{#each property.model.dependentKeys as |depKey index|}} +
    • + {{svg-jar "dependent-key-bullet" width="9px" height="9px"}} + + {{depKey}} + +
    • + {{/each}} +
    +
  • + {{/if}} {{/mixin-property}} {{else}}
  • No Properties
  • diff --git a/ember_debug/object-inspector.js b/ember_debug/object-inspector.js index c9acf9dfee..3c542d2c8a 100644 --- a/ember_debug/object-inspector.js +++ b/ember_debug/object-inspector.js @@ -232,7 +232,6 @@ export default EmberObject.extend(PortMixin, { if (this.canSend(object)) { let details = this.mixinsForObject(object); - this.sendMessage('updateObject', { parentObject: objectId, property, @@ -342,7 +341,11 @@ export default EmberObject.extend(PortMixin, { // for more details. if (compareVersion(VERSION, '2.11.0') !== -1) { if (!name && typeof mixin.toString === 'function') { - name = mixin.toString(); + try { + name = mixin.toString(); + } catch (e) { + name = '(Unable to convert Object to string)'; + } } } if (!name) { @@ -468,7 +471,25 @@ function addProperties(properties, hash) { continue; } let options = { isMandatorySetter: isMandatorySetter(hash, prop) }; + + if (typeof hash[prop] === 'object' && hash[prop] !== null) { + options.isService = hash[prop].type === 'service'; + if (!options.isService) { + if (hash[prop].constructor) { + options.isService = hash[prop].constructor.isServiceFactory; + } + } + } + if (isComputed(hash[prop])) { + options.dependentKeys = (hash[prop]._dependentKeys || []).map((key) => key.toString()); + if (!options.isService) { + if (typeof hash[prop]._getter === 'function') { + options.code = Function.prototype.toString.call(hash[prop]._getter); + } else { + options.code = ''; + } + } options.readOnly = hash[prop]._readOnly; } replaceProperty(properties, prop, hash[prop], options); @@ -493,6 +514,10 @@ function replaceProperty(properties, name, value, options) { let prop = { name, value: inspectValue(value) }; prop.isMandatorySetter = options.isMandatorySetter; prop.readOnly = options.readOnly; + prop.dependentKeys = options.dependentKeys || []; + let hasServiceFootprint = prop.value && typeof prop.value.inspect === 'string' ? prop.value.inspect.includes('@service:') : false; + prop.isService = options.isService || hasServiceFootprint; + prop.code = options.code; properties.push(prop); } diff --git a/public/assets/svg/dependent-key-bullet.svg b/public/assets/svg/dependent-key-bullet.svg new file mode 100644 index 0000000000..41cb878cc5 --- /dev/null +++ b/public/assets/svg/dependent-key-bullet.svg @@ -0,0 +1,3 @@ + diff --git a/public/assets/svg/dependent-key-connection.svg b/public/assets/svg/dependent-key-connection.svg new file mode 100644 index 0000000000..4d4653abbe --- /dev/null +++ b/public/assets/svg/dependent-key-connection.svg @@ -0,0 +1,3 @@ + diff --git a/public/assets/svg/disclosure-triangle.svg b/public/assets/svg/disclosure-triangle.svg index dff1f749de..bb2f84a318 100644 --- a/public/assets/svg/disclosure-triangle.svg +++ b/public/assets/svg/disclosure-triangle.svg @@ -1,3 +1,3 @@ - + diff --git a/tests/acceptance/object-inspector-test.js b/tests/acceptance/object-inspector-test.js index de8beb37c5..d5b6285458 100644 --- a/tests/acceptance/object-inspector-test.js +++ b/tests/acceptance/object-inspector-test.js @@ -114,7 +114,6 @@ module('Object Inspector', function(hooks) { assert.equal(firstDetail.querySelectorAll('.js-object-property').length, 1); assert.dom(firstDetail.querySelector('.js-object-property-name')).hasText('numberProperty'); assert.dom(firstDetail.querySelector('.js-object-property-value')).hasText('1'); - await click(firstDetail.querySelector('.js-object-detail-name')); assert.dom(firstDetail).hasNoClass('mixin_state_expanded', 'Expanded detail minimizes on click.'); @@ -213,6 +212,137 @@ module('Object Inspector', function(hooks) { assert.dom('.js-object-property-value').hasText('Computed value'); }); + test("Service highlight", async function(assert) { + await visit('/'); + + let obj = { + name: 'My Object', + objectId: 'myObject', + details: [{ + name: 'Detail', + properties: [{ + name: 'serviceProp', + isService: true, + value: { + inspect: '', + computed: true + } + }] + }] + }; + + await triggerPort(this, 'objectInspector:updateObject', obj); + await click('.js-object-detail-name'); + + assert.equal(findAll('.mixin__property--group').length, 1); + assert.equal(findAll('.mixin__property-icon--service').length, 1); + assert.equal(findAll('.js-property-name-service').length, 1); + assert.equal(findAll('.mixin__property-dependency-list').length, 0); + assert.equal(findAll('.mixin__property-dependency-item').length, 0); + assert.equal(findAll('.mixin__property-dependency-item > .mixin__property-dependency-name').length, 0); + }); + + test("Computed properties no dependency", async function (assert) { + await visit('/'); + + let obj = { + name: 'My Object', + objectId: 'myObject', + details: [{ + name: 'Detail', + properties: [{ + name: 'computedProp', + dependentKeys: [], + value: { + inspect: '', + type: 'type-descriptor', + computed: true + } + }] + }] + }; + + await triggerPort(this, 'objectInspector:updateObject', obj); + await click('.js-object-detail-name'); + await click('.js-calculate'); + + assert.equal(name, 'objectInspector:calculate'); + assert.deepEqual(message, { objectId: 'myObject', property: 'computedProp', mixinIndex: 0 }); + await triggerPort(this, 'objectInspector:updateProperty', { + objectId: 'myObject', + property: 'computedProp', + value: { + inspect: 'Computed value', + computed: 'foo-bar' + }, + mixinIndex: 0 + }); + + assert.equal(findAll('.mixin__property--group').length, 0); + + await click('.mixin__property-icon--computed'); + + assert.equal(findAll('.mixin__property-dependency-list').length, 0); + assert.equal(findAll('.mixin__property-dependency-item').length, 0); + assert.equal(findAll('.mixin__property-dependency-item > .mixin__property-dependency-name').length, 0); + + await click('.mixin__property-icon--computed'); + + assert.equal(findAll('.mixin__property-dependency-list').length, 0); + assert.equal(findAll('.mixin__property-dependency-item').length, 0); + assert.equal(findAll('.mixin__property-dependency-item > .mixin__property-dependency-name').length, 0); + }); + + test("Computed properties dependency expand", async function (assert) { + await visit('/'); + + let obj = { + name: 'My Object', + objectId: 'myObject', + details: [{ + name: 'Detail', + properties: [{ + name: 'computedProp', + dependentKeys: ['foo.@each.bar'], + value: { + inspect: '', + type: 'type-descriptor', + computed: true + } + }] + }] + }; + await triggerPort(this, 'objectInspector:updateObject', obj); + await click('.js-object-detail-name'); + await click('.js-calculate'); + + assert.equal(name, 'objectInspector:calculate'); + assert.deepEqual(message, { objectId: 'myObject', property: 'computedProp', mixinIndex: 0 }); + await triggerPort(this, 'objectInspector:updateProperty', { + objectId: 'myObject', + property: 'computedProp', + value: { + inspect: 'Computed value', + computed: 'foo-bar' + }, + mixinIndex: 0 + }); + + assert.equal(findAll('.mixin__property--group').length, 1); + + await click('.mixin__property-icon--computed'); + + assert.equal(findAll('.mixin__property-dependency-list').length, 1); + assert.equal(findAll('.mixin__property-dependency-item').length, 1); + assert.equal(findAll('.mixin__property-dependency-item > .mixin__property-dependency-name').length, 1); + + await click('.mixin__property-icon--computed'); + + assert.equal(findAll('.mixin__property-dependency-list').length, 0); + assert.equal(findAll('.mixin__property-dependency-item').length, 0); + assert.equal(findAll('.mixin__property-dependency-item > .mixin__property-dependency-name').length, 0); + }); + test("Properties are bound to the application properties", async function (assert) { await visit('/'); diff --git a/tests/ember_debug/object-inspector-test.js b/tests/ember_debug/object-inspector-test.js index b586011b51..f39ab4f886 100644 --- a/tests/ember_debug/object-inspector-test.js +++ b/tests/ember_debug/object-inspector-test.js @@ -4,6 +4,7 @@ import Component from '@ember/component'; import { run } from '@ember/runloop'; import { guidFor } from '@ember/object/internals'; import EmberObject, { computed } from '@ember/object'; +import Service from '@ember/service'; import Ember from 'ember'; import { module, test } from 'qunit'; import { settings as nativeDomHelpersSettings } from 'ember-native-dom-helpers'; @@ -368,6 +369,44 @@ module('Ember Debug - Object Inspector', function(hooks) { assert.ok(message.details[4].name !== 'MixinToSkip', 'Correctly skips properties'); }); + + test("Service should be successfully tagged as service on serialization", function(assert) { + let inspectedService = Service.extend({ + fooBoo() { + return true; + } + }).create(); + + let inspected = EmberObject.extend({ + service: inspectedService + }).create(); + + objectInspector.sendObject(inspected); + + let serializedServiceProperty = message.details[1].properties[0]; + + assert.equal(serializedServiceProperty.isService, true); + }); + + test("Computed property dependent keys and code should be successfully serialized", function(assert) { + let compuedFn = function() { + return this.get("foo") + this.get("bar"); + }; + + let inspected = EmberObject.extend({ + foo: true, + bar: false, + fooAndBar: computed("foo", "bar", compuedFn) + }).create(); + + objectInspector.sendObject(inspected); + let serializedComputedProperty = message.details[1].properties[2]; + + assert.equal(serializedComputedProperty.code, compuedFn.toString()); + assert.equal(serializedComputedProperty.dependentKeys[0], "foo"); + assert.equal(serializedComputedProperty.dependentKeys[1], "bar"); + }); + test('Read Only Computed properties mush have a readOnly property', function(assert) { let inspected = EmberObject.extend({ readCP: computed(function() {}).property().readOnly(),