Search code examples
vue.jsvue-componentvuex

Vuex two-way editable binding


I have a list of order positions. Each position has product name, quantity, price, sum. Also, there're input fields with a prepayment amount in percent and in absolute (e.g. dollars). I use a getter for absolute prepayment. So, in order to edit the absolute value from input and to have the percent one been up-to-date I have to update it in store through percent value, i.e. I have to calculate percent value wherein the absolute value equals to the new one (just typed into the input field). But, here's a problem: when I type a number the input field of the absolute value, calculation of the percent causes recalculation of the absolute getter, that returns the carriage to the beginning (OK, there's debounce function can be used here), but also I feel it's wrong and some logic violation takes a place.

Sorry for the mess. The code:

store.js:

Vue.use(Vuex);

let product = {
    order: 1,
    name: '',
    size: '',
    content: '',
    price: 0,
    number: 1,
    discount: '',
    guarantee: 0,
    promotion: 0,
    location: '',
    sum: 0,
};
export default new Vuex.Store({
    strict: true,
    state: {
        products: [
            Object.assign({}, product),
        ],
        form: {
            prepaymentInPercent: 100,
        },

    },
    getters: {
        total(state) {
            return +(state.products.length > 0 && state.products.reduce(function (acc, cur) {
                return acc + cur.sum;
            }, 0));
        },
        rest(state, getters) {
            return Math.round(getters.total - getters.prepaymentInRub);
        },
        prepaymentInRub(state, getters) {
            return Math.round(getters.total * state.form.prepaymentInPercent / 100);
        },
    },
    mutations: {
        [ADD_PRODUCT](state, product) {
            state.products.push(product);
        },
        [UPDATE_PRODUCT](state, {updatedProduct, index}) {
            state.products.splice(index, 1, updatedProduct);
        },
        [DELETE_PRODUCT](state, index) {
            state.products.splice(index, 1);
        },
        [UPDATE_FORM](state, updatedFormFields) {
            state.form = Object.assign({}, state.form, updatedFormFields);
        },
    },
    actions: {
        addProduct({commit}) {
            let newProduct = Object.assign({}, product, {'order': product.order++});
            commit(ADD_PRODUCT, newProduct);
        },
        updateProduct: _.debounce(function ({commit}, {product, index}) {
            let updatedProduct = Object.assign({}, product);
            commit(UPDATE_PRODUCT, {updatedProduct, index});
        }, 200),
        deleteProduct({commit, state}, index) {
            state.products.length > 1 && commit(DELETE_PRODUCT, index);
        },
        updatePrepaymentInRub({commit, getters}, rubles) {
            let prepaymentInPercent = Math.round(rubles / getters.total * 100);
            commit(UPDATE_FORM, {prepaymentInPercent});
        },
    },
});

and

OrderForm.vue

<template>
    <form>
        <label>Prepayment, %
            <input id="prepaymentInPercent" type="number" min="0" max="100" v-model="prepaymentInPercent">
        </label>
        <label>Prepayment, rubles
            <input id="prepaymentInRub" min="0" type="number" :max="total" v-model="prepaymentInRubles">
        </label>
    </form>
</template>

<script>
    import {UPDATE_FORM} from "../mutation-types";
    import {mapGetters} from 'vuex';

    export default {
        name: "OrderForm",
        computed: {
            'prepaymentInRubles': {
                get() {
                    return this.$store.getters.prepaymentInRub;
                },
                set(value) {
                    this.$store.dispatch('updatePrepaymentInRub', value);
                }
            },
            ...mapGetters(['total']),
            'prepaymentInPercent': {
                get() {
                    return this.$store.state.form.prepaymentInPercent;
                },
                set(value) {
                    return this.$store.commit(UPDATE_FORM, {
                        'prepaymentInPercent': value
                    });
                }
            }
        },
    }
</script>

ProductRow.vue

<template>
    <tr>
        <td colspan="2" class="id">{{indexFrom1}}</td>
        <Editable v-model="product.name"/>
        <Editable v-model="product.size"/>
        <Editable v-model="product.content"/>
        <Editable v-model.number="product.price"/>
        <Editable v-model.number="product.number"/>
        <Editable v-model="product.discount"/>
        <td>
            <select v-model.number="product.promotion">
                <option selected value="0">No</option>
                <optgroup label="forNewsettlers">
                    <option value="5">-5%</option>
                    <option value="10">-10%</option>
                    <option value="15">-15%</option>
                </optgroup>
            </select>
        </td>
        <td>{{sum}}</td>
        <Editable v-model.number="product.guarantee"/>
        <td>
            <select v-model="product.location">
                <option selected value="SERVICE">Service</option>
                <option value="STOREHOUSE">Storehouse</option>
                <option value="SO">Special Order</option>
                <option value="from 1st shop">exhibition sample from 1st shop</option>
            </select>
        </td>
        <td>
            <span class="table-remove" @click="removeProduct(index)">Удалить</span>
        </td>
    </tr>
</template>

<script>
    import Editable from './EditableCell';

    export default {
        components: {
            Editable,
        },
        name: 'ProductRow',
        props: {
            initialProduct: Object,
            index: Number,
        },
        data() {
            return {
                product: Object.assign({}, this.initialProduct),
            };

        },
        computed: {
            sum() {
                let discountedPrice = this.isDiscountInPercent(this.product.discount) ?
                    this.product.price * this.getCoeffFromPercent(this.product.discount) :
                    this.product.price - this.product.discount;
                let priceWithPromotion = discountedPrice * this.getCoeffFromPercent(this.product.promotion);
                let sum = Math.round(priceWithPromotion * this.product.number);
                return sum > 0 ? sum : 0;
            },
            indexFrom1() {
                return this.index + 1;
            },
        },
        methods: {
            getCoeffFromPercent(percent) {
                return 1 - parseInt(percent) / 100;
            },
            isDiscountInPercent(discount) {
                return ~discount.indexOf('%') ? true : false;
            },
            removeProduct(index) {
                this.$store.dispatch('deleteProduct', index);
            }
        },
        watch: {
            sum() {
                this.product.sum = this.sum;
            },
            product: {
                handler(product) {
                    this.$store.dispatch('updateProduct', {
                        product: product,
                        index: this.index,
                    });
                },
                deep: true,
            },
        },
    };
</script>

Solution

  • I think your logic is fine. There's a single value in the state that has two different representations and possible mutations.

    To avoid the cursor issues, I'd change the event you're listening to on the inputs. Remember v-model="prepaymentInPercent" is equivalent to @input="prepaymentInPercent = $event.target.value" :value="prepaymentInPercent". The input event fires on every character change, but you can replace it with the change event which will fire on blur or enter. e.g. @change="prepaymentInPercent = $event.target.value" :value="prepaymentInPercent"

    It turns out there's a lazy modifier that does this automatically, so you can just add .lazy to your v-model.