Skip to content

Commit

Permalink
fix: handle formless checkboxes value toggling closes #3105
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Jan 4, 2021
1 parent 526b580 commit 504f30b
Show file tree
Hide file tree
Showing 5 changed files with 80 additions and 43 deletions.
61 changes: 25 additions & 36 deletions packages/vee-validate/src/Field.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { h, defineComponent, nextTick, toRef, SetupContext, resolveDynamicComponent, computed } from 'vue';
import { h, defineComponent, nextTick, toRef, SetupContext, resolveDynamicComponent, computed, watch } from 'vue';
import { getConfig } from './config';
import { useField } from './useField';
import { normalizeChildren, hasCheckedAttr, isFileInput } from './utils';
Expand Down Expand Up @@ -59,6 +59,9 @@ export const Field = defineComponent({
type: null,
default: undefined,
},
modelValue: {
type: null,
},
},
setup(props, ctx) {
const rules = toRef(props, 'rules');
Expand Down Expand Up @@ -87,9 +90,9 @@ export const Field = defineComponent({
// Gets the initial value either from `value` prop/attr or `v-model` binding (modelValue)
// For checkboxes and radio buttons it will always be the model value not the `value` attribute
initialValue: hasCheckedAttr(ctx.attrs.type)
? ctx.attrs.modelValue
? props.modelValue
: 'modelValue' in ctx.attrs
? ctx.attrs.modelValue
? props.modelValue
: ctx.attrs.value,
// Only for checkboxes and radio buttons
valueProp: ctx.attrs.value,
Expand All @@ -98,26 +101,17 @@ export const Field = defineComponent({
validateOnValueUpdate: false,
});

let isDuringValueTick = false;
// Prevents re-render updates that rests value when using v-model (#2941)
function valueTick() {
isDuringValueTick = true;
nextTick(() => {
isDuringValueTick = false;
});
}

// If there is a v-model applied on the component we need to emit the `update:modelValue` whenever the value binding changes
const onChangeHandler =
'modelValue' in ctx.attrs
'modelValue' in props
? function handleChangeWithModel(e: any) {
handleChange(e);
ctx.emit('update:modelValue', value.value);
}
: handleChange;

const onInputHandler =
'modelValue' in ctx.attrs
'modelValue' in props
? function handleChangeWithModel(e: any) {
handleInput(e);
ctx.emit('update:modelValue', value.value);
Expand All @@ -129,18 +123,12 @@ export const Field = defineComponent({
props
);
const baseOnBlur = [handleBlur, ctx.attrs.onBlur, validateOnBlur ? validateField : undefined].filter(Boolean);
const baseOnInput = [
onInputHandler,
valueTick,
validateOnInput ? onChangeHandler : undefined,
ctx.attrs.onInput,
].filter(Boolean);
const baseOnChange = [
onInputHandler,
valueTick,
validateOnChange ? onChangeHandler : undefined,
ctx.attrs.onChange,
].filter(Boolean);
const baseOnInput = [onInputHandler, validateOnInput ? onChangeHandler : undefined, ctx.attrs.onInput].filter(
Boolean
);
const baseOnChange = [onInputHandler, validateOnChange ? onChangeHandler : undefined, ctx.attrs.onChange].filter(
Boolean
);

const attrs: Record<string, any> = {
name: props.name,
Expand All @@ -150,7 +138,7 @@ export const Field = defineComponent({
};

if (validateOnModelUpdate) {
attrs['onUpdate:modelValue'] = [onChangeHandler, valueTick];
attrs['onUpdate:modelValue'] = [onChangeHandler];
}

if (hasCheckedAttr(ctx.attrs.type) && checked) {
Expand Down Expand Up @@ -183,18 +171,19 @@ export const Field = defineComponent({
};
});

if ('modelValue' in props) {
const modelValue = toRef(props, 'modelValue');
watch(modelValue, newModelValue => {
if (newModelValue !== value.value) {
value.value = newModelValue;
validateField();
}
});
}

return () => {
const tag = resolveDynamicComponent(resolveTag(props, ctx)) as string;

// Sync the model value with the inner field value if they mismatch
// a simple string comparison is used here
// make sure to check if the re-render isn't caused by a value update tick
if ('modelValue' in ctx.attrs && String(ctx.attrs.modelValue) !== String(value.value) && !isDuringValueTick) {
nextTick(() => {
handleChange(ctx.attrs.modelValue as any);
});
}

const children = normalizeChildren(ctx, slotProps.value);

if (tag) {
Expand Down
9 changes: 8 additions & 1 deletion packages/vee-validate/src/useField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
getFromPath,
setInPath,
injectWithSelf,
resolveNextCheckboxValue,
} from './utils';
import { isCallable } from '../../shared';
import { FieldContextSymbol, FormInitialValuesSymbol, FormContextSymbol } from './symbols';
Expand Down Expand Up @@ -116,7 +117,13 @@ export function useField<TValue = any>(
return;
}

value.value = normalizeEventValue(e);
let newValue = normalizeEventValue(e);
// Single checkbox field without a form to toggle it's value
if (checked && type === 'checkbox' && !form) {
newValue = resolveNextCheckboxValue(value.value, unref(valueProp), unref(uncheckedValue));
}

value.value = newValue;
meta.dirty = true;
if (!validateOnValueUpdate) {
return validate();
Expand Down
13 changes: 7 additions & 6 deletions packages/vee-validate/src/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
FormState,
FormValidationResult,
} from './types';
import { getFromPath, isYupValidator, keysOf, setInPath, unsetPath } from './utils';
import { getFromPath, isYupValidator, keysOf, resolveNextCheckboxValue, setInPath, unsetPath } from './utils';
import { FormErrorsSymbol, FormContextSymbol, FormInitialValuesSymbol } from './symbols';

interface FormOptions<TValues extends Record<string, any>> {
Expand Down Expand Up @@ -139,10 +139,7 @@ export function useForm<TValues extends Record<string, any> = Record<string, any

// Multiple checkboxes, and only one of them got updated
if (Array.isArray(fieldInstance) && fieldInstance[0]?.type === 'checkbox' && !Array.isArray(value)) {
const oldVal = getFromPath(formValues, field as string);
const newVal = Array.isArray(oldVal) ? [...oldVal] : [];
const idx = newVal.indexOf(value);
idx >= 0 ? newVal.splice(idx, 1) : newVal.push(value);
const newVal = resolveNextCheckboxValue(getFromPath(formValues, field as string) || [], value, undefined);
setInPath(formValues, field as string, newVal);
fieldInstance.forEach(fieldItem => {
valuesByFid[fieldItem.fid] = newVal;
Expand All @@ -153,7 +150,11 @@ export function useForm<TValues extends Record<string, any> = Record<string, any
let newValue = value;
// Single Checkbox: toggles the field value unless the field is being reset then force it
if (fieldInstance?.type === 'checkbox' && !force) {
newValue = getFromPath(formValues, field as string) === value ? fieldInstance.uncheckedValue : value;
newValue = resolveNextCheckboxValue<TValues[T]>(
getFromPath(formValues, field as string),
value as TValues[T],
unref(fieldInstance.uncheckedValue)
);
}

setInPath(formValues, field as string, newValue);
Expand Down
14 changes: 14 additions & 0 deletions packages/vee-validate/src/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,17 @@ export function normalizeField(field: FieldApi | FieldApi[]): FieldApi | undefin

return field;
}

export function resolveNextCheckboxValue<T>(currentValue: T, checkedValue: T, uncheckedValue: T): T;
export function resolveNextCheckboxValue<T>(currentValue: T[], checkedValue: T, uncheckedValue: T): T[];
export function resolveNextCheckboxValue<T>(currentValue: T | T[], checkedValue: T, uncheckedValue: T) {
if (Array.isArray(currentValue)) {
const newVal = [...currentValue];
const idx = newVal.indexOf(checkedValue);
idx >= 0 ? newVal.splice(idx, 1) : newVal.push(checkedValue);

return newVal;
}

return currentValue === checkedValue ? uncheckedValue : checkedValue;
}
26 changes: 26 additions & 0 deletions packages/vee-validate/tests/Field.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,4 +842,30 @@ describe('<Field />', () => {
await flushPromises();
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ terms: false }), expect.anything());
});

// #3105
test('single checkboxes without forms toggles their value with v-model', async () => {
let model!: Ref<boolean>;
const wrapper = mountWithHoc({
setup() {
model = ref(false);

return { model };
},
template: `
<div>
<Field name="terms" type="checkbox" v-model="model" :unchecked-value="false" :value="true" /> Dinner?
</div>
`,
});

await flushPromises();
const input = wrapper.$el.querySelector('input');
setChecked(input, true);
await flushPromises();
expect(model.value).toBe(true);
setChecked(input, false);
await flushPromises();
expect(model.value).toBe(false);
});
});

0 comments on commit 504f30b

Please sign in to comment.