Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to set initial state of glimmer component based on argument?

I am struggling to figure out how to implement data down, actions up in a glimmer component hierarchy (using Ember Octane, v3.15).

I have a parent component with a list of items. When the user clicks on a button within the Parent component, I want to populate an Editor component with the data from the relevant item; when the user clicks "Save" within the Editor component, populate the changes back to the parent. Here's what happens instead:

GIF of my app

How can I make the text box be populated with "Hello", and have changes persisted back to the list above when I click "Save"?

Code

{{!-- app/components/parent.hbs --}}
<ul>
{{#each this.models as |model|}}
    <li>{{model.text}} <button {{on 'click' (fn this.edit model)}}>Edit</button></li>
{{/each}}
</ul>

<Editor @currentModel={{this.currentModel}} @save={{this.save}} />
// app/components/parent.js
import Component from '@glimmer/component';
export default class ParentComponent extends Component {
    @tracked models = [
        { id: 1, text: 'Hello'},
        { id: 2, text: 'World'}
    ]
    @tracked currentModel = null;

    @action
    edit(model) {
        this.currentModel = model;
    }

    @action
    save(model) {
        // persist data
        this.models = models.map( (m) => m.id == model.id ? model : m )
    }
}
{{!-- app/components/editor.hbs --}}
{{#if @currentModel}}
<small>Editing ID: {{this.id}}</small>
{{/if}}
<Input @value={{this.text}} />
<button {{on 'click' this.save}}>Save</button>
// app/components/editor.hbs
import Component from '@glimmer/component';
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";

export default class EditorComponent extends Component {
    @tracked text;
    @tracked id;

    constructor() {
        super(...arguments)
        if (this.args.currentModel) {
            this.text = this.args.currentModel.text;
            this.id = this.args.currentModel.id;
        }
        
    }

    @action
    save() {
        // persist the updated model back to the parent
        this.args.save({ id: this.id, text: this.text })
    }
}

Rationale/Problem

I decided to implement Editor as a stateful component, because that seemed like the most idiomatic way to get form data out of the <Input /> component. I set the initial state using args. Since this.currentModel is @tracked in ParentComponent and I would expect re-assignment of that property to update the @currentModel argument passed to Editor.

Indeed that seems to be the case, since clicking "Edit" next to one of the items in ParentComponent makes <small>Editing ID: {{this.id}}</small> appear. However, neither the value of the <Input /> element nor the id are populated.

I understand that this.text and this.id are not being updated because the constructor of EditorComponent is not being re-run when currentModel changes in the parent... but I'm stuck on what to do instead.


What I've tried

As I was trying to figure this out, I came across this example (code), which has pretty much the same interaction between BlogAuthorComponent (hbs) and BlogAuthorEditComponent (hbs, js). Their solution, as applied to my problem, would be to write EditorComponent like this:

{{!-- app/components/editor.hbs --}}
{{#if this.isEditing}}
<small>Editing ID: {{@currentModel.id}}</small>
<Input @value={{@currentModel.text}} />
<button {{on 'click' this.save}}>Save</button>
{{/if}}
// app/components/editor.hbs
import Component from '@glimmer/component';
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";

export default class EditorComponent extends Component {
    get isEditing() {
        return !!this.args.currentModel
    }

    @action
    save() {
        // persist the updated model back to the parent
        this.args.save({ id: this.id, text: this.text })
    }
}

enter image description here

It works! But I don't like this solution, for a few reasons:

  • Modifying a property of something passed to the child component as an arg seems... spooky... I'm honestly not sure why it works at all (since while ParentComponent#models is @tracked, I wouldn't expect properties of POJOs within that array to be followed...)
  • This updates the text in ParentComponent as you type which, while neat, isn't what I want---I want the changes to be persisted only when the user clicks "Save" (which in this case does nothing)
  • In my real app, when the user is not "editing" an existing item, I'd like the form to be an "Add Item" form, where clicking the "Save" button adds a new item. I'm not sure how to do this without duplicating the form and/or doing some hairly logic as to what goes in <Input @value...

I also came across this question, but it seems to refer to an old version of glimmer.

Thank you for reading this far---I would appreciate any advice!

like image 807
caseygrun Avatar asked Feb 16 '20 06:02

caseygrun


Video Answer


1 Answers

To track changes to currentModel in your editor component and set a default value, use the get accessor:

get model() {
  return this.args.currentModel || { text: '', id: null };
}

And in your template do:

{{#if this.model.id}}
  <small>
    Editing ID:
    {{this.model.id}}
  </small>
{{/if}}
<Input @value={{this.model.text}} />
<button type="button" {{on "click" this.save}}>
  Save
</button>

Be aware though that this will mutate currentModel in your parent component, which I guess is not what you want. To circumvent this, create a new object from the properties of the model you're editing.

Solution:

// editor/component.js
export default class EditorComponent extends Component {
  get model() {
    return this.args.currentModel;
  }

  @action
  save() {
    this.args.save(this.model);
  }
}

In your parent component, create a new object from the passed model. Also, remember to reset currentModel in the save action. Now you can just check whether id is null or not in your parent component's save action, and if it is, just implement your save logic:

// parent/component.js
@tracked currentModel = {};

@action
edit(model) {
  // create a new object
  this.currentModel = { ...model };
}

@action
save(model) {
  if (model.id) {
    this.models = this.models.map((m) => (m.id == model.id ? model : m));
  } else {
    // save logic
  }

  this.currentModel = {};
}
like image 88
noobs4ibot1 Avatar answered Nov 03 '22 19:11

noobs4ibot1