5
\$\begingroup\$

I wanted to reproduce the type of navigation system found in the console of Firefox and Chrome: you can explore an object's properties by unfolding boxes:

enter image description here

So I'm searching to:

  1. display all of the object's properties (even if they're nested)

  2. being able to fold/unfold the properties if themselves are objects


Element handling

I have written two functions createElement and appendElement, I will need them later:

/**
  * Creates an HTML element without rendering in the DOM
  * @params {String} htmlString is the HTML string that is created 
  * @return {HTMLElement} is the actual object of type HTMLElement
  */
const createElement = htmlString => {

  const div = document.createElement('div');
  div.innerHTML = htmlString;

  return div.firstChild;

}


/**
  * Appends the given element to another element already rendered in the DOM
  * @params {String or HTMLElement} parent is either a CSS String selector or a DOM element
  *         {String or HTMLElement} element is either a HTML String or an HTMLElement
  * @return {HTMLElement} the appended child element
  */
const appendElement = (parent, element) => {

  element = element instanceof HTMLElement ? element : createElement(element);

  return (parent instanceof HTMLElement ? parent : document.querySelector(parent))
    .appendChild(element);

}

Version 1

My initial attempt was to use a recursive approach. Essentially each level would call the function for each of its children which would, in turn, call the function for their own children.

In the end, it would result in having the complete tree displayed on the page.

const showContent = (object, parent) => {

  Object.keys(object).forEach(e => {
    if(object[e] && object[e].constructor == Object) {
      showContent(object[e], appendElement(parent, `<div class="level fold"><div class="parent"><span>-</span> ${e}<div></div>`));
    } else {
      appendElement(parent, `<div class="level">${e}: <span>${object[e]}</span></div>`)
    }
  });

}

// display the object's property
showContent(obj, 'body');

// toggle function to fold/unfold the properties
const toggle = () => event.target.parentElement.classList.toggle('fold');

// listen to click event on each element
document.querySelectorAll('.parent span').forEach(e => e.parentElement.addEventListener('click', toggle));

const showContent = (object, parent) => {

  Object.keys(object).forEach(e => {
    if(object[e] && object[e].constructor == Object) {
      showContent(object[e], appendElement(parent, `<div class="level fold"><div class="parent"><span>-</span> ${e}<div></div>`));
    } else {
      appendElement(parent, `<div class="level">${e}: <span>${object[e]}</span></div>`)
    }
  });

}

// display the object's property
showContent(obj, 'body');

// toggle function to fold/unfold the properties
const toggle = () => event.target.parentElement.classList.toggle('fold');

