Named arguments don't work with default values?


#1

Hi,

Ember 3.1 introduced named arguments in components and I wanted to give it a try in a component I wrote. The component implements a musician form and is therefore called musician-form :slight_smile: It has the following API:

// new.hbs
{{musician-form
    musician=model
    bands=bands
    selectedBands=selectedBands
    on-submit=createMusician}}

As is often the case, the form is used both on a new and an edit page. In the latter case, when it’s used to update an already existing musician, the selectedBands is not passed in as I want the bands related to the musician to be pre-selected:

// edit.hbs
{{musician-form musician=model bands=bands on-submit=saveMusician}}

Let’s actually see how it is used in the component’s template:

{{!-- app/templates/components/musician-form.hbs --}}
{{#power-select-multiple
  options=bands
  selected=selectedBands
  (...)
  onchange=(action (mut selectedBands)) as |band|}}
  {{band.name}}
{{/power-select-multiple}}

The component initializes the selectedBands if it’s not passed in by the caller, to the bands the musician already belongs to:

// app/components/musician-form.js
export default Component.extend({
  async init() {
    this._super(...arguments);
    if (!this.selectedBands) {
      let bands = await this.musician.get('bands');
      this.set('selectedBands', bands.toArray());
    }
  },
  (...)
});

That works wonderfully.

Let’s switch to named arguments:

{{!-- app/templates/components/musician-form.hbs --}}
{{#power-select-multiple
  options=@bands
  selected=@selectedBands
  (...)
  onchange=(action (mut selectedBands)) as |band|}}
  {{band.name}}
{{/power-select-multiple}}

The first case, passing in selectedBands on the new page still works, but when selectedBands is not passed in, the component breaks – no band is pre-selected for the dropdown, even though the init method still runs the this.set('selectedBands', bands.toArray()) line just fine. So it seems like setting an attribute in the component’s code when it’s not passed in doesn’t make it available as a named argument, so setting it to a default value is not possible. (Note that selected=this.selectedBands works!)

The other thing to note is that (mut @selectedBands) is not possible either, an error is thrown (Assertion Failed: You can only pass a path to mut).

Do you know of a way to make setting default values to arguments work with named arguments? I find it’s a really useful pattern and would love to have it work with this new feature. Thank you.


#2

I had this discussion with @rwjblue and it seems like this is the intended behavior. One reason is because with @myArg you know that comes directly from outside of the component, so any defaults kind of mess with that.

The suggested ways to do default values are using something like

  • Use init hook and set a new value (e.g. myUpdatedArg) using the passed in and the default value
  • Use getters (IMO should be used more in Ember code)
  • {{or @myArg 'default'}}

With the second option, you get the benefit of using {{this.myUpdatedArg}} which you know might have been changed in the component.js.

I think there is some room for the addon ecosystem to do something about better DX. I think https://github.com/ciena-blueplanet/ember-prop-types is positioned really well for this (or maybe ember-decorators).


#3

Thank you for your swift reply. Being able to see at a glance what comes from outside the component indeed has some benefit, I’ll admit.

To set a default value, what kind of getters do you mean?


#4

Something like

get myUpdatedArg() {
  return defaultTo(this.myArg, 'defaultValue');
}

Using https://lodash.com/docs/4.17.10#defaultTo in the example above.

Although not sure if there are any caveats/differences between Ember.Object and ES Classes when using this method. cc @pzuraq


#5

I wrote up a bit about this in this issue thread:


#6

A couple more thoughts/questions as I work with named arguments:

  1. As we’re still passing in arguments the same way from the caller (for example, {{musician-form maxBands=3 (...)}}), there’s no way to prevent component code from mutating the passed in value, even though the use of the @ sigil in the component’s template should warn against this. I can do this.maxBands += 1 in the component’s init and then {{@maxBands}} in the template will render 4, not the passed in value.

I don’t think Ember can prevent this, but it might be something we want to teach people about (and as I’m writing the advanced level sequel of the R&R book, I should do that, if I’m right).

  1. I have a hard time seeing why using named arguments in the template doesn’t work in some contexts. I’ve found these ones not to work:
  • (mut @selectedBands) – prints “Assertion Failed: You can only pass a path to mut”
  • {{v-get @musician 'name' 'message'}} – Doesn’t log anything but the validation error message is simply not rendered (works with passing musician to v-get). v-get comes from the ember-cp-validations add-on, which makes me wonder whether add-on authors need to do something so that their components/helpers also work when named arguments are passed into them.

