Is there a better way to share data between dynamically loaded components? - vue.js

I recently built a small application with Vue.js and Express.js. There are few components needed to be prepared by the servers, e.g., the combobox for Article.Category and Article.User. The options for these 2 components needed to be rendered from server. I use <component /> as the placeholder for these 2 components in the article edit form:
<component v-bind:is="user_selection_component"></component>
<component v-bind:is="category_selection_component"></component>
I use the template string for initialising the components, the template string result.data.template is passed by server:
let org_data = original_store;
let new_data = () => {
org_data['remote_options'] = result.data.remote_options;
//if there is any default value, then assign the value to field referred by "model_name"
if(model_name && result.data.preset_value){
let previous_value = $shared.index(org_data, model_name);
if(!previous_value){
$shared.index(org_data, model_name, result.data.preset_value);
}
}
return org_data;
}
var default_cb = ()=>{console.info('['+model_name+'].default_cb()')};
let TempComponent = {
template: result.data.template,
methods: component_ref.methods,
data: new_data,
mounted: cb !== null ? cb.bind(this, org_data) : default_cb.bind(this)
};
app[mount_component] = TempComponent;
Here is the problem, the data method returns a new Observable store for the dynamically loaded components, they don't share the same store object with the parent component, which is the article edit from. Hence, if I want to modify the category field value or user field value, I have to let the callback function cb to accept the store objects of these 2 dynamically loaded components. Otherwise, from the parent component, I could not modify the values in these 2 components.
So I came up with a temporary workaround, I passed the setter method as the callback function to these dynamically loaded functions:
let set_user_id = null;
let set_cate_id = null;
(org_store) => { set_user_id = (new_id) => { org_store.form.user_id = new_id; }}
(org_store) => { set_cate_id = (new_id) => {org_store.form.category_id = new_id; }}
After I load other components or anytime I want to set the category/user value, I can just call set_user_id($new_user_id) or set_cate_id($new_category_id);
I don't like this work around at all. I tried to use the event handler to emit the new values into these 2 components. But I couldn't access these 2 dynamically loaded component via $ref. Is there a better way to let data be shared between dynamically loaded components? Thanks.

If your components will accept props, you can localize your event bus, which is a little nicer than having a global. The parent component creates the bus as a data item:
data() {
...
bus: new Vue()
}
The components accept it as a prop:
<component v-bind:is="user_selection_component" :bus="bus"></component>
<component v-bind:is="category_selection_component" :bus="bus"></component>
and you use it as in your answer, except referring to this.bus instead of just bus.

I don't think what I have now is the best solution. After consulting with other people, I took event bus as the better solution. So I modified my code as:
In my init component:
let org_data = original_store;
let new_data = () => {
org_data['remote_options'] = result.data.remote_options;
//if there is any default value, then assign the value to field referred by "model_name"
if(model_name && result.data.preset_value){
let previous_value = $shared.index(org_data, model_name);
if(!previous_value){
$shared.index(org_data, model_name, result.data.preset_value);
}
}
return org_data;
}
var default_cb = () => { console.info('['+model_name+'].default_cb()') };
let TempComponent = {
template: result.data.template,
methods: component_ref.methods,
data: new_data,
mounted: cb !== null ? cb.bind(this, org_data) : default_cb.bind(this)
};
bus.$on('set.' + model_name, function(value){
$shared.index(org_data, model_name, value);
});
The difference is here:
bus.$on('set.' + model_name, function(value){
$shared.index(org_data, model_name, value);
});
The bus is the common event bus created by Vue():
let bus = new Vue()
From the parent component, I can just use this event bus to emit the event:
bus.$emit('set.form.user_id', this.form.user_id);
I do feel better after changing to this solution. But I still appreciate if there is an even better way. Thanks.

Related

Event only firing as inline JS statement

