lcoq
lcoq

Reputation: 10726

How to replace `@computed` with setter returning new value with new native setters?

Problem

I've often used this kind of computed properties where the setter simply returns the new value :

  @computed('args.myValue')
  get myValue() {
    return this.args.myValue;
  }
  set myValue(newValue) {
    return newValue; // <==== this is no longer valid with native setter
  }

This does few things :

  1. Set initial value to args.myValue
  2. Allow to change the value (typically through an <Input @value={{this.myValue}} />)
  3. Restore the default value when args.myValue changes

The problem comes with native setters which can't return any value.

Notice I could probably find a "hackish" solution but I'd like to have code that follows new EmberJS conventions in order to avoid painfull later updates.

Things I tried

Manual caching

  @tracked _myValue = null;

  get myValue() {
    return this._myValue || this.args.myValue;
  }
  set myValue(newValue) {
    this._myValue = newValue;
  }

This does not work because _myValue is always set after the first myValue=(newValue). In order to make it work, there should be some kind of observer which resets it to null on args.myValue change.

Sadly, observers are no longer part of EmberJS with native classes.

{{unbound}} helper

<Input @value={{unbound this.myValue}} />

As expected, it does not work because it just doesn't update myValue.

{{unbound}} helper combined with event.target.value handling

<Input @value={{unbound this.myValue}} {{on "keyup" this.keyPressed}} />
  get myValue() {
    return this.args.myValue;
  }

  @action keyPressed(event) {
    this.doStuffThatWillUpdateAtSomeTimeMyValue(event.target.value);
  }

But the Input is still not updated when the args.myValue changes.

Initial code

Here is a more concrete use example :

Component

// app/components/my-component.js

export default class MyComponent extends Component {

  @computed('args.projectName')
  get projectName() {
    return this.args.projectName;
  }
  set projectName(newValue) {
    return newValue; // <==== this is no longer valid with native setter
  }

  @action
  searchProjects() {
    /* event key stuff omitted */
    const query = this.projectName;
    this.args.queryProjects(query);
  }
}
{{! app/components/my-component.hbs }}

<Input @value={{this.projectName}} {{on "keyup" this.searchProjects}} />

Controller

// app/controllers/index.js

export default class IndexController extends Controller {

  get entry() {
    return this.model.entry;
  }

  get entryProjectName() {
    return this.entry.get('project.name');
  }

  @tracked queriedProjects = null;

  @action queryProjects(query) {
    this.store.query('project', { filter: { query: query } })
      .then((projects) => this.queriedProjects = projects);
  }

  @action setEntryProject(project) {
    this.entry.project = project;
  }
}
{{! app/templates/index.hbs }}

<MyComponent 
  @projectName={{this.entryProjectName}} 
  @searchProjects={{this.queryProjects}} />

When the queriedProjects are set in the controller, the component displays them.

When one of those search results is clicked, the controller updates the setEntryProject is called.

Upvotes: 1

Views: 604

Answers (2)

IBue
IBue

Reputation: 321

There is a quite generic and concise approach to this 2-source binding scenario with any interactive input element and beyond.

Considering your first attempt (»Manual Caching«):

  • we have a functional feedback loop through the getter and setter; no return value from the setter is required since it unconditionally triggers a bound getter (this._myValue doesn't need to be tracked)
  • a switch is needed to let a changing external preset value (this.args.myValue) inject into this loop
  • this is accomplished by a GUID hashmap based on the preset value that establishes a transient scope for the interactive input; thus, changing preset value injections and interative inputs overwrite each other:
// app/components/my-component.js
import Component from '@glimmer/component';
import { guidFor } from '@ember/object/internals';

export default class extends Component {

    // external preset value by @stringArg
    _myValue = new Map();
    
    get myValue() {
        let currentArg = this.args.stringArg || null;
        let guid = guidFor(currentArg);
        if (this._myValue.has(guid)) {
            return this._myValue.get(guid)
        }
        else {
            this._myValue.clear(); // (optional) avoid subsequent GUID reuse of primitive types (Strings)
            return currentArg;
        }
    }

    set myValue(value) {
        this._myValue.set(guidFor(this.args.stringArg || null), value);
    }
}

// app/components/my-component.hbs
<Input @value={{mut this.myValue}} />

https://ember-twiddle.com/a72fa70c472dfc54d03d040f0d849d17

Upvotes: 0

lcoq
lcoq

Reputation: 10726

According to this Ember.js discussion :

Net, my own view here is that for exactly this reason, it’s often better to use a regular <input> instead of the <Input> component, and to wire up your own event listeners. That will make you responsible to set the item.quantity value in the action, but it also eliminates that last problem of having two different ways of setting the same value, and it also gives you a chance to do other things with the event handling.

I found a solution for this problem by using standard <input>, which seems to be the "right way" to solve it (I'll really appreciate any comment that tells me a better way) :

{{! app/components/my-component.hbs }}

<input value={{this.projectName}} {{on "keyup" this.searchProjects}} />
// app/components/my-component.js

@action
searchProjects(event) {
  /* event key stuff omitted */
  const query = event.target.value;
  this.args.queryProjects(query);
}

If I needed to keep the input value as a property, I could have done this :

{{! app/components/my-component.hbs }}

<input value={{this.projectName}} 
  {{on "input" this.setProjectQuery}} 
  {{on "keyup" this.searchProjects}} />
// app/components/my-component.js

@action setProjectQuery(event) {
  this._projectQuery = event.target.value;
}

@action
searchProjects( {
  /* event key stuff omitted */
  const query = this._projectQuery;
  this.args.queryProjects(query);
}

EDIT

Notice the following solution has one downside : it does not provide a simple way to reset the input value to the this.projectName when it does not change, for example after a focusout.

In order to fix this, I've added some code :

{{! app/components/my-component.hbs }}

<input value={{or this.currentInputValue this.projectName}}
  {{on "focusin" this.setCurrentInputValue}}
  {{on "focusout" this.clearCurrentInputValue}}
  {{on "input" this.setProjectQuery}} 
  {{on "keyup" this.searchProjects}} />
// app/components/my-component.js
// previous code omitted

@tracked currentInputValue = null;

@action setCurrentInputValue() {
  this.currentInputValue = this.projectName;
}

@action clearCurrentInputValue() {
  this.currentInputValue = null;
}

Upvotes: 1

Related Questions