diff --git a/packages/ui/src/components/va-carousel/VaCarousel.vue b/packages/ui/src/components/va-carousel/VaCarousel.vue index e59c7d0e63..7afab0f8b8 100644 --- a/packages/ui/src/components/va-carousel/VaCarousel.vue +++ b/packages/ui/src/components/va-carousel/VaCarousel.vue @@ -166,7 +166,7 @@ export default defineComponent({ emits: [...useStatefulEmits], setup (props, { emit }) { - const { valueComputed: currentSlide } = useStateful(props, emit, 'modelValue', { defaultValue: 0 }) + const { valueComputed: currentSlide } = useStateful(props, emit, 'modelValue') const { goTo, next, prev, diff --git a/packages/ui/src/components/va-form/VaForm.stories.ts b/packages/ui/src/components/va-form/VaForm.stories.ts index c2dc959226..695db8fa44 100644 --- a/packages/ui/src/components/va-form/VaForm.stories.ts +++ b/packages/ui/src/components/va-form/VaForm.stories.ts @@ -23,6 +23,7 @@ import { VaRadio, } from '../' import { sleep } from '../../utils/sleep' +import { useForm } from '../../composables' export default { title: 'VaForm', @@ -475,3 +476,66 @@ addText( 'This is old demo resqued to have visual tests, but we want to rewrite it eventually.', 'stale', ) + +export const FormDataInitialValue = () => ({ + components: { + VaForm, + VaInput, + VaSelect, + VaDateInput, + VaTimeInput, + VaOptionList, + VaButton, + }, + data: () => ({ + input: 'value', + checkbox: true, + date: new Date(0), + time: new Date(0), + options: OPTIONS, + select: OPTIONS[0], + validationRules: [false], + }), + setup () { + const form = useForm('formEl') + + return { + form, + } + }, + template: ` + [form data]:
{{ form.formData }}
+ + + + + + + + + + Reset form + + `, +}) diff --git a/packages/ui/src/components/va-input/VaInput.stories.ts b/packages/ui/src/components/va-input/VaInput.stories.ts index 9b1fa67267..ccd9908842 100644 --- a/packages/ui/src/components/va-input/VaInput.stories.ts +++ b/packages/ui/src/components/va-input/VaInput.stories.ts @@ -1,3 +1,4 @@ +import { ref } from 'vue' import VaInputDemo from './VaInput.demo.vue' import VaInput from './VaInput.vue' import { expect } from '@storybook/jest' @@ -19,6 +20,11 @@ export const Loading = () => ({ template: '', }) +export const Stateful = () => ({ + components: { VaInput }, + template: '', +}) + export const Clearable = () => ({ components: { VaInput }, data () { diff --git a/packages/ui/src/components/va-input/VaInput.vue b/packages/ui/src/components/va-input/VaInput.vue index 693c5a689b..f1403ccb4a 100644 --- a/packages/ui/src/components/va-input/VaInput.vue +++ b/packages/ui/src/components/va-input/VaInput.vue @@ -103,7 +103,7 @@ export default defineComponent({ // input placeholder: { type: String, default: '' }, tabindex: { type: [String, Number], default: 0 }, - modelValue: { type: [String, Number] }, + modelValue: { type: [Number, String], default: '' }, type: { type: String as AnyStringPropType<'text' | 'password'>, default: 'text' }, inputClass: { type: String, default: '' }, pattern: { type: String }, @@ -132,7 +132,7 @@ export default defineComponent({ const input = shallowRef() - const { valueComputed } = useStateful(props, emit, 'modelValue', { defaultValue: '' }) + const { valueComputed } = useStateful(props, emit, 'modelValue') const reset = () => withoutValidation(() => { emit('update:modelValue', props.clearValue) diff --git a/packages/ui/src/components/va-select/VaSelect.stories.ts b/packages/ui/src/components/va-select/VaSelect.stories.ts index 85818eb690..3a337011d6 100644 --- a/packages/ui/src/components/va-select/VaSelect.stories.ts +++ b/packages/ui/src/components/va-select/VaSelect.stories.ts @@ -29,7 +29,7 @@ export const Validation: StoryFn = () => ({ components: { VaSelect }, data () { - return { value: '', options: ['one', 'two', 'tree'], rules: [(v: string) => (v && v === 'one') || 'Must be one'] } + return { value: '', options: ['one', 'two', 'three'], rules: [(v: string) => (v && v === 'one') || 'Must be one'] } }, template: '', @@ -46,7 +46,7 @@ export const ImmediateValidation: StoryFn = () => ({ components: { VaSelect }, data () { - return { value: '', options: ['one', 'two', 'tree'], rules: [(v: string) => (v && v === 'one') || 'Must be one'] } + return { value: '', options: ['one', 'two', 'three'], rules: [(v: string) => (v && v === 'one') || 'Must be one'] } }, template: '', @@ -63,7 +63,7 @@ export const DirtyValidation: StoryFn = () => ({ components: { Component: VaSelect }, data () { - return { value: '', dirty: false, haveError: false, options: ['one', 'two', 'tree'], rules: [(v: string) => (v && v === 'one') || 'Must be one'] } + return { value: '', dirty: false, haveError: false, options: ['one', 'two', 'three'], rules: [(v: string) => (v && v === 'one') || 'Must be one'] } }, template: ` @@ -104,7 +104,7 @@ export const DirtyImmediateValidation: StoryFn = () => ({ components: { Component: VaSelect }, data () { - return { value: '', dirty: false, haveError: false, options: ['one', 'two', 'tree'], rules: [(v: string) => (v && v === 'one') || 'Must be one'] } + return { value: '', dirty: false, haveError: false, options: ['one', 'two', 'three'], rules: [(v: string) => (v && v === 'one') || 'Must be one'] } }, template: ` @@ -124,3 +124,24 @@ DirtyImmediateValidation.play = async ({ canvasElement, step }) => { expect(error).not.toBeNull() }) } + +export const Autocomplete: StoryFn = () => ({ + components: { VaSelect }, + + data () { + // Test if initial value is correctly set + return { value: 'one', options: ['one', 'two', 'three'] } + }, + + template: '', +}) + +export const AutocompleteMultiple: StoryFn = () => ({ + components: { VaSelect }, + + data () { + return { value: ['one', 'two'], options: ['one', 'two', 'three'] } + }, + + template: '', +}) diff --git a/packages/ui/src/components/va-select/hooks/useAutocomplete.ts b/packages/ui/src/components/va-select/hooks/useAutocomplete.ts index 0e9f981044..2fa9dc71eb 100644 --- a/packages/ui/src/components/va-select/hooks/useAutocomplete.ts +++ b/packages/ui/src/components/va-select/hooks/useAutocomplete.ts @@ -17,6 +17,11 @@ export const useAutocomplete = ( ) => { const getLastOptionText = (v: SelectOption[]) => v?.length ? getText(v.at(-1)!) : '' + if (props.autocomplete && !props.multiple) { + // Set current value as autocomplete value text + autocompleteValue.value = getLastOptionText(value.value) + } + watch(value, (newValue, oldValue) => { if (!props.autocomplete) { return } diff --git a/packages/ui/src/components/va-stepper/VaStepper.vue b/packages/ui/src/components/va-stepper/VaStepper.vue index fd79ae74e2..28d6710324 100644 --- a/packages/ui/src/components/va-stepper/VaStepper.vue +++ b/packages/ui/src/components/va-stepper/VaStepper.vue @@ -116,7 +116,7 @@ export default defineComponent({ emits: ['update:modelValue', 'finish', 'update:steps'], setup (props, { emit }) { const stepperNavigation = shallowRef() - const { valueComputed: modelValue }: { valueComputed: Ref } = useStateful(props, emit, 'modelValue', { defaultValue: 0 }) + const { valueComputed: modelValue }: { valueComputed: Ref } = useStateful(props, emit, 'modelValue') const focusedStep = ref({ trigger: false, stepIndex: props.navigationDisabled ? -1 : props.modelValue }) diff --git a/packages/ui/src/composables/tests/useStateful.spec.ts b/packages/ui/src/composables/tests/useStateful.spec.ts index ae8b4df209..673f3e08b9 100644 --- a/packages/ui/src/composables/tests/useStateful.spec.ts +++ b/packages/ui/src/composables/tests/useStateful.spec.ts @@ -30,8 +30,8 @@ describe('useStateful', () => { [ true, true, true ], [ false, true, undefined ], /* eslint-enable */ - ])('stateful %s', async (stateful: boolean, valueToSet: boolean, internalValue?: true) => { - const wrapper = mount(TestComponentRich, { props: { stateful } }) + ])('stateful %s', async (stateful: boolean, valueToSet: boolean, internalValue?: boolean) => { + const wrapper = mount(TestComponentRich, { props: { stateful } as any }) wrapper.vm.valueComputed = valueToSet expect(wrapper.emitted()['update:modelValue']).toBeTruthy() expect(wrapper.vm.valueComputed).toBe(internalValue) @@ -39,4 +39,9 @@ describe('useStateful', () => { await wrapper.setProps({ modelValue: false }) expect(wrapper.vm.valueComputed).toBe(false) }) + + it('should react to prop change', async () => { + const wrapper = mount(TestComponentRich, { props: { stateful: true, modelValue: 'Hello!' } }) + expect(wrapper.vm.valueComputed).toBe('Hello!') + }) }) diff --git a/packages/ui/src/composables/tests/useUserProvidedProp.spec.ts b/packages/ui/src/composables/tests/useUserProvidedProp.spec.ts new file mode 100644 index 0000000000..da7de5586f --- /dev/null +++ b/packages/ui/src/composables/tests/useUserProvidedProp.spec.ts @@ -0,0 +1,27 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect } from 'vitest' +import { defineComponent } from 'vue' +import { NOT_PROVIDED, useUserProvidedProp } from '../useUserProvidedProp' + +const TestComponentRich = defineComponent({ + template: '

', + props: { modelValue: { type: String } }, + setup (props) { + const providedProp = useUserProvidedProp('modelValue', props) + + return { + providedProp, + } + }, + emits: ['update:modelValue'], +}) + +describe('useUserProvidedProp', () => { + it('should react to prop change', async () => { + const wrapper = mount(TestComponentRich, { props: { stateful: true } } as any) + expect(wrapper.vm.providedProp).toBe(NOT_PROVIDED) + + await wrapper.setProps({ modelValue: 'Hello' }) + expect(wrapper.vm.providedProp).toBe('Hello') + }) +}) diff --git a/packages/ui/src/composables/useSelectable.ts b/packages/ui/src/composables/useSelectable.ts index 708981eea3..af28500b4d 100644 --- a/packages/ui/src/composables/useSelectable.ts +++ b/packages/ui/src/composables/useSelectable.ts @@ -15,6 +15,7 @@ export type SelectableProps = StatefulProps & LoadingProps & ExtractPro indeterminateValue: V | null, disabled: boolean, readonly: boolean, + modelValue: unknown } export type Elements = { diff --git a/packages/ui/src/composables/useStateful.ts b/packages/ui/src/composables/useStateful.ts index c57096b2be..a69a53f86e 100644 --- a/packages/ui/src/composables/useStateful.ts +++ b/packages/ui/src/composables/useStateful.ts @@ -1,4 +1,5 @@ -import { ref, computed, watch, PropType, Ref } from 'vue' +import { ref, computed, watch, PropType, Ref, getCurrentInstance, watchEffect } from 'vue' +import { NOT_PROVIDED, useUserProvidedProp } from './useUserProvidedProp' export type StatefulProps = { stateful: boolean @@ -6,7 +7,7 @@ export type StatefulProps = { export type StatefulOptions = { eventName?: string - /** @deprecated set default value for prop, not here */ + /** Prefer to set default value for prop, not here. */ defaultValue?: T } @@ -41,18 +42,21 @@ export const useStateful = < D extends any, O extends StatefulOptions, Key extends string = 'modelValue', - P extends StatefulProps & { [key in Key]?: T } = StatefulProps & { [key in Key]?: T }, + P extends StatefulProps & Record = StatefulProps & Record >( props: P, emit: (name: `update:${Key}`, ...args: any[]) => void, key: Key = 'modelValue' as Key, options: O = {} as O, ) => { - const { defaultValue, eventName } = options + const { eventName, defaultValue } = options const event = (eventName || `update:${key.toString()}`) as `update:${Key}` - const valueState = ref(defaultValue === undefined ? props[key] : defaultValue) as Ref - let unwatchModelValue: Function + const passedProp = useUserProvidedProp(key, props) + + const valueState = ref(passedProp.value === NOT_PROVIDED ? defaultValue || props[key] : passedProp) as Ref + + let unwatchModelValue: ReturnType const watchModelValue = () => { unwatchModelValue = watch(() => props[key], (modelValue) => { valueState.value = modelValue @@ -63,7 +67,7 @@ export const useStateful = < stateful ? watchModelValue() : unwatchModelValue?.() }, { immediate: true }) - const valueComputed = computed>({ + const valueComputed = computed({ get: () => { if (props.stateful) { return valueState.value } diff --git a/packages/ui/src/composables/useUserProvidedProp.ts b/packages/ui/src/composables/useUserProvidedProp.ts new file mode 100644 index 0000000000..8ccba997d5 --- /dev/null +++ b/packages/ui/src/composables/useUserProvidedProp.ts @@ -0,0 +1,14 @@ +import { computed, getCurrentInstance } from 'vue' + +export const NOT_PROVIDED = Symbol('NOT_PROVIDED') + +export const useUserProvidedProp = >(propName: Name, props: Props) => { + const vm = getCurrentInstance()! + + return computed(() => { + if (!vm?.vnode.props) { return null } + const originalProp = props[propName] + // If vnode doesn't have this prop it mean default value is used + return propName in vm.vnode.props ? originalProp as Props[Name] : NOT_PROVIDED + }) +}