I have the following code in a Nuxtjs app in SSR mode.
<Component
:is="author.linkUrl ? 'a' : 'div'"
v-bind="!author.linkUrl && { href: author.linkUrl, target: '_blank' }"
#click="author.linkUrl ? handleAnalytics() : null"
>
The click event in case it's an a tag, will only fire if it's written as handleAnalytics(), but handleAnalytics will not work.
Don't get me wrong the code is working, but I don't understand why.
With classical event binding (#click="handleAnalytics), Vue will auto bind it for you because it sees it's a function.
But when provided a ternary condition, it's not auto binded but wrapped into a anonymous function instead. So you have to call it with parenthesis otherwise you're just returning the function without executing it.
To be clearer, you can write it this way: #click="() => author.linkUrl ? handleAnalytics() : null"
Note: when having a dynamic tag component, I'd suggest to use the render function instead.
This is an advanced technique, but this way you won't bind things to an element that doesn't need it (without having the kind of hack to return null).
Example:
export default {
props: {
author: { type: Object, required: true },
},
render (h: CreateElement) {
const renderLink = () => {
return h('a', {
attrs: {
href: author.linkUrl,
target: '_blank',
},
on: {
click: this.handleAnalytics
},
)
}
const renderDiv = () => {
return h('div')
}
return this.author.linkUrl ? renderLink() : renderDiv()
}
}
Documention: Vue2, Vue3
In javascript functions are a reference to an object. Just like in any other language you need to store this reference in memory.
Here are a few examples that might help you understand on why its not working:
function handleAnalytics() { return 'bar' };
const resultFromFunction = handleAnalytics();
const referenceFn = handleAnalytics;
resultFromFunction will have bar as it's value, while referenceFn will have the reference to the function handleAnalytics allowing you to do things like:
if (someCondition) {
referenceFn();
}
A more practical example:
function callEuropeanUnionServers() { ... }
function callAmericanServers() { ... }
// Where would the user like for his data to be stored
const callAPI = user.preferesDataIn === 'europe'
? callEuropeanUnionServers
: callEuropeanUnionServers;
// do some logic
// ...
// In this state you won't care which servers the data is stored.
// You will only care that you need to make a request to store the user data.
callAPI();
In your example what happens is that you are doing:
#click="author.linkUrl ? handleAnalytics() : null"
What happens in pseudo code is:
Check the author has a linkUrl
If yes, then EXECUTE handleAnalytics first and then the result of it pass to handler #click
If not, simply pass null
Why it works when you use handleAnalytics and not handleAnalytics()?
Check the author has a linkUrl
If yes, then pass the REFERENCE handleAnalytics to handler #click
If not, simply pass null
Summary
When using handleAnalytics you are passing a reference to #click. When using handleAnalytics() you are passing the result returned from handleAnalytics to #click handler.

Vuejs - update array of an object which is in an array

I'm developing a helpdesk tool in which I have a kanban view.
I previously used nested serializers in my backend and I managed to have everything working with a single query but it's not scalable (and it was ugly) so I switched to another schema :
I query my helpdesk team ('test' in the screenshot)
I query the stages of that team ('new', 'in progress')
I query tickets for each stage in stages
So when I mount my component, I do the following :
async mounted () {
if (this.helpdeskTeamId) {
await this.getTeam(this.helpdeskTeamId)
if (this.team) {
await this.getTeamStages(this.helpdeskTeamId)
if (this.stages) {
for (let stage of this.stages) {
await this.getStageTickets(stage)
}
}
}
}
},
where getTeam, getTeamStages and getStageTickets are :
async getTeam (teamId) {
this.team = await HelpdeskTeamService.getTeam(teamId)
},
async getTeamStages (teamId) {
this.stages = await HelpdeskTeamService.getTeamStages(teamId)
for (let stage of this.stages) {
this.$set(stage, 'tickets', [])
}
},
async getStageTickets (stage) {
const tickets = await HelpdeskTeamService.getTeamStageTickets(this.helpdeskTeamId, stage.id)
// tried many things here below but nothing worked.
// stage.tickets = stage.tickets.splice(0, 0, tickets)
// Even if I try to only put one :
// this.$set(this.stages[this.stages.indexOf(stage)].tickets, 0, tickets[0])
// I see it in the data but It doesn't appear in the view...
// Even replacing the whole stage with its tickets :
// stage.tickets = tickets
// this.stages.splice(this.stages.indexOf(stage), 1, stage)
},
In getTeamStages I add an attribute 'tickets' to every stage to an empty list. The problem is when I query all the tickets for every stage. I know how to insert a single object in an array with splice or how to delete one object from an array but I don't know how to assign a whole array to an attribute of an object that is in an array while triggering the Vue reactivity. Here I'd like to put all the tickets (which is a list), to stage.tickets.
Is it possible to achieve this ?
If not, what is the correct design to achieve something similar ?
Thanks in advance !
EDIT:
It turns out that there was an error generated by the template part. I didn't think it was the root cause since a part of the view was rendered. I thought that it would have prevent the whole view from being rendered if it was the case. But finally, in my template I had a part doing stage.tickets.length which was working when using a single query to populate my view. When making my API more granular and querying tickets independently from stages, there is a moment when stage has no tickets attribute until I set it manually with this.$set(stage, 'tickets', []). Because of that, the template stops rendering and raises an issue. But the ways of updating my stage.tickets would have worked without that template issue.
I could update the stages reactively. Here is my full code; I used the push method of an array object and it works:
<template>
<div>
<li v-for="item in stages" :key="item.stageId">
{{ item }}
</li>
</div>
</template>
<script>
export default {
data() {
return {
stages: [],
};
},
methods: {
async getTeamStages() {
this.stages = [{ stageId: 1 }, { stageId: 2 }];
for (let stage of this.stages) {
this.$set(stage, "tickets", []);
}
for (let stage of this.stages) {
await this.getStageTickets(stage);
}
},
async getStageTickets(stage) {
const tickets = ["a", "b", "c"];
for (let ticket of tickets) {
this.stages[this.stages.indexOf(stage)].tickets.push(ticket);
}
},
},
mounted() {
this.getTeamStages();
},
};
</script>
It should be noted that I used the concat method of an array object and also works:
this.stages[this.stages.indexOf(stage)].tickets = this.stages[this.stages.indexOf(stage)].tickets.concat(tickets);
I tried your approaches some of them work correctly:
NOT WORKED
this.$set(this.stages[this.stages.indexOf(stage)].tickets, tickets)
WORKED
this.$set(this.stages[this.stages.indexOf(stage)].tickets, 0, tickets[0]);
WORKED
stage.tickets = tickets
this.stages.splice(this.stages.indexOf(stage), 1, stage)
I'm sure it is XY problem..
A possible solution would be to watch the selected team and load the values from there. You seem to be loading everything from the mounted() hook, and I suspect this won't actually load all the content on demand as you'd expect.
I managed to make it work here without needing to resort to $set magic, just the pure old traditional vue magic. Vue will notice the properties of new objects and automatically make then reactive, so if you assign to them later, everything will respond accordingly.
My setup was something like this (showing just the relevant parts) -- typing from memory here, beware of typos:
data(){
teams: [],
teamId: null,
team: null
},
watch:{
teamId(v){
this.refreshTeam(v)
}
},
methods: {
async refreshTeam(id){
let team = await fetchTeam(id)
if(!team) return
//here, vue will auomaticlly make this.team.stages reactive
this.team = {stages:[], ...team}
let stages = await fetchStages(team.id)
if(!stages) return
//since this.team.stages is reactive, vue will update reactivelly
//turning the {tickets} property of each stage reactive also
this.team.stages = stages.map(v => ({tickets:[], ...v}))
for(let stage of this.team.stages){
let tickets = await fetchTickets(stage.id)
if(!tickets) continue
//since tickets is reactive, vue will update it accordingly
stage.tickets = tickets
}
}
},
async mounted(){
this.teams = fetchTeams()
}
Notice that my 'fetchXXX' methods would just return the data retrieved from the server, without trying to actually set the component data
Edit: typos

vue3 reactive unexpected behaviour

i've a reactive object, on the save function i call toRaw in order to remove de object reactivity, but, when i change the reactive object props, also the group object is changing....
How???
const newGroup = reactive<Group>({ id: undefined, name: '', enabled: false })
const handleSave = () => {
const group = toRaw(newGroup)
persist(group).then(() => {
newGroup.id = undefined
newGroup.name = 'demo'
console.log(isReactive(group)) //say false but the group.name value is demo
})
}
destructuring the reactive obj as const group = { ...newGroup } looks like it works, still no understand why toRaw is not working.
EDIT:
the whole problem comes from a sense of lack of control when dealing with reactive objects for example:
the following cumputed object retrieves a list from the store which is then drawn in the template as rows of a table at which click the selected element is changed of state. in the function I am forced to deconstruct records to avoid that the modification trigger the change of enabled before it is persisted
Is there a better way?
Should I make readonly groups?
//in setup()
const groups = computed <Group []> (() => getters ['registry / groups'])
const toggle = (record: Group) => {
const group = { ...record }
group.enabled = !group.enabled
persist(group)
}
//in template
<a-menu-item # click = "toggle (record)">
This is expected behaviour, but the description may not be detailed enough and cause some confusion.
according to toRaw docs
Returns the raw, original object of a reactive or readonly proxy. This is an escape hatch that can be used to temporarily read without incurring proxy access/tracking overhead or write without triggering changes. It is not recommended to hold a persistent reference to the original object. Use with caution.
What is happening when you use toRaw, is that you're getting back the original object that you passed into the reactive function without the Proxy. That means that the object is the same object (o and g in example 👇). toRaw only allows you to "escape" the reactivity/listeners bound to the object if you use toRaw instead of the Proxy itself. Even though the objects update, the reactivity (trickling down to DOM) is not applied.
Here is an example:
let o = {name:"Ja Ja"};
let m = Vue.reactive(o);
let g = Vue.toRaw(m);
console.log(o.name, m.name, g.name);
g.name = g.name + " Ding Dong";
console.log(o.name, m.name, g.name);
console.log("Is 'm' same as 'o'? ", m == g);
console.log("Is 'g' really just 'o'? ", o == g);
<script src="https://unpkg.com/vue#next/dist/vue.global.prod.js"></script>
If you want to create an object that will not update the original object, the simplest way is to clone it.
Most of the time you can use JSON.parse(JSON.stringify(o)). The caveat is that you get into issues if you have cyclical references (ie. {a:b, b:a}).
Another option is to use destructuring const copy = {...o}. The caveat here is that it is a shallow copy. Example o = {a:{b:"I will update"}, c:"I won't"}, where copy.a is still same object as o.a
That would then look like either
let o = {name:"Ja Ja"};
let m = Vue.reactive(o);
let g0 = {...Vue.toRaw(m)}; // destructure
let g1 = JSON.parse(JSON.stringify(Vue.toRaw(m))); // clone with json
Alternatively, you can also use some library that handles cloning object for you.
You have a misunderstanding of reactivity here. A reactive object connects the data with DOM, not between javascript objects, i.e. when a property of the reactive object is updated, the DOM is automatically updated.
toRaw returns the raw, original object of a reactive or readonly proxy, i.e group and newGroup are still the same object, the difference is that group would not trigger a DOM update as newGroup does.
Example, UI is updated when you update newGroup, but updating group won't trigger a DOM change. However the two objects still have the same reference, which is why group.name and newGroup.name are always the same.
Vue.createApp({
setup() {
const newGroup = Vue.reactive({ id: undefined, name: '', enabled: false })
const group = Vue.toRaw(newGroup)
return {
newGroup,
group
}
}
}).mount('#app')
<script src="https://unpkg.com/vue#next/dist/vue.global.prod.js"></script>
<div id="app">
<button #click="newGroup.name = 'reactive update'">reactive update</button>
newGroup Name: {{newGroup.name}}<br/>
<button #click="group.name = 'raw update'">raw update</button>
group Name: {{group.name}}
</div>
it is normal that isReactive(group) return false.
Actually in your code we have const group = toRaw(newGroup)
=> group is the original object of the reactive value newGroup.
=> group is a raw value, not a reactive value
if you tried isReactive(newGroup) this will return true
My solution is based the article in:
https://chrysanthos.xyz/article/how-to-get-the-data-of-a-proxy-object-in-vue-js-3/
1 - First import { toRaw } from "vue";
2 - In my vuex on return getters:
`
// getters
const getters = {
listOperations:(state: { operations: any; }) => {
return toRaw(state.operations);
}
}
`
3 - Browser: (9) [{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}]

Vuex: Referencing specific state data in getter

I have to pull in data from 12 specific departments. When my Vue store gets mounted, I fire off some actions to load in these orders via 12 different ajax calls. (These calls are to different API end points and servers).
Once I get a response I commit them to the state. So in my state I would have the following data set to arrays:
state: {
sportsOrders: [],
photographicOrders: []
//And more..
},
Now I need to filter these specific orders based on if they are late, in production, etc. So I have a getter that is setup like so:
getters: {
getDepartmentLateOrders: (state) => (department) => {
let resultArray = []
state.department.forEach(function(order, index) {
let now = new Date()
let orderShipDate = new Date(order.ExpectedShipDate)
let diff = date.getDateDiff(orderShipDate, now)
if(diff < 0) {
resultArray.push(order)
}
})
return resultArray
},
}
And then when I need to use it in a component...
return this.$store.getters.getDepartmentLateOrders(this.department+'Orders')
//for example it will end up passing 'sportsOrders' to the getter
However, when I do this, Vue doesn't use the state data for sportsOrders so I know I'm not accessing it correctly. It says:
vue.runtime.esm.js?ff9b:574 [Vue warn]: Error in render: "TypeError: Cannot read property 'forEach' of undefined"
If I hardcode my getter like so it works as expected though (but I don't want to do this for 12 departments)..
state.sportsOrders.forEach(function(order, index) {
...
}
How can I set this up so I can use this one getter and have it work with all 12 departments? I only want to pass the name of the department if possible.

Sharing data from a VueJS component

I have a VueJS address lookup component.
Vue.component('address-lookup',
{
template: '#address-lookup-template',
data: function()
{
return {
address: {'name': '', 'town:': '', 'postcode': ''},
errors: {'name': false, 'town': false, 'postcode': false},
states: {'busy': false, 'found': false},
result: {}
}
},
methods:
{
findAddress: function(event)
{
if( typeof event === 'object' && typeof event.target === 'object' )
{
event.target.blur();
}
$.ajax(
{
context: this,
url: '/lookup',
data:
{
'name': this.address.name,
'town': this.address.town,
'postcode': this.address.postcode
},
success: function(data)
{
this.states.busy = false;
this.states.found = true;
this.address.name = data.name;
this.result = data;
}
});
},
reset: function()
{
this.states.found = false;
this.result = {};
}
}
});
Inside my template I've then bound the result like so:
<p>{{ result.formatted_address }}</p>
There is some extra data returned within the result (like a twitter handle) that isn't part of the address lookup template, and occurs on a separate part of the form. For reasons relating to how my form is structured I can't include these inputs within the same template.
I found a way to bind those inputs, although it felt somewhat 'hacky'.
<input type="text" name="twitter" v-model="$refs.lookupResult._data.result.twitter">
That all works fine.
My problem is that the form is included as part of a larger template sometimes in the context of creating a new record, sometimes in the context of editing. When editing a record, the lookup component is removed (using an if server-side, so the template is no longer loaded at all) and when that happens I get this error.
$refs.lookupResult._data.result.twitter": TypeError: Cannot read property '_data' of undefined
This makes sense. lookupResult is defined when I include the template, and when editing I am removing this line:
<address-lookup v-ref:lookup-result></address-lookup>
I've worked around it by including a version of each extra input without the v-model attribute, again using a server-side if. But there are quite a few of these and it's getting a bit messy.
Is there a cleaner approach I could be using to better achieve this?
So I don't know the hierarchy of your layout, it isn't indicated above, but assuming that address-lookup component is a child of your parent, and you in fact need the results of address lookup in that parent, eg:
<parent-component> <!-- where you need the data -->
<address-lookup></address-lookup> <!-- where you lookup the data -->
</parent-component>
then you can simply pass the data props, either top-down only (default) or bidirectionally by defining 'address' for example on your parent's vue data hook:
// parent's data() function
data = function () {
return {
address: {}
}
}
// parent template, passed address with .sync modifier (to make it bi-directional)
<parent-component>
<address-lookup :address.sync='address'></address-lookup>
</parent-component>
// have the props accepted in the address look up component
var addressComponent = Vue.extend({
props: ['address']
})
Now in your $.ajax success function, simply set the props you need on this.address. Of course you can do this with all the props you need: errors, results, state etc. Even better, if you can nest them into a single key on the parent, you can pass the single key for the object containing all four elements instead of all four separately.