javascriptember.jsember-bootstrap

Click events on Ember


I'm discovering EmberJS and started to migrate an existing website to this framework. I was having an issue with a Bootstrap-based dropdown. This issue actually helped me understand Ember's concepts a bit better but I still have some questions.

I used the ember-bootstrap module to generate this dropdown (among other things) and here is what the code is supposed to be:

{{#bs-dropdown as |dd|}}
  {{#dd.button}}
    Sort by
  {{/dd.button}}

  {{#dd.menu as |ddm|}}
    {{#ddm.item}}{{#ddm.link-to "index"}}Price low to high{{/ddm.link-to}}{{/ddm.item}}
    {{#ddm.item}}{{#ddm.link-to "index"}}Price high to low{{/ddm.link-to}}{{/ddm.item}}
  {{/dd.menu}}
{{/bs-dropdown}}

Now, I want some javascript code to be executed when the user clicks on one of the items. After checking the module's documentation, I found where the menu item component was defined and edited its code as follows:

export default Component.extend({
  layout,
  classNameBindings: ['containerClass'],

  /* ... */

  actions: {
    // My addition
    sortByPrice(param){
      alert("sorting");
    },
    // End of the addition

    toggleDropdown() {
      if (this.get('isOpen')) {
        this.send('closeDropdown');
      } else {
        this.send('openDropdown');
      }
    },
  },
});

Then I updated the hbs file as follows:

{{#dd.menu as |ddm|}}
   {{#ddm.item action "sortByPrice" low_to_high}}

    {{#ddm.link-to "index"  action "sortByPrice" low_to_high}}
      Prix croissant
    {{/ddm.link-to}}

  {{/ddm.item}}
{{/dd.menu}}

This didn't work, and that's why you I added the *action* to the link-to element as well and declared similarly the action on its component file.

import LinkComponent from '@ember/routing/link-component';

export default LinkComponent.extend({
  actions: {
    sortByPrice(param){
        alert("sorting");
      console.log("sorting");
      },
  },
});

As you can see, the *link-to* component extends the LinkComponent one. I eventually understood that it wasn't possible for this element to handle click events natively, as explained in this thread.

Out of frustration, I ended up with a less elegant approach that still does the trick:

{{#bs-dropdown id="sort" as |dd|}}
  {{#dd.button}}
    Sort by
  {{/dd.button}}

  {{#dd.menu as |ddm|}}
    {{#ddm.item action "sortByPrice" low_to_high}}
      <a
        class="dropdown-item"
        onclick="sortByPrice('low_to_high'); return false;"
        href="#"
      >
        Price low to high
      </a>
    {{/ddm.item}}
  {{/dd.menu}}
{{/bs-dropdown}}

Now here are my questions:

  1. Why is it that defining actions on both the Component file and the hbs one didn't change the result?
  2. Why doesn't the LinkComponent handle click events natively? I get that a link is supposed to redirect users to a new page (which is still arguable), but the DOM event is still fired, so does Ember deliberately ignore it and choose not to let developers handle it? I want to know the logic behind this.
  3. Is there a better approach than my solution?

Thanks.


Solution

  • Cheers for studying EmberJS and posting a beautiful, explicit question!

    Your mistakes

    1. Never modify the code inside node_modules/ and bower_components/ folders. If you really need to monkey-patch something, you can do it in an initializer. But your use case does not require monkey patching.

    2. You attempted to define an action in the menu item component, but you apply it in a parent template. That action has to be defined in that parent's template component/controller.

    3. This invocation is incorrect:

      {{#ddm.link-to "index"  action "sortByPrice" low_to_high}}
      

      Here are the problems:

      1. The ddm.link-to component is supposed to create a link to another route. It does not seem to support passing an action into it.

      2. You're just passing a bunch of positional params to the component. If ddm.link-to did support accepting an action, the correct invocation would look like this:

        {{#ddm.link-to "index" argName=(action "sortByPrice" low_to_high)}}
        

        In this case, "index" is a position param and argName is a named param.

      3. low_to_high without quotes is a reference to a property defined on the current scope. You probably meant a string instead: "low_to_high".

    4. Never use JS code in template directly. This you should never do in Ember:

      <a onclick="sortByPrice('low_to_high'); return false;">
      

      Instead, pass an action (defined in the local scope: in a component or controller):

      <a onclick={{action 'sortByPrice' 'low_to_high'}}>
      

      The onclick property name is optional. An action defined without a property implies onclick (you only need to provide the property name if you need to attach the action to a different event):

      <a {{action 'sortByPrice' 'low_to_high'}}>
      

      For the link to be styled properly in a browser, a href attribute is required. But you don't have to pass a value '#' to it. The hash symbol was required in old-school apps to prevent the link from overwriting the URL. Ember overrides URL overwriting for you, so you can simply pass an empty href.

      Here's the final correct usage:

      <a href {{action 'sortByPrice' 'low_to_high'}}>
      

    Answers to your questions

    1. Why is it that defining actions on both the Component file and the hbs one didn't change the result?

    Because you defined them in different scopes.

    If you define an action in app/components/foo-bar.js, the action must be applied in app/templates/components/foo-bar.hbs.

    If you define an action in app/controllers/index.js, the action must be applied in app/templates/index.hbs.

    1. Why doesn't the LinkComponent handle click events natively? I get that a link is supposed to redirect users to a new page (which is still arguable), but the DOM event is still fired, so does Ember deliberately ignore it and choose not to let developers handle it? I want to know the logic behind this.

    In a PWA, you do not do actual page redirects. Such a redirect would reload the whole app.

    Instead, the LinkComponent overrides the click and tell the Ember's routing system to perform a transition. Routes must be set up properly and the route passed to the LinkComponent must exist.

    It seems that your goal is not to perform a transition but to change a variable, so the LinkComponent is not applicable here. That's unless you wire the sort order property to an URL query param, in which case you can change the sort order by making a transition to a different query param.

    1. Is there a better approach than my solution?

    See below for the simplest approach that uses ember-bootstrap's dropdown.


    A working example

    Controller:

    export default Ember.Controller.extend({
      isSortAccending: true,
    
      actions: {
        changeSortDirection (isSortAccending) {
          this.set('isSortAccending', isSortAccending);
        }
      }
    });
    

    Template:

    <p>
      Current sort order:
      {{if isSortAccending "ascending" "descending"}}
    </p>
    
    {{#bs-dropdown as |dd|}}
      {{#dd.button}}
        Sort by
      {{/dd.button}}
    
      {{#dd.menu as |ddm|}}
        {{#ddm.item}}
          <a href {{action "changeSortDirection" true}}>
            Price high to low
          </a>
        {{/ddm.item}}
    
        {{#ddm.item}}
          <a href {{action "changeSortDirection" false}}>
            Price high to low
          </a>
        {{/ddm.item}}
      {{/dd.menu}}
    {{/bs-dropdown}}
    

    Here's a working demo.