1

I have a checkbox form question component I made in Vue 2 and i'm migrating to Vue 3 so want to simplify it as much as possible and make sure i've taken advantage of all the new feature of Vue 3.

After a lot of trial and error i've managed to get it to work, but have I over complicated it?

The component should take a question using v-slot and answers in the 'options' array prop. It should let the user select as many answers as applicable but if they select 'none' it should clear all other answers. If they have 'none' selected then they select another answer it should clear 'none' so it isn't checked anymore.

Parent component

<template>
    <div>
        <h2>title</h2>

        <InputCheckboxGroup
            name="question_one"
            :required="checkIsRequired('question_one')"
            :error="formRef.errors.get('question_one')"
            v-model="formRef.question_one"
            :options="getField('question_one')['options']"
            @update:modelValue="handleAffectedByInput"
        >
            <template v-slot:question>
                {{ getField("question_one").question }}
            </template>
        
        </InputCheckboxGroup>

    </div>
</template>

<script setup>
import InputCheckboxGroup from "../Form/InputCheckboxGroup";
import Form from "../../../../Form";
import { isRequired } from "./formHelper.js";
import { ref, markRaw, defineExpose } from "vue";

const props = defineProps({
    step: Object,
    fields: Object,
    formData: Object
})


let formRef = markRaw(ref(new Form({
    question_one: []
}, props.formData)))

const getField = (fieldName) => {
   return props.fields[fieldName];
}

const checkIsRequired = (fieldName) => {
   var fieldData = getField(fieldName);
   return isRequired(fieldData, formRef.value);
}

function handleAffectedByInput(values) {
    if (values.length && (values[values.length - 1] === 'none')) {
        formRef.question_one = [values[values.length - 1]];
        return;
    }
   clearCheckboxValues(['none'], values, 'question_one');
}

function clearCheckboxValues(previousAnswers, values, formField) { 
    for (const answer of previousAnswers) {
        if (values.includes(answer)) {
            formRef.value[formField] = values.filter(value => value !== answer);
        }
    }
}

defineExpose({
 formRef
})
</script>

Child/Checkbox questionn componet

<template>
    <div class="form-group">
        <fieldset :aria-describedby="name">
            <legend>
                <p v-if="$slots.question" class="input__question">
                    <slot name="question"></slot>
                </p>
            </legend>
            <div
                class="input_checkbox"
                v-for="opt in options"
                v-bind:key="opt.value"  
            >           
                <label v-if="opt.value == 'none'">
                    <input
                        type="checkbox"
                        value="none"
                        v-model="model"
                        @click="onCheckboxChange"
                    />
                    <span>None</span>
                </label>    
                <div v-else class="form-group form-check">
                    <input  
                        type="checkbox"
                        class="form-check-input"
                        :value="opt.value"
                        @click="onCheckboxChange"
                        v-model="model"
                    />
                    <label :for="opt.value" class="form-check-label">
                        {{ opt.label }}
                    </label>    
                </div>
            </div>
        </fieldset>
    </div>
</template>
  
<script setup>
import { defineModel, defineEmits } from "vue";
const props = defineProps({
    error: String,
    name: String,
    options: Array,
    required: Boolean
});

const model = defineModel()

const emit = defineEmits(['update:modelValue'])

function optionIsChecked (value) {
    return model.value.includes(value);
}

function onCheckboxChange($event) {
    var previouslySelected = model.value || [];
    var newValue = [];
    if ($event.target.checked) {
        newValue = [...previouslySelected, $event.target.value];
    } else {
        newValue = previouslySelected.filter(
            (x) => x != $event.target.value
        );
    }
    
    if ($event.target.value === 'none' || $event.target.value === 'involved_none_above') {
        newValue = [$event.target.value];
    }
    
    model.value = newValue
    
    emit("update:modelValue", newValue);
}

</script>
3
  • 1
    Your question is not clear, you mention a successful migration from v2 to v3, and it is working but you are concern of the implementation, or am I missing something here? Commented Jan 3 at 14:39
  • 1
    I'm just worried i've overcomplicated the solution and wanted advice on if there's a more efficient way of achieving what i've done. Just feedback really Commented Jan 3 at 14:46
  • 2
    If that is the case, try posting here instead codereview.stackexchange.com Commented Jan 3 at 14:47

