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:
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 })
}
}
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!
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 = {};
}
Related
I have a Javascript complex data structure with 2 person fields - customer and payer (both are of type Person)
{
invoice: {
id: 123,
warehouseId: 456;
customer: {
id: 777,
name: "Coco"
}
payer: {
id: 778,
name: "Roro"
}
}
}
I am using child component for displaying Person object:
class ConnectedPersonFieldSet extends Component {
render () {
return
<div>
<div>{this.props.label}</div>
<div>{this.props.data.id}</div>
<div>{this.props.data.name}</div>
</div>
}
}
const PersonFieldSet = connect(mapStateToProps, mapDispatchToProps)(ConnectedPersonFieldSet);
export default PersonFieldSet;
And I have parent component that display full Invoice object and which has 2 child components for customer and payer respectively:
class ConnectedInvoice extends Component {
render () {
return
<div>
<div>{this.props.invoice.id}</div>
<div>{this.props.invoice.warehouseId}</div>
<PersonFieldSet label={"Customer" + /* this.props.customer.name */ } data={this.props.customer}></PersonFieldSet>
<PersonFieldSet label="Payer" data={this.props.payer}></PersonFieldSet>
</div>
}
}
const Invoice = connect(mapStateToProps, mapDispatchToProps)(ConnectedInvoice);
export default Invoice;
I have also complex logic that changes just invoice.customer.name. The updated customer name becomes visible in the Invoice component:
<div>{this.props.invoice.id}</div>
But, unfortunately, the
<PersonFieldSet label={"Customer" + /* this.props.customer.name */ } data={this.props.customer}></PersonFieldSet>
stays the same. If I uncomment /* this.props.customer.name */ then the updated customer.name becomes visible both in the label and in the name subcomponent of the PersonFieldSet.
So - my question is - why the child component, which receives the object, can not detect the change of the one attribute of this object and hence, does not update visual data upon the change of the one attribute of the object?
If the child component is able to feel somehow (e.g. via label={"..." + this.props.customer.name}) that the update of the attribute happened, then the child component displays the full update of all the attributes.
How to press the child component to detect that attributes can change the forwarded object?
I have read (e.g. React: why child component doesn't update when prop changes) that there is a trick with (more or less redundant) key attribute of the child element, but is this really my case?
My understanding is that React should support the hierarchical composition of both visual components and data components and do it without tricks or any other intrigues, but how to handle my situation? Should I really start to use hacks (key or others) in this situation that is pretty standard?
Added:
I am using Redux architecture for making updates - currently I am testing update of just one field - name:
const rootReducer = (state = initialState, action) => {
switch(action.type) {
case UPDATE_INVOICE_CUSTOMER: {
let person_id = action.payload.person_id;
let data = {
invoice: state.invoice
}
let newData = updateInvoiceByCustomer(data, person_id);
return {...state,
invoice: newData.invoice,
}
}
}
}
export function updateInvoiceByCustomer(data, person_id) {
let newData = {
invoice: data.invoice,
}
/* This will be replaced by the complex business logic, that retrieves
customer from the database using person_id and afterwards complex
calculations are done on the invoice, e.g. discounts and taxes
are assigned according to the rules relevant for the specific
customer. Possible all this code will have to be moved to the chain
of promises */
newData.invoice.customer.name='Test';
return newData;
}
Thanks #Yoshi for comments on my question and for persisting to check my Redux update logic. Indeed, when I have removed all the copying-update logic (which should be corrected to use cloning) and replaced it by:
return {
...state,
['invoice']: {...state['invoice'],
['customer']: {...state['invoice']['customer'],
['name']: 'REAL-TEST',
}
}
}
Then child component started to re-render and to show the actual value without any hacks or use of key-attributes. So, that was the cause of error.
Here's how my code is structured: parent component shuffles through child components via v-if directives, one of the child components is using a state to define its data. Everything works except when I switch between the child components. When I get back, no data can be shown because the state has become null.
Parent component:
<template>
<div>
<Welcome v-if="view==0" />
<Courses v-if="view==1" /> //the component that I'm working on
<Platforms v-if="view==2" />
</div>
</template>
Courses component:
<template>
<div>Content</div>
</template>
<script>
export default {
name: 'Courses',
computed: {
...mapState([
'courses'
])
},
data () {
return {
courseList: [],
len: Number,
}
},
created () {
console.log("state.courses:")
console.log(this.courses)
this.courseList = this.courses
this.len = this.courses.length
},
}
</script>
Let say the default value for "view" is 1, when I load the page, the "Courses" component will be shown (complete with the data). If I click a button to change the value of "view" to 0, the "Welcome" component is shown. However, when I tried to go back to the "Courses" component, the courses component is rendered but is missing all the data.
Upon inspection (via console logging), I found that when the "Courses" component was initially rendered, the state was mapped correctly and I could use it, but if I changed the "view" to another value to render another component and then changed it back to the original value, the "Courses" component still renders but the state became undefined or null.
EDIT: Clarification.
Set courseList to a component name and use <component>
and set some enum
eg.
<template>
<component :is="this.viewState" />
</template>
<script>
export default {
// your stuff
data() {
return {
viewState: 'Welcome',
}
},
methods: {
getComponent(stateNum) {
const States = { '1': 'Welcome', '2': 'Courses', '3': 'Platforms' }
Object.freeze(States)
return States[stateNum]
}
},
created() {
// do your stuff here
const view = someTask() // someTask because I don't get it from where you're getting data
this.viewState = this.getComponent(view)
}
}
</script>
I don't actually understood correctly but here I gave some idea for approaching your problem.
I suffer the third day. Help me please. How in vuex.js save the text entered into the input in the "store", and then add it to the Value of the same input itself.
I'm trying to do it like this but somewhere I make a mistake.
HTML
<f7-list-input
label="Username"
name="username"
placeholder="Username"
type="text"
:value="newUserName"
#input="username = $event.target.value"
required validate
pattern="[3-9a-zA-Zа-яА-ЯёЁ]+"
v-model="saveUserName"
/>
SCRIPT
export default {
data() {
return {
username: '',
password: '',
};
},
methods: {
signIn() {
const self = this;
const app = self.$f7;
const router = self.$f7router;
router.back();
app.dialog.alert(`Username: ${self.username}<br>Password: ${self.password}`, () => {
router.back();
});
},
saveUserName(){
this.$store.commit(saveName);
}
},
computed:{
userName(){
return this.$store.state.newUserName;
}
}
};
STORE
export default new Vuex.Store({
state:{
userNameStor: 'newUserName',
userPasswordStor:''
},
mutations:{
saveName(state){
userNameStor:newUserName;
return newUserName;
}
}
});
Let's explain the whole functionnality, and then some code.
The input is in the template part of a component.
The component contain also a script part, which trigger code based on template events and so on.
The component code can trigger mutations (for state change), which are the way you store something in the store.
you have this screen to store flow:
1/ component template event => 2/ component script code => 3/ execute mutation on store
For the other side, you have this:
state => mapMutation in component computed property => component template.
Inside component script you can map a store value into a computed property of the component (with mapMutations helper). Then you map this field in your template from the component computed property.
1 - Your template
#input is the event occuring when input change by user action.
:value is the value of the input, defined programmatically.
v-model is a shorcut for using #input and :value at the same time. Don't use it with :value and #input.
Ok minimal Template:
<f7-list-input
type="text"
:value="username"
#input="changeUsername"
/>
Inside the script, you just have to link the changeUsername method to the mutation (with mapMutation), and also define a computed property whose name is username and that is a map of username from the store (with mapState).
import {mapState, mapMutations} from "vuex"
export default {
methods:{
...mapMutations({
changeUsername:"saveName"
},
computed:{
...mapState({
username:state=>state.username
}),
}
};
Consider looking at Vue doc about this mutations and state
I have a list of instruments that should render a c-input with autosuggest window when the user types something. Also, I need an option for c-input to add or remove autosuggest component.
/* instrument component */
<template>
<c-input ref="input"
:values="inputValue"
:placeholder="placeholder"
#input="onInput"
#change="onChangeInput"
#reset="reset" />
<autosuggest
v-if="showSuggests"
:inputValue="inputValue"
:suggests="suggests"
#onSelectRic="selectRicFromList"
></autosuggest>
</div>
</template>
<script>
export default {
name: 'instrument',
data: () => ({
suggests: [],
inputValue: '',
}),
computed: {
showSuggests() {
return this.isNeedAutosuggest && this.showList;
},
showList() {
return this.$store.state.autosuggest.show;
},
isloading() {
return this.$store.state.instruments.showLoading;
},
defaultValue() {
if (this.instrument.name) {
return this.instrument.name;
}
return '';
},
},
[...]
};
</script>
This is a parent component:
<template>
<div>
<instrument v-for="(instrument, index) in instruments"
:key="instrument.name"
:instrument="instrument"
:placeholder="$t('change_instrument')"
:isNeedAutosuggest="true" /> <!--that flag should manage an autosuggest option-->
<instrument v-if="instruments.length < maxInstruments"
ref="newInstrument"
:isNeedAutosuggest="true" <!-- here too -->
:placeholder="$t('instrument-panel.ADD_INSTRUMENT')" />
</div>
</template>
The main issues are I have so many autosuggests in DOM as I have instruments. In other words, there is should be 1 autosuggest component when the option is true. Moving autosuggest to the parent level is not good because of flexibility and a lot of logically connected with c-input.
Have you any ideas to do it?
[UPDATE]
Here is how I've solve this;
I created an another component that wraps input and autosuggest components. If I need need an input with autosuggest I will use this one, either I will use a simple input.
/* wrapper.vue - inserted into the Instrument.vue*/
<template>
<span>
<fc-input ref="input"
:values="value"
:placeholder="placeholder"
:isloading="isloading"
#input="onInput"
#changeInput="$emit('change', $event)"
#resetInput="onResetInput" />
<fc-autosuggest
v-if="isSuggestsExist"
:suggests="suggests"
/>
</span>
</template>
You can do it if you create a function inside each instrument component, which will call the parent component and search the first component instrument to find autosuggest. Function will be like that:
name: 'instrument',
...
computed: {
autosuggestComponent () {
// this is a pseudo code
const parentChildrenComponents = this.$parent.children();
const firstChild = parentChildrenComponents[0];
const autosuggestEl = firstChild.$el.getElementsByTagName('autosuggest')[0];
// return Vue component
return autosuggestEl.__vue__;
}
},
methods: {
useAutosuggestComponent () {
this.autosuggestComponent.inputValue = this.inputValue;
this.autosuggestComponent.suggests = [{...}];
}
}
This solution is not so beautiful, but it allows to keep the logic inside the instrument component.
But my advice is create some parent component which will contain instrument components and I suggest to work with autosuggest through the parent. You can create autosuggest component in the parent and pass it to the children instruments. And if instrument doesn't receive a link to a autosuggest (in props), than it will create autosuggest inside itself. It will allow to use instrument for different conditions.
Let me know if I need to explain my idea carefully.
I'm building a small vue application where among other things it is possible to delete an entry of a music collection. So at this point I have a list of music albums and next to the entry I have a "delete" button. When I do the following:
<li v-for="cd in cds">
<span>{{cd.artist}} - {{cd.album}}</span> <button v-on:click="deleteAlbum(cd.ID)">Delete</button>
</li>
and then in my methods do:
deleteAlbum(id){
this.$http.delete('/api/cds/delete/'+id)
.then(function(response){
this.fetchAll()
// });
},
this works fine so far, but to make it more nice, I want the delete functionality to appear in a modal/popup, so I made the following changes:
<li v-for="cd in cds">
<div class="cd-wrap">
<span>{{cd.artist}} - {{cd.album}}</span>
<button #click="showDeleteModal({id: cd.ID, artist: cd.artist, album: cd.album})" class="btn">Delete</button>
</div>
<delete-modal v-if="showDelete" #close="showDelete = false" #showDeleteModal="cd.ID = $event"></delete-modal>
</li>
so, as seen above I created a <delete-modal>-component. When I click on the delete button I want to pass the data from the entry to <delete-modal> component with the help of an eventbus. For that, inside my methods I did this:
showDeleteModal(item) {
this.showDelete = true
eventBus.$emit('showDeleteModal', {item: item})
}
Then, in the <delete-modal>, inside the created()-lifecycle I did this:
created(){
eventBus.$on('showDeleteModal', (item) => {
console.log('bus data: ', item)
})
}
this gives me plenty of empty opened popups/modals!!??
Can someone tell me what I'm doing wrong here?
** EDIT **
After a good suggestion I dumped the eventBus method and pass the data as props to the <delete-modal> so now it looks like this:
<delete-modal :id="cd.ID" :artist="cd.artist" :album="cd.album"></delete-modal>
and the delete-modal component:
export default {
props: ['id', 'artist', 'album'],
data() {
return {
isOpen: false
}
},
created(){
this.isOpen = true
}
}
Only issue I have now, is that it tries to open a modal for each entry, how can I detect the correct ID/entry?
I am going to show you how to do it with props since it is a parent-child relation.I will show you a simple way of doing it.You need to modify or add some code of course in order to work in your app.
Parent component
<template>
<div>
<li v-for="cd in cds" :key="cd.ID">
<div class="cd-wrap">
<span>{{cd.artist}} - {{cd.album}}</span>
<button
#click="showDeleteModal({id: cd.ID, artist: cd.artist, album: cd.album})"
class="btn"
>
Delete
</button>
</div>
<delete-modal v-if="showDelete" :modal.sync="showDelte" :passedObject="objectToPass"></delete-modal>
</li>
</div>
</template>
<script>
import Child from 'Child'
export default {
components: {
'delete-modal': Child
},
data() {
return {
showDelete: false,
objectToPass: null,
//here put your other properties
}
},
methods: {
showDeleteModal(item) {
this.showDelete = true
this.objectToPass = item
}
}
}
</script>
Child Component
<template>
/* Here put your logic component */
</template>
<script>
export default {
props: {
modal:{
default:false
},
passedObject: {
type: Object
}
},
methods: {
closeModal() { //the method to close the modal
this.$emit('update:modal')
}
}
//here put your other vue.js code
}
</script>
When you use the .sync modifier to pass a prop in child component then,there (in child cmp) you have to emit an event like:
this.$emit('update:modal')
And with that the modal will close and open.Also using props we have passed to child component the object that contains the id and other stuff.
If you want to learn more about props, click here