Having to use both the @ and the plain form in the same template could be confusing and I also didn’t know where I can safely use the @ form before trying.


#7

Could you please provide a reproduction? As far as I understand named arguments that should be considered a bug. Here is an ember-twiddle showing how it should work: https://ember-twiddle.com/c4a631b4b78ee7a093319f5a1abe691e?numColumns=2&openFiles=templates.application.hbs%2Ccomponents.my-component.js


#8

I must have done something wrong because you’re right, {{@maxBands}} still refers to the original value so the @ creates an immutable reference.

That might also explain why mut throws an error as @selectedBands is a read-only reference and mut by definition updates the passed-in reference.

It might also explain why {{v-get @musician 'name' 'message'}} doesn’t work. The passed in @musician refers to the original version of the object that’s passed in and when the underlying musician object is updated (when ember-cp-validations sets a validation error on it), the {{v-get}} doesn’t re-render because it’s passed an immutable reference that never changes.

In this way, {{@musician}} is the same as {{unbound musician}}, isn’t it?


#9

An unbound binding does not update after the initial rendering, which is not what happens with named arguments. {{@musician}} is more like as if the argument was passed to the component as read-only: {{musician-model musician=(read-only model)}}.


#10

You’re right that’s an important difference, thank you for pointing it out.


#11

Just want to point out that you can switch the calling side too:

<MusicianForm @maxBands=3 />

This is polyfilled all the way back to Ember 2.12 and implemented natively in 3.4.


#12

Looks great, thank you (I didn’t even know 3.3, and thus 3.4-beta was out), I’ll play around with switching the call to angle-bracket component.


#13

So, I keep coming back to this post (thanks for it @balint) as I use named arguments more and try and fill a few gaps in my knowledge. In terms of default arguments, this is where my current thinking and approach is. Is anyone able to indicate whether I am headed in a favourable direction?

Firstly, I have added a new helper to our app, {{default-to}}. No doubt there is something like this already that I’m not aware of so please let me know if this is the case. It just checks if a value is undefined and if so, returns a default value.

So, now in my component JS, I have (this is a contrived example):

export default Component.extend({
  title: 'FooBar'
});

and in my template:

<h1>{{default-to @title this.title}}</h1>

So, my understanding here is that if @title is provided when consuming this addon, then the value passed in will be referenced without referencing the component JS value. If @title is not provided then this.title will be used which is referencing the property defined on the component JS.

Is this a sensible approach and is my understanding of how things work accurate?


#14

While that might work (does it?), I think it’s potentially quite confusing.

In {{default-to @title this.title}}, this.title will also be the value of title if it was passed in but that might be fine as default-to resolves to @title if it exists. However, the fact that I had to think in some length about it shows that it might be confusing (or it’s just Friday night and I’m tired :slight_smile: )

If you want to define a default value in the component (for all invocations of the component, I can think of a few ways:

  1. Use another name: {{default-to @title defaults.title}} (where defaults would be a hash storing the default values)
  2. Don’t use a named argument for an argument you want to have a default value for:
export default Component.extend({
  init() {
    this._super(...arguments);
    if (!this.title) {
      this.set('title', 'Foobar');
    }
  }
});

Then in the template just use: <h1>{{this.title}}</h1>

BTW, I think the or helper would serve just as fine for what defaults-to does so you can even spare the component definition and have this in the template:

<h1>{{or @title "FooBar"}}</h1>

#15

Ooh, I like this.

Hmm…I’m not a fan of the mix of named and non named arguments. I like being able to look at a template and easily see what was passed in by the consumer and what is internal. It’s this exact thing I’m trying to avoid by my current proposed pattern.

Yep, true. I was trying to be a but more “intention revealing” but maybe it’s overkill.

I like the idea of the defaults object. I’m going to explore this to see if it makes sense and is practical.

Thanks mate.


#16

I think I get your point but you can look at this as follows:

  • If you see a named argument used in a component’s template you know you don’t have to look in the component file to find out what the value is (just the call site).
  • If you see a non-named argument, you might have to look in the component file, too (and if everybody used named arguments everywhere where it’s possible, you’d know for sure).

You can still use both named and non-named arguments because you can signal the intent of where to look for the value very clearly to the reader. On top of that, even with {{defaults-to @title defaults.title}} you have to look into the component file to find out what the value of that expression is.

Anyway, I don’t want to dissuade you from using the first solution I’d proposed :slight_smile: