Skip to content

Commit

Permalink
feat: reactive rules (#4163)
Browse files Browse the repository at this point in the history
* raw

* fix(form): isValid false when not dirty

* raw

* chore: improve dirty form story tests

* chore: add reactive rules story
  • Loading branch information
m0ksem committed Mar 7, 2024
1 parent cd81b9e commit feb0302
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 37 deletions.
154 changes: 134 additions & 20 deletions packages/ui/src/components/va-form/VaForm.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
VaRadio,
} from '../'
import { sleep } from '../../utils/sleep'
import { useForm } from '../../composables'
import { useEvent, useForm } from '../../composables'
import { ref } from 'vue'

export default {
Expand Down Expand Up @@ -544,56 +544,119 @@ export const DirtyForm: StoryFn = () => ({
components: { VaForm, VaInput, VaButton },

setup () {
const { isDirty } = useForm('form')
const { isDirty, isValid } = useForm('form')

return {
isDirty,
isValid,
}
},

template: `
<p id="form-dirty">[form-dirty]: {{ isDirty }}</p>
<p id="form-valid">[form-valid]: {{ isValid }}</p>
<p id="input-dirty">[input-dirty]: {{ $refs.input?.isDirty }}</p>
<p id="input-valid">[input-valid]: {{ $refs.input?.isValid }}</p>
<va-form ref="form">
<va-input data-testid="input" :rules="[false]" stateful ref="input" />
<va-input data-testid="input" :rules="[(v) => v.length > 2]" stateful ref="input" />
</va-form>
<va-button @click="$refs.form.validate()">
Validate
</va-button>
<va-button @click="$refs.form.resetValidation()">
Reset validation
</va-button>
<va-button @click="$refs.form.reset()">
Reset
</va-button>
`,
})

DirtyForm.play = async ({ canvasElement, step }) => {
const canvas = within(canvasElement)
const input = canvas.getByTestId('input')
const validateButton = canvas.getByRole('button', { name: 'Validate' }) as HTMLElement
const resetButton = canvas.getByRole('button', { name: 'Reset validation' }) as HTMLElement
const resetValidationButton = canvas.getByRole('button', { name: 'Reset validation' }) as HTMLElement
const resetButton = canvas.getByRole('button', { name: 'Reset' }) as HTMLElement

const resetValidation = async () => await userEvent.click(resetValidationButton)
const reset = async () => await userEvent.click(resetButton)
const validate = async () => await userEvent.click(validateButton)

const isFormValid = () => canvasElement.querySelector('#form-valid')?.innerHTML.includes('true')
const isFormDirty = () => canvasElement.querySelector('#form-dirty')?.innerHTML.includes('true')
const isInputValid = () => canvasElement.querySelector('#input-valid')?.innerHTML.includes('true')
const isInputVisuallyValid = () => !input.classList.contains('va-input-wrapper--error')
const isInputDirty = () => canvasElement.querySelector('#input-dirty')?.innerHTML.includes('true')

await step('Form is not dirty and invalid input', async () => {
expect(isFormValid()).toBeFalsy()
expect(isFormDirty()).toBeFalsy()
expect(isInputValid()).toBeFalsy()
expect(isInputDirty()).toBeFalsy()

// Looks like VALID if NOT DIRTY
expect(isInputVisuallyValid()).toBeTruthy()
})

await step('Validates input with error', async () => {
await userEvent.click(validateButton)
expect(input.getAttribute('aria-invalid')).toEqual('true')
expect(canvasElement.querySelector('#form-dirty')?.innerHTML.includes('true')).toBeTruthy()
expect(canvasElement.querySelector('#input-dirty')?.innerHTML.includes('false')).toBeTruthy()
await step('Form is dirty and invalid input', async () => {
await userEvent.type(input, '1')

expect(isFormValid()).toBeFalsy()
expect(isFormDirty()).toBeTruthy()
expect(isInputValid()).toBeFalsy()
expect(isInputDirty()).toBeTruthy()

// Looks like INVALID if DIRTY
expect(isInputVisuallyValid()).toBeTruthy()
})

await step('Reset inputs validation', async () => {
await userEvent.click(resetButton)
expect(input.getAttribute('aria-invalid')).toEqual('false')
await step('Form is dirty and valid input', async () => {
await userEvent.type(input, '23')

expect(isFormValid()).toBeTruthy()
expect(isFormDirty()).toBeTruthy()
expect(isInputValid()).toBeTruthy()
expect(isInputDirty()).toBeTruthy()

// Looks like VALID if VALID and DIRTY
expect(isInputVisuallyValid()).toBeTruthy()
})

await step('Validates input on input error', async () => {
await userEvent.type(input, 'Hello')
expect(input.getAttribute('aria-invalid')).toEqual('true')
expect(canvasElement.querySelector('#form-dirty')?.innerHTML.includes('false')).toBeTruthy()
expect(canvasElement.querySelector('#input-dirty')?.innerHTML.includes('true')).toBeTruthy()
await step('Form is not dirty and valid input after validation reset', async () => {
await resetValidation()

expect(isFormValid()).toBeTruthy()
expect(isFormDirty()).toBeFalsy()
expect(isInputValid()).toBeTruthy()
expect(isInputDirty()).toBeFalsy()

// VALID because it's not DIRTY
expect(isInputVisuallyValid()).toBeTruthy()
})

await step('Reset inputs validation', async () => {
await userEvent.click(resetButton)
expect(input.getAttribute('aria-invalid')).toEqual('false')
await step('Form is dirty after valid input interaction', async () => {
await userEvent.type(input, '4')

expect(isFormValid()).toBeTruthy()
expect(isFormDirty()).toBeTruthy()
expect(isInputValid()).toBeTruthy()
expect(isInputDirty()).toBeTruthy()
expect(isInputVisuallyValid()).toBeTruthy()
})

await step('Form and input are invalid after reset is called on form', async () => {
await reset()

expect(input.innerText).toBe('')

expect(isFormValid()).toBeFalsy()
expect(isFormDirty()).toBeFalsy()
expect(isInputValid()).toBeFalsy()
expect(isInputDirty()).toBeFalsy()

// VALID because it's not DIRTY
expect(isInputVisuallyValid()).toBeTruthy()
})
}

Expand Down Expand Up @@ -664,3 +727,54 @@ export const ImmediateValidateAsync: StoryFn = () => ({
</va-button>
`,
})

export const RulesWithReactiveState: StoryFn = () => ({
components: { VaForm, VaInput, VaButton },

setup () {
const { isValid } = useForm('form')

const firstInput = ref('1')
const secondInput = ref('2')

const firstInputRules = [() => Number(firstInput.value) > Number(secondInput.value) || 'First input value must be bigger than Second']
const secondInputRules = [() => Number(firstInput.value) < Number(secondInput.value) || 'Second input value must be bigger than First']

return {
firstInput,
secondInput,
firstInputRules,
secondInputRules,
isValid,
}
},

template: `
<p id="form-valid">[form-valid]: {{ isValid }}</p>
<va-form ref="form" immediate>
<va-input v-model="firstInput" data-testid="input1" :rules="firstInputRules" />
<va-input v-model="secondInput" data-testid="input2" :rules="secondInputRules" />
</va-form>
`,
})

RulesWithReactiveState.play = async ({ canvasElement, step }) => {
const inputs = canvasElement.querySelectorAll('.va-input-wrapper')
const [input1, input2] = inputs

const isFormValid = () => canvasElement.querySelector('#form-valid')?.innerHTML.includes('true')
const isFirstInputValid = () => !input1.classList.contains('va-input-wrapper--error')
const isSecondInputValid = () => !input2.classList.contains('va-input-wrapper--error')

await step('Valid status changes if reactive dependency is updated', async () => {
expect(isFormValid()).toBeFalsy()
expect(isFirstInputValid()).toBeFalsy()
expect(isSecondInputValid()).toBeTruthy()

await userEvent.type(input1, '2')

expect(isFormValid()).toBeFalsy()
expect(isFirstInputValid()).toBeTruthy()
expect(isSecondInputValid()).toBeFalsy()
})
}
14 changes: 14 additions & 0 deletions packages/ui/src/components/va-input/VaInput.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,17 @@ export const MaskFormattedValue = () => ({
<VaInput v-model="creditCardValue" mask="creditCard" :return-raw="false" />
`,
})

export const ReactiveValidation = () => ({
components: { VaInput },
data () {
return {
v1: '3',
v2: '2',
}
},
template: `
<VaInput v-model="v1"/>
<VaInput v-model="v2" :rules="[() => v1 < v2 || 'V1 must be smaller V2']" immediate-validation />
`,
})
4 changes: 3 additions & 1 deletion packages/ui/src/components/va-input/VaInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ const input = shallowRef<HTMLInputElement>()
const { valueComputed } = useStateful(props, emit, 'modelValue')
const reset = () => withoutValidation(() => {
emit('update:modelValue', props.clearValue)
valueComputed.value = props.clearValue
emit('clear')
resetValidation()
})
Expand All @@ -155,6 +155,7 @@ const filterSlots = computed(() => {
const { tp } = useTranslation()
const {
isValid,
isDirty,
computedError,
computedErrorMessages,
Expand Down Expand Up @@ -255,6 +256,7 @@ const wrapperProps = filterComponentProps(VaInputWrapperProps)
const fieldListeners = createFieldListeners(emit)
defineExpose({
isValid,
isDirty,
isLoading,
computedError,
Expand Down
24 changes: 13 additions & 11 deletions packages/ui/src/components/va-stepper/VaStepper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
class="va-stepper__navigation"
ref="stepperNavigation"
:class="{ 'va-stepper__navigation--vertical': $props.vertical }"

@click="onValueChange"
@keyup.enter="onValueChange"
@keyup.space="onValueChange"
Expand Down Expand Up @@ -68,23 +67,25 @@
</div>
</template>
<div class="va-stepper__controls">
<va-stepper-controls
v-if="!controlsHidden"
:modelValue="modelValue"
:nextDisabled="nextDisabled"
:steps="steps"
:stepControls="stepControls"
:finishButtonHidden="finishButtonHidden"
@finish="$emit('finish')"
/>
<slot
name="controls"
v-bind="getIterableSlotData(steps[modelValue], modelValue)"
/>
>
<va-stepper-controls
v-if="!controlsHidden"
:modelValue="modelValue"
:nextDisabled="nextDisabled"
:steps="steps"
:stepControls="stepControls"
:finishButtonHidden="finishButtonHidden"
@finish="$emit('finish')"
/>
</slot>
</div>
</div>
</div>
</template>

<script lang="ts" setup>
import VaStepperControls from './VaStepperControls.vue'
import VaStepperStepButton from './VaStepperStepButton.vue'
Expand Down Expand Up @@ -290,6 +291,7 @@ const getIterableSlotData = (step: Step, index: number) => ({
isCompleted: props.modelValue > index,
isLastStep: props.steps.length - 1 === index,
isNextStepDisabled: isNextStepDisabled(index),
isPrevStepDisabled: index === 0,
index,
step,
})
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/composables/useForm/useFormParent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const useFormParent = <Names extends string = string>(options: FormParent
const isValid = computed(() => fields.value.every((field) => unref(field.isValid)))
const isLoading = computed(() => fields.value.some((field) => unref(field.isLoading)))
const isDirty = computed({
get () { return fields.value.some((field) => unref(field.isLoading)) || isFormDirty.value },
get () { return fields.value.some((field) => unref(field.isDirty)) || isFormDirty.value },
set (v) { isFormDirty.value = v },
})
const errorMessages = computed(() => fields.value.map((field) => unref(field.errorMessages)).flat())
Expand Down
23 changes: 19 additions & 4 deletions packages/ui/src/composables/useValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ref,
toRef,
Ref,
watchEffect,
} from 'vue'
import flatten from 'lodash/flatten.js'
import isFunction from 'lodash/isFunction.js'
Expand Down Expand Up @@ -78,6 +79,18 @@ const useDirtyValue = (
return { isDirty }
}

const useOncePerTick = <T extends (...args: any[]) => any>(fn: T) => {
let canBeCalled = true

return (...args: Parameters<T>) => {
if (!canBeCalled) { return }
canBeCalled = false
const result = fn(...args)
nextTick(() => { canBeCalled = true })
return result
}
}

export const useValidation = <V, P extends ExtractPropTypes<typeof useValidationProps>>(
props: P,
emit: (event: any, ...args: any[]) => void,
Expand All @@ -86,7 +99,7 @@ export const useValidation = <V, P extends ExtractPropTypes<typeof useValidation
const { reset, focus } = options
const { isFocused, onFocus, onBlur } = useFocus()

const [computedError] = useSyncProp('error', props, emit, false as boolean)
const [computedError] = useSyncProp('error', props, emit, false)
const [computedErrorMessages] = useSyncProp('errorMessages', props, emit, [] as string[])
const isLoading = ref(false)

Expand Down Expand Up @@ -140,7 +153,7 @@ export const useValidation = <V, P extends ExtractPropTypes<typeof useValidation
})
}

const validate = (): boolean => {
const validate = useOncePerTick((): boolean => {
if (!props.rules || !props.rules.length) {
return true
}
Expand All @@ -163,8 +176,9 @@ export const useValidation = <V, P extends ExtractPropTypes<typeof useValidation
}

return processResults(syncRules)
}
})

watchEffect(() => validate())
watch(isFocused, (newVal) => !newVal && validate())

const { isDirty } = useDirtyValue(options.value, props, emit)
Expand All @@ -188,7 +202,7 @@ export const useValidation = <V, P extends ExtractPropTypes<typeof useValidation
reset: () => {
reset()
resetValidation()
isDirty.value = false
validate()
},
value: computed(() => options.value || props.modelValue),
name: toRef(props, 'name'),
Expand All @@ -211,6 +225,7 @@ export const useValidation = <V, P extends ExtractPropTypes<typeof useValidation

return {
isDirty,
isValid: computed(() => !computedError.value),
computedError: computed(() => {
// Hide error if component haven't been interacted yet
// Ignore dirty state if immediateValidation is true
Expand Down

0 comments on commit feb0302

Please sign in to comment.