// listen to click event on each element
document.querySelectorAll('.parent span').forEach(e => e.parentElement.addEventListener('click', toggle));
body>.level{position:relative;left:25%;width:50%}.level{background:lightgrey;border:2px solid brown;padding:4px;margin:4px;overflow:hidden}.level>span{color:#0366d5}.level>.parent{color:green;display:inline-block;width:100%}.fold{height:18px}
<script>const createElement=htmlString=>{const div=document.createElement('div');div.innerHTML=htmlString;return div.firstChild};const appendElement=(parent,element)=>{element=element instanceof HTMLElement?element:createElement(element);return(parent instanceof HTMLElement?parent:document.querySelector(parent)).appendChild(element)};const obj = {innerObj:{other:'yes',note:12,innerObj:{other:'no',note:1}},name:'one',int:1225,bool:true};</script>

Here are a few things I didn't like about this code:

  • there's an event on each property element, also the event listener needs to be set after the element has been added to the DOM. So calling showContent before the event handlers doesn't feel natural.

  • this version doesn't support circular structures. For example:

    let obj = {};
    obj['obj'] = obj;
    showContent(obj);
    

    will fail...

So this won't work for me. I need something able to handle cycles without too much trouble and that would not require to add an event listener each time a new property is unfolded.


Version 2

I came up with a better version that solved all these problems:

/**
  * Shows all the object's properties with a depth of 1 
  * @params {Object} object, its first level properties are shown
  *         {String or HTMLElement} parent the element in which are displayed the properties
  * @return {undefined}
  */
const showObject = (object, parent='body') => {

  Object.entries(object).forEach(([key, value]) => {

    if(value && value.constructor == Object) {
      const element = appendElement(parent, `<div class="level fold"><div class="parent"><span>-</span> ${key}<div></div>`);
      element.addEventListener('click', () => {showObject(value, element)}, {once: true});

    } else {
      appendElement(parent, `<div class="level">${key}: <span>${value}</span></div>`);
    }

  });

};

/**
  * Toggles the CSS class .fold on the provided element
  * @params {HTMLElement} element on which to toggle the class
  * @return {undefined}
  */
const fold = element => element.classList.toggle('fold');


/**
  * Document click listener
  */
document.addEventListener('click', function() {

  const target = event.target;

  const isFoldable = target.classList.contains('parent');
  if(isFoldable) {
    fold(target.parentElement);
  }

});

/**
  * Shows all the object's properties with a depth of 1 
  * @params {Object} object, its first level properties are shown
  *         {String or HTMLElement} parent the element in which are displayed the properties
  * @return {undefined}
  */
const showObject = (object, parent='body') => {

  Object.entries(object).forEach(([key, value]) => {

    if(value && value.constructor == Object) {
      const element = appendElement(parent, `<div class="level fold"><div class="parent"><span>-</span> ${key}<div></div>`);
      element.addEventListener('click', () => {showObject(value, element)}, {once: true});

    } else {
      appendElement(parent, `<div class="level">${key}: <span>${value}</span></div>`);
    }

  });

};


/**
  * Toggles the CSS class .fold on the provided element
  * @params {HTMLElement} element on which to toggle the class
  * @return {undefined}
  */
const fold = element => element.classList.toggle('fold');


/**
  * Document click listener
  */
document.addEventListener('click', function() {

  const target = event.target;

  const isFoldable = target.classList.contains('parent');
  if(isFoldable) {
    fold(target.parentElement);
  }

});

showObject(obj);
body>.level{position:relative;left:25%;width:50%}.level{background:lightgrey;border:2px solid brown;padding:4px;margin:4px;overflow:hidden}.level>span{color:#0366d5}.level>.parent{color:green;display:inline-block;width:100%}.fold{height:18px}
<script>const createElement=htmlString=>{const div=document.createElement('div');div.innerHTML=htmlString;return div.firstChild};const appendElement=(parent,element)=>{element=element instanceof HTMLElement?element:createElement(element);return(parent instanceof HTMLElement?parent:document.querySelector(parent)).appendChild(element)};const obj = {innerObj:{other:'yes',note:12,innerObj:{other:'no',note:1}},name:'one',int:1225,bool:true};</script>

Here it is seen working with a cycle:

let obj = {};
obj['obj'] = obj;

showObject(obj);
body>.level{position:relative;left:25%;width:50%}.level{background:lightgrey;border:2px solid brown;padding:4px;margin:4px;overflow:hidden}.level>span{color:#0366d5}.level>.parent{color:green;display:inline-block;width:100%}.fold{height:18px}
<script>const createElement=htmlString=>{const div=document.createElement('div');div.innerHTML=htmlString;return div.firstChild};const appendElement=(parent,element)=>{element=element instanceof HTMLElement?element:createElement(element);return(parent instanceof HTMLElement?parent:document.querySelector(parent)).appendChild(element)};const showObject=(object,parent='body')=>{Object.entries(object).forEach(([key,value])=>{if(value&&value.constructor==Object){const element=appendElement(parent,`<div class="level fold"><div class="parent"><span>-</span> ${key}<div></div>`);element.addEventListener('click',()=>{showObject(value,element)},{once:!0})}else{appendElement(parent,`<div class="level">${key}: <span>${value}</span></div>`)}})};const fold=element=>element.classList.toggle('fold');document.addEventListener('click',function(){const target=event.target;const isFoldable=target.classList.contains('parent');if(isFoldable){fold(target.parentElement)}})</script>


Questions

  • What do you think?
  • What could be improved (structure, naming, comments, programming style)?
  • Any advice?
\$\endgroup\$
3
\$\begingroup\$

Very neat project! Your code already looks pretty clean to me. I especially like the optimization of only running showObject once for each expansion.

  1. Bug: If my object is { a: '<b>Bold</b>' }, the text displayed for property a will be Bold, not <b>Bold</b>. This is a great example of why building HTML with strings is a bad idea. The <template> element can be used to define an HTML structure and replicate it multiple times.

  2. Missing implementation: The Chrome devtools handle Sets, Maps, and other data structures in a very nice way, making it possible to peek inside them. Your implementation will just display them as strings (e.g. [object Set]). If you are looking to extend this code, I'd recommend handling Sets, Maps, and Dates.

  3. You might want to consider using the <details> element instead of a custom foldable structure.

  4. In general, I avoid allowing multiple types of parameters. Instead of taking a string or an element for showObject's parent, I'd only take an element and change the default argument to document.body.

  5. Enhancement: Getters should not be evaluated until explicitly requested. This lets the user avoid evaluating getters which mutate the state (yes, unfortunately some people do this...). You can check if a key is a getter with Reflect.getOwnPropertyDescriptor()

    Demo (open inner, obj, inner):

        let obj = {
          inner: {
            i: 0,
            get x() { return obj.inner.i++ }
          }
        };
        obj.obj = obj;
    
        showObject(obj);
        body>.level{position:relative;left:25%;width:50%}.level{background:lightgrey;border:2px solid brown;padding:4px;margin:4px;overflow:hidden}.level>span{color:#0366d5}.level>.parent{color:green;display:inline-block;width:100%}.fold{height:18px}
        <script>const createElement=htmlString=>{const div=document.createElement('div');div.innerHTML=htmlString;return div.firstChild};const appendElement=(parent,element)=>{element=element instanceof HTMLElement?element:createElement(element);return(parent instanceof HTMLElement?parent:document.querySelector(parent)).appendChild(element)};const showObject=(object,parent='body')=>{Object.entries(object).forEach(([key,value])=>{if(value&&value.constructor==Object){const element=appendElement(parent,`<div class="level fold"><div class="parent"><span>-</span> ${key}<div></div>`);element.addEventListener('click',()=>{showObject(value,element)},{once:!0})}else{appendElement(parent,`<div class="level">${key}: <span>${value}</span></div>`)}})};const fold=element=>element.classList.toggle('fold');document.addEventListener('click',function(){const target=event.target;const isFoldable=target.classList.contains('parent');if(isFoldable){fold(target.parentElement)}})</script>

Since this seemed very short, I decided to provide a simple example of one way to fix the HTML injection. I'll admit - in this case templates might be slightly overkill, but when working with even slightly more complex HTML structures, templates can be a lifesaver.

const showObject = (object, parent = document.body) => {
  const keyValueTemplate = document.getElementById('keyValue');
  const folderTemplate = document.getElementById('folder');

  Object.entries(object).forEach(([key, value]) => {
    if (value && value.constructor == Object) {
      // Since this structure is really simple, just create the elements.
      const element = document.createElement('details');
      const summary = element.appendChild(document.createElement('summary'));
      summary.textContent = key;

      element.addEventListener('toggle', () => {
        showObject(value, element)
      }, { once: true });

      parent.appendChild(element);
    } else {
      // Use a template since the structure is somewhat complex.
      const element = document.importNode(keyValueTemplate.content, true);
      element.querySelector('.key').textContent = key;
      element.querySelector('.value').textContent = value;
      parent.appendChild(element);
    }
  });

};

showObject({ a: "<b>Hi</b>", b: { c: 123, d: true }});
.property, details {
  background: lightgrey;
  border: 2px solid brown;
  padding: 4px;
  margin: 4px;
  overflow: hidden;
}

.value {
  color: #0366d5;
}
summary {
  color: green;
}
<template id="keyValue">
  <div class="property">
    <span class="key"></span>:
    <span class="value"></span>
  </div>
</template>

\$\endgroup\$
  • \$\begingroup\$ Hello @Guerrit0 and thank you very much for reviewing my project. I've learnt a lot reading your answer. There are a lot of things I have improved: I have actually published a follow up question just now with my new version. I haven't implemented parsing of Set, Map and Date objects nor the verification of getters, but it will come... Thanks again. Ivan \$\endgroup\$ – Ivan Jul 2 '18 at 22:11

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.