Vue.js 3, Composition API
I have data coming from a legacy server (can't change it!) which looks like this:
{
"propA": [{"valueA": "x", "valueB": "y"}, {"valueA": "o", "valueB": "p"}],
"propB": [{"valueL": "a", "valueM": "b"}]
}
Each value of each property has an optional field state (e.g. {"valueA": "x", "valueB": "y", "state": "updated"}), which needs to be set accordingly, depending if the property was updated or deleted.
Each property has an editor for each of its values (e.g. text input field) which is wrapped in another (reusable) component handling state changes:
it can reset the value to its initial state
it can set the values state to deleted
it needs to set its state to updated whenever any property of a value (except state!) changes
Each value is passed as v-model to the wrapper component.
The wrapper component doesn't know which fields exist, except for state (doesn't need to, fields vary a lot), it has buttons to reset the state of the object to its initial value and to set state to deleted. It should watch for changes to initial data as well and set updated or delete state accordingly.
On form submit the application sends the data to the server along with the state properties.
What would the best way be to solve this with Vue.js 3?
I tried watching each value ({"valueA": "x", "valueB": "y"}) with a watcher function, but then it gets into endless loop on updateValue / deleteValue in watch:
const initialState = lodash.cloneDeep(props.modelValue)
watch(props.modelValue, (new, old) => {
if (hasInitialStateChanged(new)) {
updateValue("state", "updated");
} else {
deleteValue("state");
}
});
function updateValue(key, value) {
emit("update:modelValue", { ...props.modelValue, [key]: value });
}
function deleteValue(key) {
const o = { ...props.modelValue };
delete o[key];
emit("update:modelValue", o);
}
function hasInitialStateChanged(newState) {
const item = lodash.cloneDeep(newState);
const originalItem = lodash.cloneDeep(initialItem);
delete item["state"];
delete originalItem["state"];
return !isEqual(item, originalItem);
}
Any ideas? For Angular folks: I used ngDoChange and mutated the state property accordingly.
After some discussion on Vue forums I found quite an elegant solution, which works fine with the data in its current form.
Here is a working solution for anyone interested: Vue SFC Playground
//App.vue
<script setup>
import { ref } from 'vue'
import StatefulEditor from './StatefulEditor.vue'
const o = ref({
"propA": [{"valueA": "x", "valueB": "y"}, {"valueA": "o", "valueB": "p"}],
"propB": [{"valueL": "a", "valueM": "b"}]
})
</script>
<template>
<h1>
HELLO
</h1>
<StatefulEditor v-model="o.propA[index]"
v-for="(prop, index) in o.propA"
:key="index"
>
<input type="text" v-model="prop.valueA" />
</StatefulEditor>
</template>
//StatefulEditor.vue
<template>
<div>
<div>
<slot></slot>
</div>
<div>
<button
v-if="modelValue.state === 'updated'"
#click="resetItem()"
>
RESET
</button>
<button
v-if="modelValue.state === 'deleted'"
#click="undeleteItem()"
>
UNDO
</button>
<button
v-if="(modelValue.state === undefined || modelValue.state === 'created')"
#click="deleteItem()"
>
DELETE
</button>
<span>{{modelValue.state}}</span>
</div>
</div>
</template>
<script setup>
import { watch, computed } from "vue";
import { cloneDeep, isEqual } from "lodash";
const props = defineProps(["modelValue"]);
const emit = defineEmits(["update:modelValue"]);
const initialItem = cloneDeep(props.modelValue);
const initialStateChanged = computed(() => hasInitialStateChanged(props.modelValue))
watch(initialStateChanged, (n) => {updateValue("state", n ? "updated" : undefined)})
function resetItem() {
emit("update:modelValue", { ...initialItem });
}
function deleteItem() {
updateValue("state", "deleted");
}
function undeleteItem() {
updateValue("state", undefined);
}
function hasInitialStateChanged(newState) {
const item = cloneDeep(newState);
const originalItem = cloneDeep(initialItem);
delete item["state"];
delete originalItem["state"];
return !isEqual(item, originalItem);
}
function updateValue(key, value) {
emit("update:modelValue", { ...props.modelValue, [key]: value });
}
function deleteValue(key) {
const o = { ...props.modelValue };
delete o[key];
emit("update:modelValue", o);
}
</script>
Related
I'm trying to build what I'd assumed would be a relatively straightforward implementation of Ant-D's Tree component, and I'm running into issues.
Specifically, I'm unclear how to replicate the "half-checked" vs. "full-checked" behavior of their example. I'd like for the child nodes, when all fully checked, to also check their parent. Likewise when the parents are de-selected, I'd like all of the children to be deselected as well. Finally, when only some of a child nodes are checked/unchecked, the parent should go into a "half-checked" state.
The API seems to allow for this, and indeed they have an example here that purports to show off this functionality.
<template>
<a-tree
v-model:selectedKeys="selectedKeys"
v-model:checkedKeys="checkedKeys"
default-expand-all
checkable
:height="233"
:tree-data="treeData"
>
<template #title="{ title, key }">
<span v-if="key === '0-0-1-0'" style="color: #1890ff">{{ title }}</span>
<template v-else>{{ title }}</template>
</template>
</a-tree>
</template>
<script lang="ts">
import type { TreeProps } from 'ant-design-vue';
import { defineComponent, ref, watch } from 'vue';
function dig(path = '0', level = 3) {
const list: TreeProps['treeData'] = [];
for (let i = 0; i < 10; i += 1) {
const key = `${path}-${i}`;
const treeNode: TreeProps['treeData'][number] = {
title: key,
key,
};
if (level > 0) {
treeNode.children = dig(key, level - 1);
}
list.push(treeNode);
}
return list;
}
export default defineComponent({
setup() {
const selectedKeys = ref<string[]>(['0-0-0', '0-0-1']);
const checkedKeys = ref<string[]>(['0-0-0', '0-0-1']);
watch(selectedKeys, () => {
console.log('selectedKeys', selectedKeys);
});
watch(checkedKeys, () => {
console.log('checkedKeys', checkedKeys);
});
return {
treeData: dig(),
selectedKeys,
checkedKeys,
};
},
});
</script>
It's not clear to me how this works. Nowhere are they setting the checkedKeys data. Is this handled internally by the tree? I've tried copying this example locally and it's not even working.
The documentation further states about the checkedKeys prop:
"When this specifies the key of a treeNode which is also a parent treeNode, all the children treeNodes of will be checked; and vice versa, when it specifies the key of a treeNode which is a child treeNode, its parent treeNode will also be checked. When checkable and checkStrictly is true, its object has checked and halfChecked property."
If this example does not have checkStrictly set to true, then how are only some of the nodes supposed to become "half checked"?
I have a parent component that is passing down some API data to a child component in order to pre-populate some input fields. When the user changes some of this data on the child component, that child component emits the data back to the parent where we will process it for server submission on user form submit.
To handle the updates for processing, I am sending the child data back as an object which the parent stores in an array (array of objects). This array is what I am sending to the server for processing.
I am struggling with how to update object properties in an array of objects.
Codesandbox: https://codesandbox.io/s/romantic-mestorf-yc0i1h?file=/src/components/Parent.vue
Let me explain in detail. I have 3 components:
<App>
<Parent>
<Child />
</Parent>
</App>
App.vue:
<template>
<div id="app">
<form #submit.prevent="submitForm()">
<Parent
:localShortNames="formValues.localShortNames"
#save-form-data="saveFormData"
/>
<button type="submit">Submit</button>
</form>
</div>
</template>
<script>
import Parent from "./components/Parent.vue";
import data from "./assets/data.json"; // <--- will be an actual API request
export default {
components: {
Parent,
},
data() {
return {
formValues: {
localShortNames: data,
},
};
},
methods: {
saveFormData(x) {
// TO DO
},
submitForm() {
// TO DO: save data to server
},
},
};
</script>
Parent.vue:
<template>
<div>
<h5>List of Data</h5>
<Child
v-for="item in localShortNames"
:key="item.localSnameID"
:localShortName="item"
#save-form-data="saveFormData"
/>
</div>
</template>
<script>
import Child from "./Child.vue";
export default {
props: {
localShortNames: {
type: Array,
},
},
components: {
Child,
},
data() {
return {
newLocalShortNamesArr: this.localShortNames,
};
},
methods: {
saveFormData(x) {
let elementId = (el) => el.localSnameID === x.localSnameID;
const newArr = this.newLocalShortNamesArr.map((obj) => {
if (elementId) {
// I need to update the existing object in newLocalShortNamesArr with updated user submitted properties
// ...
} else {
// item does not exist, lets push it to newLocalShortNamesArr
// TO DO LATER: create "add new data" button for adding new objects to array
},
},
},
},
}
</script>
Child.vue:
<template>
<div>
<label for="name-input">Name:</label>
<input
type="text"
id="name-input"
v-model="formValues.name"
#input="$emit('save-form-data', formValues)"
/>
<label for="dialect-input">Dialect:</label>
<input
type="text"
id="dialect-input"
v-model="formValues.iso6393Char3Code"
#input="$emit('save-form-data', formValues)"
/>
</div>
</template>
<script>
export default {
props: {
localShortName: {
type: Object,
},
},
data() {
return {
formValues: {
localSnameID: this.localShortName
? this.localShortName.localSnameID
: null,
name: this.localShortName ? this.localShortName.name : null,
iso6393Char3Code: this.localShortName
? this.localShortName.iso6393Char3Code
: null,
},
};
},
};
</script>
Question: How to handle the update of objects in an array and "overwrite" those properties (name, and iso6393Char3Code) if the same id exists in the original array?
In the parent.vue, I was thinking of doing something like this, but I don't know:
saveFormData(x) {
// console.log(x);
let elementId = (el) => el.localSnameID === x.localSnameID;
const newArr = this.newLocalShortNamesArr.map((obj) => {
if (elementId) {
// I need to update the existing object in newLocalShortNamesArr with updated user submitted properties
// ...
} else {
// item does not exist, lets push it to newLocalShortNamesArr
// ...
}
});
Would Object.assign be better here vs map()? All I am trying to do is provide an array to the API called localShortNames that contains all the objects whether they have been updated or not. Hope this makes sense!
I have a codesandbox here with the above code: https://codesandbox.io/s/romantic-mestorf-yc0i1h?file=/src/components/Parent.vue
The first problem in Parent Component in saveFormData
if you want to check if the object exist in array or not you can use findIndex() method it loop through array and return the object index if exists else return -1 if not exist
if exist objectIndex will be greater than -1 and update that index with the object from child component and then we emit the array after updated to App.vue component
saveFormData(x) {
const objectIndex = this.newLocalShortNamesArr.findIndex((ele) => {
return ele.localSnameID == x.localSnameID
});
if (objectIndex > -1) {
// the object exists
this.newLocalShortNamesArr[objectIndex] = { ...x };
} else {
// this case need new html form for add new item in array
this.newLocalShortNamesArr.push(x);
}
this.$emit("save-form-data", this.newLocalShortNamesArr);
},
in App.vue component
we update the main array with the data that emitted from parent component
saveFormData(x) {
this.formValues.localShortNames = [...x];
},
i updated the codesandbox with the new code ..
I hope the solution will be clear to you
https://codesandbox.io/s/dreamy-clarke-v04wfl?file=/src/App.vue:595-734
I made a page with two routes one is the home page another is the config where you can decide what should be written to that container, now in the config panel I was able to get the input values, I put them to my state with map actions now I am getting an array with string values in it.
How can I access that array with mapGetters ? I link my code:
<template>
<body>
<div class="container">
<h1 v-show="elementVisible" class="info">{{ message }}</h1>
</div>
</body>
</template>
<script>
import moment from "moment";
import { mapGetters } from "vuex";
export default {
name: "Home",
data() {
return {
// message: this.store.state.message
elementVisible: true
};
},
computed: {
...mapGetters(["message", "sec"]),
...mapGetters({
message: "message",
sec: "sec"
}),
createdDate() {
return moment().format("DD-MM-YYYY ");
},
createdHours() {
return moment().format("HH:mm ");
}
},
mounted() {
this.$store.dispatch("SET_MESSAGE");
this.$store.dispatch("SET_SEC");
setTimeout(() => (this.elementVisible = false), this.sec);
}
};
</script>
so what I have to do is to put to that{{message}} template my message which I received from the config panel and which is in my state right now sitting there as an array of string, for example, ["hello", "how are you"] that's how they are sitting there, so how can I grab the first one('hello') and write it out as a clean string and not as ["hello"] if you know how to grab all of them would be even better.
(RightNow it's just putting that whole array to my template)
Maybe I should something rewrite in my storejs file?
STOREJS:
const state = {
message: [],
// console.log(message);
sec: +[]
// other state
};
const getters = {
message: state => {
// console.log(this.state.message);
return state.message;
},
sec: state => {
return state.sec;
}
// other getters
};
const actions = {
setMessage: ({ commit, state }, inputs) => {
commit(
"SET_MESSAGE",
inputs.map(input => input.message)
);
return state.message;
},
setSec: ({ commit, state }, inputs) => {
commit("SET_TIMEOUT", inputs.map(x => x.sec).map(Number));
console.log(inputs.map(x => x.sec).map(Number));
return state.sec;
}
// other actions
};
const mutations = {
SET_MESSAGE: (state, newValue) => {
state.message = newValue;
},
SET_TIMEOUT: (state, newSecVal) => {
state.sec = newSecVal;
}
// other mutations
};
export default {
state,
getters,
actions,
mutations
};
what my homepage should do is that it writes out that message and there is a sec value which stands for the timeout, after that I want to continue with the second value in that array and when that times out I should want the third to be written out.. and so on.
Thank you!
Hello and welcome to Stack Overflow! Your message Array is being mapped correctly with mapGetters, but you're flattening it as a String when you put it inside the template with {{message}}, since the template interpolation logic covert objects to strings, the same as calling Array.toString in this case. You need to iterate it, i.e. using the v-for directive:
<template>
<body>
<div class="container">
<h1 v-show="elementVisible" class="info">
<span v-for="m of message" :key="m">{{m}}</span>
</h1>
</div>
</body>
</template>
Of course, if you only need the first item, you could show it directly using:
<template>
<body>
<div class="container">
<h1 v-show="elementVisible" class="info">{{message[0] || 'No message'}}</h1>
</div>
</body>
</template>
I have a VueJS component which contains a button whose class and text are computed properties and changes every time the button is clicked. They are changing fine as long as I click on the button once it is loaded. I wanted to store the state in localStorage and if I reload the page set the text and class based on the value stored. The value of ordered is changing but the button text and class are not reflecting that in UI. Does anyone have any suggestion as to what I may be doing wrong? Following is the source
<template>
<div class="main-view">
<button type="button" :class="order_button_style" #click="on_order_button_click()">
{{ order_button_text }}
</button>
</div>
</template>
<script>
export default {
name: "FoodComponent",
props: {
item: Object
},
methods: {
on_order_button_click() {
this.item.ordered = !this.item.ordered;
localStorage.setItem(this.item.id, this.item.ordered);
}
},
mounted() {
var storedState = localStorage.getItem(this.item.id);
if (storedState) {
this.item.ordered = storedState;
}
},
computed: {
order_button_text() {
return this.item.ordered === true ? "Ordered" : "Order";
},
order_button_style() {
return this.item.ordered === true
? "button ordered-button"
: "button unordered-button";
}
}
};
</script>
What you will get from the local storage is a string. In mounted, ordered property will be a string instead of a boolean so you order_button_text computed property condition will never be true. To fix this you can just convert storedState property to a boolean :
mounted() {
const storedState = localStorage.getItem(this.item.id) === 'true';
if (storedState) {
this.item.ordered = storedState;
}
},
I looked at potential dupes of this, such as this one and it doesn't necessarily solve my issue.
My scenario is that I have a component of orgs with label and checkbox attached to a v-model. That component will be used in combination of other form components. Currently, it works - but it only passes back one value to the parent even when both checkboxes are click.
Form page:
<template>
<section>
<h1>Hello</h1>
<list-orgs v-model="selectedOrgs"></list-orgs>
<button type="submit" v-on:click="submit">Submit</button>
</section>
</template>
<script>
// eslint-disable-next-line
import Database from '#/database.js'
import ListOrgs from '#/components/controls/list-orgs'
export default {
name: 'CreateDb',
data: function () {
return {
selectedOrgs: []
}
},
components: {
'list-orgs': ListOrgs,
},
methods: {
submit: function () {
console.log(this.$data)
}
}
}
</script>
Select Orgs Component
<template>
<ul>
<li v-for="org in orgs" :key="org.id">
<input type="checkbox" :value="org.id" name="selectedOrgs[]" v-on:input="$emit('input', $event.target.value)" />
{{org.name}}
</li>
</ul>
</template>
<script>
import {db} from '#/database'
export default {
name: 'ListOrgs',
data: () => {
return {
orgs: []
}
},
methods: {
populateOrgs: async function (vueObj) {
await db.orgs.toCollection().toArray(function (orgs) {
orgs.forEach(org => {
vueObj.$data.orgs.push(org)
})
})
}
},
mounted () {
this.populateOrgs(this)
}
}
</script>
Currently there are two fake orgs in the database with an ID of 1 and 2. Upon clicking both checkboxes and clicking the submit button, the selectedOrgs array only contains 2 as though the second click actually over-wrote the first. I have verified this by only checking one box and hitting submit and the value of 1 or 2 is passed. It seems that the array method works at the component level but not on the component to parent level.
Any help is appreciated.
UPDATE
Thanks to the comment from puelo I switched my orgListing component to emit the array that is attached to the v-model like so:
export default {
name: 'ListOrgs',
data: () => {
return {
orgs: [],
selectedOrgs: []
}
},
methods: {
populateOrgs: async function (vueObj) {
await db.orgs.toCollection().toArray(function (orgs) {
orgs.forEach(org => {
vueObj.$data.orgs.push(org)
})
})
},
updateOrgs: function () {
this.$emit('updateOrgs', this.$data.selectedOrgs)
}
},
mounted () {
this.populateOrgs(this)
}
}
Then on the other end I am merely console.log() the return. This "works" but has one downside, it seems that the $emit is being fired before the value of selectedOrgs has been updated so it's always one "check" behind. Effectively,I want the emit to wait until the $data object has been updated, is this possible?
Thank you so much to #puelo for the help, it helped clarify some things but didn't necessarily solve my problem. As what I wanted was the simplicity of v-model on the checkboxes populating an array and then to pass that up to the parent all while keeping encapsulation.
So, I made a small change:
Select Orgs Component
<template>
<ul>
<li v-for="org in orgs" :key="org.id">
<input type="checkbox" :value="org.id" v-model="selectedOrgs" name="selectedOrgs[]" v-on:change="updateOrgs" />
{{org.name}}
</li>
</ul>
</template>
<script>
import {db} from '#/database'
export default {
name: 'ListOrgs',
data: () => {
return {
orgs: []
}
},
methods: {
populateOrgs: async function (vueObj) {
await db.orgs.toCollection().toArray(function (orgs) {
orgs.forEach(org => {
vueObj.$data.orgs.push(org)
})
})
},
updateOrgs: function () {
this.$emit('updateOrgs', this.$data.selectedOrgs)
}
},
mounted () {
this.populateOrgs(this)
}
}
</script>
Form Page
<template>
<section>
<h1>Hello</h1>
<list-orgs v-model="selectedOrgs" v-on:updateOrgs="updateSelectedOrgs"></list-orgs>
<button type="submit" v-on:click="submit">Submit</button>
</section>
</template>
<script>
// eslint-disable-next-line
import Database from '#/database.js'
import ListOrgs from '#/components/controls/list-orgs'
export default {
name: 'CreateDb',
data: function () {
return {
selectedOrgs: []
}
},
components: {
'list-orgs': ListOrgs
},
methods: {
updateSelectedOrgs: function (org) {
console.log(org)
},
submit: function () {
console.log(this.$data)
}
}
}
</script>
What the primary change here is I now fire a method of updateOrgs when the checkbox is clicked and I pass the entire selectedOrgs array via the this.$emit('updateOrgs', this.$data.selectedOrgs)`
This takes advantage of v-model maintaining the array of whether they're checked or not. Then on the forms page I simply listen for this event on the component using v-on:updateOrgs="updateSelectedOrgs" which contains the populated array and maintains encapsulation.
The documentation for v-model in form binding still applies to custom components, as in:
v-model is essentially syntax sugar for updating data on user input
events...
https://v2.vuejs.org/v2/guide/forms.html#Basic-Usage and
https://v2.vuejs.org/v2/guide/components-custom-events.html#Customizing-Component-v-model
So in your code
<list-orgs v-model="selectedOrgs"></list-orgs>
gets translated to:
<list-orgs :value="selectedOrgs" #input="selectedOrgs = $event.target.value"></list-orgs>
This means that each emit inside v-on:input="$emit('input', $event.target.value) is actually overwriting the array with only a single value: the state of the checkbox.
EDIT to address the comment:
Maybe don't use v-model at all and only listen to one event like #orgSelectionChange="onOrgSelectionChanged".
Then you can emit an object with the state of the checkbox and the id of the org (to prevent duplicates):
v-on:input="$emit('orgSelectionChanged', {id: org.id, state: $event.target.value})"
And finally the method on the other end check for duplicates:
onOrgSelectionChanged: function (orgState) {
const index = selectedOrgs.findIndex((org) => { return org.id === orgState.id })
if (index >= 0) selectedOrgs.splice(index, 1, orgState)
else selectedOrgs.push(orgState)
}
This is very basic and not tested, but should give you an idea of how to maybe solve this.