1 Answer 1

3

Without any bells and whistles1, here's how the requested logic would look in Composition API:

<script setup>

const options = ['one', 'two']
const selection = ref([])
const isSelectionEmpty = computed({
  get() {
    return !selection.value.length
  },
  set() {
    selection.value = []
  }
})

<template>

<label v-for="item in options" :key="item">
  <input type="checkbox" v-model="selection" :value="item" />
  {{ item }}
</label>
<label>
  <input type="checkbox" v-model="isSelectionEmpty" />
  none
</label>

Note the none checkbox is governed by a computed which is true whenever selection is empty and false otherwise. When the user clicks this input, selection is emptied, thus setting the value of isSelectionEmpty to true.

Arguably, the most important part of this construct is that the none option is not really an actual option. As in: it's not included in selection and you do not have to worry about ignoring it when you pass selection to external logic (e.g: validation, submission).
none is what it means: "selection is empty".


See it working:

const { createApp, ref, computed } = Vue

createApp({
  setup() {
    const selection = ref([])
    return {
      options: ['one', 'two', 'three', 'four'],
      selection,
      isSelectionEmpty: computed({
        get() {
          return !selection.value.length
        },
        set() {
          selection.value = []
        }
      })
    }
  }
}).mount('#app')
label input[type="checkbox"] {
  pointer-events: none;
}
label {
  display: block;
  cursor: pointer;
}
label:hover {
  background-color: #f5f5f5;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.5.4/vue.global.prod.min.js"></script>
<div id="app">
  <label v-for="item in options" :key="item">
    <input type="checkbox" v-model="selection" :value="item" /> {{ item }}
  </label>
  <label>
    <input type="checkbox" v-model="isSelectionEmpty" /> none
  </label>
</div>


To make it reusable (so it could be applied to multiple sets of options), we could encapsulate its logic in a composable function:

const useEmptySelection = () => {
  const selection = ref([])
  return {
    selection,
    isSelectionEmpty: computed({
      get() {
        return !selection.value.length
      },
      set() {
        selection.value = []
      }
    })
  }
}

and the demo app above would become:

createApp({
  setup: () => ({
    options: ['one', 'two', 'three', 'four'],
    ...useEmptySelection()
  })
}).mount('#app')

or, using a more explicit syntax:

createApp({
  setup() {
    const options = ['one', 'two', 'three', 'four']
    const { selection, isSelectionEmpty } = useEmptySelection()
   
    return {
      options,
      selection,
      isSelectionEmpty
    }
  }
}).mount('#app')

Demo using a <checkbox-group /> component to render the options and to add the none option on the fly:

const { createApp, ref, computed, defineComponent } = Vue

const useEmptySelection = () => {
  const selection = ref([])
  return {
    selection,
    isSelectionEmpty: computed({
      get() {
        return !selection.value.length
      },
      set() {
        selection.value = []
      }
    })
  }
}

const CheckboxGroup = defineComponent({
  props: ['options'],
  setup: useEmptySelection,
  template: '#checkbox-group-tpl'
})

createApp({
  components: { CheckboxGroup },
  setup: () => ({
    groups: [
      ['one', 'two', 'three'],
      ['foo', 'bar'],
      ['yet', 'another', 'checkboxes', 'group']
    ]
  })
}).mount('#app')
label input[type="checkbox"] {
  pointer-events: none;
}
label {
  display: block;
  cursor: pointer;
}
label:hover {
  background-color: #f5f5f5;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.5.4/vue.global.prod.min.js"></script>
<div id="app">
  <template v-for="(options, key) in groups" :key="key">
    <checkbox-group v-bind="{ options }"></checkbox-group>
    <hr />
  </template>
</div>

<template id="checkbox-group-tpl">
  <label v-for="item in options" :key="item">
    <input type="checkbox" v-model="selection" :value="item" /> {{ item }}
  </label>
  <label>
    <input type="checkbox" v-model="isSelectionEmpty" /> none
  </label>
</template>


1 - These are fairly basic examples, using strings as options and <input type="checkbox" /> as inputs. One might want to modify them to use objects as options/values and fancier input components from the UI library of choice.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.