Search code examples
javascriptmeteornouislider

How to update div in Meteor without helper?


I can I update a div using JavaScript with no problem. For example, the initial values for both divs are shown on the image on the left, and the values after clicking on the test button are shown on the right.

enter image description here

when I click the test button then the counter is increased by 1 and the values for the divs are changed like this:

var ela = document.getElementById("a");
var elb = document.getElementById("b");
$("#" + ela.id).html("new value " + ela.id + " ");
$("#" + elb.id).html("new value " + elb.id + " ");

So far so good, but now I would like to change the order of the divs: when the counter is even I want div a = yellow to be at the top (div b = grey at the bottom), and the other way around when the counter is uneven.

For simplicity in the example I use 2 small arrays for each of the possible orders (a,b) and (b,a) and a helper 'order' will return 1 of the 2 cases depending on the counter value (x%2 is zero or not)

Unfortunately, the result is not what I want and expected. The divs are changing position correctly, but their text is not. After the first click the counter goes from 5 to 6, so the yellow div is moving from bottom to top, but the text inside the divs is wrong, I expect 'new value a' in the yellow div that moved up, but I get 'new value b a' instead (same for other div the other way around)

Also when I inspect the divs in the console output I'm seeing strange results, Meteor seems to be confused about div ids? see the below image, the first div is both a and b, and yellow and grey at the same time ...

strange console output, contradicting id's

Does anybody know why this is? And how can I fix it?

I know I can just use helpers to get the correct text in the correct div. But my end goal is not to change the text inside divs, but instead I want to create a slider using nouislider like this:

var noUiSlider = require('nouislider');
var sliderHtmlElement = document.getElementById("a");
var options = {
    start: [0, 100],
    range: {
        'min': [0],
        'max': [100]
    }
};
noUiSlider.create(sliderHtmlElement, options);

My complete test code:

<template name="MyTemplate">
	{{x}}
	<br>
	<br>
	{{#each order}}
		<div class="{{label}}" id="{{label}}" 
        style="background-color: {{color}};">
            start value {{label}}
        </div>
		<br>
	{{/each}}
	<button class="test">test</button>
</template>

var order1;
var order2;

Template.MyTemplate.onCreated(function() {
	Session.set("x", 5);
	
	var or0 = [];
	or0["label"] = "a";
	or0["color"] = "yellow";

	var or1 = [];
	or1["label"] = "b";
	or1["color"] = "grey";

	order1 = [];
	order1[0] = or0;
	order1[1] = or1;
	
	order2 = [];
	order2[0] = or1;
	order2[1] = or0;
});

Template.MyTemplate.events({
	'click .test': function(event) {
		var varx = Session.get("x") + 1;
		Session.set("x", varx);
		createSliders();
	}
});

Template.MyTemplate.helpers({
	x: function() {
		return Session.get("x");
	},
	order: function() {
		if (Session.get("x") % 2 === 0) {
			return order1;
		} else {
			return order2;
		}
	}
});

function createSliders() {
	var ela = document.getElementById("a");
	var elb = document.getElementById("b");
	console.log(ela);
	console.log(elb);
	$("#" + ela.id).html("new value " + ela.id + " ");
	$("#" + elb.id).html("new value " + elb.id + " ");
}


Solution

  • With Blaze you have to explicitly import the .css file as well in order to get the styles applied:

    // no slider without css
    import 'nouislider/distribute/nouislider.css'
    import noUiSlider from 'nouislider'
    

    Then you can easily use the Template's builtin jQuery to get a target div where nouislider will render.

    Consider the following template:

    <template name="MyTemplate">
        <div>
            <div id="range"></div>
        </div>
        {{#if values}}
            <div>
                <span>values: </span>
                <span>{{values}}</span>
            </div>
        {{/if}}
        <button class="test">Show Slider</button>
    </template>
    

    Now let's render a new nouislider into the div with id range by clicking the button:

    Template.MyTemplate.events({
      'click .test': function (event, templateInstance) {
        createSliders(templateInstance)
      },
    })
    
    function createSliders (templateInstance) {
      // get the target using the template's jQuery
      const range = templateInstance.$('#range').get(0)
      noUiSlider.create(range, {
        start: [0, 100],
        range: {
          'min': [0],
          'max': [100]
        },
        connect: true
      })
    }
    

    Now you could also easily add some reactive data here (but avoid Session):

    Template.MyTemplate.onCreated(function () {
      const instance = this
      instance.state = new ReactiveDict()
      instance.state.set('values', null)
    })
    

    ... and connect it with some data. The noUiSlider allows you to hook into it's update event, from where you can pass the values into the state:

    function createSliders (templateInstance) {
      // get slider, render slider...
      // ...
      range.noUiSlider.on('update', function (values, handle) {
        // update values, for instance use a reactive dict
        templateInstance.state.set('values', values)
      })
    }
    

    Render the value into the template using a helper:

    Template.MyTemplate.helpers({
      values () {
        return Template.instance().state.get('values')
      }
    })
    

    Import your own .css files to statically style the slider like so:

    #range {
      width: 300px;
      margin: 14px;
    }
    

    or style it dynamically using jQuery's css.


    UPDATE: Correct rendering on updated display list

    The issue you described is correct and can be reproduced. However it can also be prevented, using Template.onRendered to control the point when rendering may occur.

    I extended the Template to the following code:

    <template name="MyTemplate">
        {{#each sliders}}
            <div class="range">
                <div>
                    <span>id:</span>
                    <span>{{this.id}}</span>
                </div>
                <div id="{{this.id}}">
                    {{#if ready}}{{slider this}}{{/if}}
                </div>
                {{#with values this.id}}
                    <div>
                    <span>values: </span>
                    <span>{{this}}</span>
                    </div>
                {{/with}}
            </div>
        {{/each}}
    
        <button class="test">Switch Sliders</button>
    </template>
    

    Now look inside the target div, which previously had only an id assigned. Now there is a {{#if ready}} flag and a function, call to {{slider this}}.

    Now in order to render only when the DOM has initially been rendered, we need Template.onRendered:

    Template.MyTemplate.onRendered(function () {
      const instance = this
      instance.state.set('ready', true)
    })
    

    and add it to the (updated) helpers:

    Template.MyTemplate.helpers({
      sliders () {
        return Template.instance().state.get('sliders')
      },
      values (sliderId) {
        return Template.instance().state.get('values')[sliderId]
      },
      slider (source) {
        createSliders(source.id, source.options, Template.instance())
      },
      ready() {
        return Template.instance().state.get('ready')
      }
    })
    

    Now we have some more issues here that need to be resolved. We only want to render if the switch changes but not if the values update. But we need the latest values in order to re-assign them as start position in the next render (otherwise the sliders would be set with the start values 0,100).

    To do that we change the onCreated code a bit:

    Template.MyTemplate.onCreated(function () {
    
      // initial slider states
      const sliders = [{
        id: 'slider-a',
        options: {
          start: [0, 100],
          range: {
            'min': [0],
            'max': [100]
          },
          connect: true
        }
      }, {
        id: 'slider-b',
        options: {
          start: [0, 100],
          range: {
            'min': [0],
            'max': [100]
          },
          connect: true
        }
      },
      ]
    
      const instance = this
      instance.state = new ReactiveDict()
      instance.state.set('values', {}) // mapping values by sliderId
      instance.state.set('sliders', sliders)
    })
    

    Now if we press the switch button want a) delete all current sliders with their events etc. and b) update the sliders data to our new (reversed) state:

    Template.MyTemplate.events({
      'click .test': function (event, templateInstance) {
    
        let sliders = templateInstance.state.get('sliders')
        const values = templateInstance.state.get('values')
    
        // remove current rendered sliders
        // and their events / prevent memory leak
        sliders.forEach(slider => {
          const target = templateInstance.$(`#${slider.id}`).get(0)
          if (target && target.noUiSlider) {
            target.noUiSlider.off()
            target.noUiSlider.destroy()
          }
        })
    
        // assign current values as
        // start values for the next newly rendered
        // sliders
        sliders = sliders.map(slider => {
          const currentValues = values[slider.id]
          if (currentValues) {
            slider.options.start = currentValues.map(n => Number(n))
          }
          return slider
        }).reverse()
    
        templateInstance.state.set('sliders', sliders)
      }
    })
    

    Because we have multiple slider values to be updated separately, we also need to change some code in the createSlider function:

    function createSliders (sliderId, options, templateInstance) {
      const $target = $(`#${sliderId}`)
      const target = $target.get(0)
    
      //skip if slider is already in target
      if ($target.hasClass('noUi-target')) {
        return
      }
    
      noUiSlider.create(target, options)
    
      target.noUiSlider.on('update', function (values, handle) {
        // update values by sliderId
        const valuesObj = templateInstance.state.get('values')
        valuesObj[sliderId] = values
        templateInstance.state.set('values', valuesObj)
      })
    }
    

    By using this approach you some advantages and some disadvantages.

    • (+) pattern can be used for many similar problems
    • (+) no autorun required
    • (+) separated slider state from value state
    • (-) rendering can occur more than once for a re-render, users wont notice but it wastes resources, which can be an issue on mobile.
    • (-) can become overly complex on larger templates. Encapsulation of Templates is very important here.