Repository: effector/effector
Pull request #686 (2022-05-20 22:59:55)

Allow pass shape of Stores [v-model]
Added support for passing shape of object. It would be great if developer uses something like effector-forms for vue.

```
const form = createForm({ ... })
form.$fields // shape of stores
```

After this update each field can be modified store separately


Patch:
diff --git a/docs/api/effector-vue/useGate.md b/docs/api/effector-vue/useGate.md
index eca4384d..218e47f2 100644
--- a/docs/api/effector-vue/useGate.md
+++ b/docs/api/effector-vue/useGate.md
@@ -9,11 +9,11 @@ description: Using a gate to consume data from view. Designed for vue 3
 
 ## `useGate(GateComponent, props)`
 
-Using a [_`Gate`_](Gate.md) to consume data from view. Designed for vue 3
+Using a [_`Gate`_](./Gate.md) to consume data from view. Designed for vue 3
 
 **Arguments**
 
-1. `GateComponent` (_Gate_)
+1. `GateComponent` ([_Gate_](./Gate.md))
 2. `props` (_Props_)
 
 **Returns**
diff --git a/docs/api/effector-vue/useVModel.md b/docs/api/effector-vue/useVModel.md
index 744684f8..89e859e8 100644
--- a/docs/api/effector-vue/useVModel.md
+++ b/docs/api/effector-vue/useVModel.md
@@ -16,13 +16,14 @@ Designed for vue 3
 
 **Arguments**
 
-1. `store` (_Store_)
+1. `store` ([_Store_](../effector/Store.md))
+2. `shape of Stores` ([_Store_](../effector/Store.md))
 
 **Returns**
 
-(_State_)
+(State)
 
-### Example
+### Example 1 (Single Store)
 
 ```js
 import {createStore, createApi} from 'effector'
@@ -57,3 +58,42 @@ export default {
   </div>
 </div>
 ```
+### Example 2 (Store Shape)
+
+```js
+import {createStore, createApi} from 'effector'
+import {useVModel} from 'effector-vue/composition'
+
+const $name = createStore('')
+const $surname = createStore('')
+const $skills = createStore([])
+
+const model = {
+  name: $name,
+  surname: $surname
+  skills: $skills
+}
+
+export default {
+  setup() {
+    const user = useVModel(model);
+
+    return {
+      user
+    }
+  }
+}
+```
+
+```html
+<div id="app">
+  <input type="text" v-model="user.name" />
+  <input type="text" v-model="user.surname" />
+
+  <div>
+    <input type="checkbox" v-model="user.skills" value="HTML" />
+    <input type="checkbox" v-model="user.skills" value="CSS" />
+    <input type="checkbox" v-model="user.skills" value="JS" />
+  </div>
+</div>
+```
diff --git a/packages/effector-vue/composition.d.ts b/packages/effector-vue/composition.d.ts
index 908378d5..33ae22a4 100644
--- a/packages/effector-vue/composition.d.ts
+++ b/packages/effector-vue/composition.d.ts
@@ -16,7 +16,16 @@ type Gate<Props> = {
   set: Event<Props>;
 };
 
+type ExtractStore<T extends Record<string, Store<unknown>>> = {
+  [Key in keyof T]: T[Key] extends Store<infer U> ? Ref<UnwrapRef<U>> : never
+ }
+
+ export interface UseVModel {
+   <T>(vm: Store<T>): Ref<T>
+   <T extends Record<string, Store<any>>>(vm: T): ExtractStore<T>
+ }
+export function useVModel<T>(vm: Store<T>): Ref<UnwrapRef<T>>
+export function useVModel<T extends Record<string, Store<any>>>(vm: T): ExtractStore<T>
 export function useStore<T>(store: Store<T>): DeepReadonly<Ref<T>>
-export function useVModel<T>(store: Store<T>): Ref<UnwrapRef<T>>
 export function createGate<Props>(config?: GateConfig<Props>): Gate<Props>
 export function useGate<Props>(GateComponent: Gate<Props>, cb?: () => Props): void
diff --git a/src/vue/useVModel.ts b/src/vue/useVModel.ts
index 335ad284..918ecf0f 100644
--- a/src/vue/useVModel.ts
+++ b/src/vue/useVModel.ts
@@ -1,13 +1,14 @@
-import {Store, is} from "effector"
-import {onUnmounted, ref, watch} from "vue-next"
+import {Store, is, createStore} from "effector"
+import {onUnmounted, reactive, Ref, ref, watch} from "vue-next"
 
 import {unwrapProxy} from "./lib/unwrapProxy"
 import {deepCopy} from "./lib/deepCopy"
 import {stateReader} from "./lib/state-reader"
 import {createWatch} from "./lib/create-watch"
 import {getScope} from "./lib/get-scope"
+import { UseVModel } from "effector-vue/composition"
 
-export function useVModel<T>(store: Store<T>) {
+function createVModel<T>(store: Store<T>, key?: string, shape?: Record<string, unknown>) {
   if (!is.store(store)) throw Error("expect useVModel argument to be a store")
 
   let {scope} = getScope()
@@ -36,8 +37,15 @@ export function useVModel<T>(store: Store<T>) {
     stop()
   })
 
+  const watchFn = () => {
+    if (key && shape) {
+      return shape[key]
+    }
+    return _.value
+  }
+
   watch(
-    () => _.value,
+    watchFn,
     (value) => {
       isSelfUpdate = true
 
@@ -53,5 +61,29 @@ export function useVModel<T>(store: Store<T>) {
     {deep: true, immediate: false},
   )
 
+  return _ as Ref<T>
+}
+
+function isStore<T,>(arg: Store<T> | Record<string, unknown>): arg is Store<T> {
+  return is.store(arg)
+}
+
+// @ts-expect-error
+export const useVModel: UseVModel = <T, K extends string = keyof Store<unknown>>(vm: Store<T> | Record<K, Store<T>>) => {
+  if (isStore(vm)) {
+    return createVModel(vm)
+  }
+
+  const _ = reactive({})
+
+  const shape = Object.fromEntries(
+    Object.entries<Store<T>>(vm).map(([key, value]) => [key, createVModel(value, key, _)])
+  )
+
+  for (const key in shape) {
+    // @ts-ignore
+    _[key] = shape[key]
+  }
+
   return _
 }


Test patch:
diff --git a/src/vue/__tests__/useVModel.test.ts b/src/vue/__tests__/useVModel.test.ts
index 21608116..deb10291 100644
--- a/src/vue/__tests__/useVModel.test.ts
+++ b/src/vue/__tests__/useVModel.test.ts
@@ -1,11 +1,10 @@
 /* eslint-disable react-hooks/rules-of-hooks */
 import {useVModel} from 'effector-vue/composition'
-import {createEvent, createStore} from 'effector'
+import {createEvent, createStore, restore} from 'effector'
 import {shallowMount} from 'vue-test-utils-next'
 
 jest.mock('vue', () => require('vue-next'))
 
-
 it('updated value of input if store changed from outside', async () => {
   const updated = createEvent()
   const $user = createStore({
@@ -13,8 +12,9 @@ it('updated value of input if store changed from outside', async () => {
       {name: 'HTML', points: 10}
     ]
   })
+
   $user
-  .on(updated, (state) => ({...state, skills: [{name: 'HTML', points: 20}]}))
+    .on(updated, (state) => ({...state, skills: [{name: 'HTML', points: 20}]}))
 
   const wrapper = shallowMount({
     template: `
@@ -24,6 +24,7 @@ it('updated value of input if store changed from outside', async () => {
     `,
     setup() {
       const user = useVModel($user)
+      user.value.skills
       return {user}
     }
   })
@@ -146,3 +147,117 @@ it('[v-model] works correct with radio (like vue-3 way)', async () => {
   await wrapper.find('[data-test="gender"]').setValue()
   expect($gender.getState()).toEqual('female')
 })
+
+it('[v-model] default values rendered correct when passing object with Store values', async () => {
+  const $user = {
+    name: createStore('John'),
+    surname: createStore('Doe'),
+  }
+
+  const wrapper = shallowMount({
+    template: `
+      <div>
+        <input v-model="user.name" data-test="name">
+        <input v-model="user.surname" data-test="surname">
+      </div>
+    `,
+    setup() {
+      const user = useVModel($user)
+
+      return {user}
+    }
+  })
+
+  const nameInput = wrapper.find('[data-test="name"]').element as any
+  const surnameInput = wrapper.find('[data-test="surname"]').element as any
+
+  expect(nameInput.value).toBe('John')
+  expect(surnameInput.value).toBe('Doe')
+})
+
+it('[v-model] changed deep value', async () => {
+  const updated = createEvent()
+  const $user = createStore({
+    skills: [
+      {name: 'HTML', points: 10}
+    ]
+  })
+
+  $user
+    .on(updated, (state) => ({...state, skills: [{name: 'HTML', points: 20}]}))
+
+  const userForm = {
+    base: $user
+  }
+
+  const wrapper = shallowMount({
+    template: `
+      <div>
+        <input v-model="user.base.skills[0].points" data-test="skills.points">
+      </div>
+    `,
+    setup() {
+      const user = useVModel(userForm)
+
+      return {user}
+    }
+  })
+  await wrapper.find('[data-test="skills.points"]').setValue(15)
+
+  expect($user.getState()).toEqual({
+    skills: [{name: 'HTML', points: '15'}]
+  })
+
+  updated()
+
+  await wrapper.vm.$nextTick()
+  // @ts-ignore
+  expect(wrapper.find('[data-test="skills.points"]').element.value).toBe('20')
+})
+
+it('[v-model] change each store value separately', async () => {
+  const nameChanged = createEvent<string>()
+  const surnameChanged = createEvent<string>()
+
+  const $name = restore(nameChanged, 'John')
+  const $surname = restore(surnameChanged, 'Doe')
+
+  const $user = {
+    name: $name,
+    surname: $surname,
+  }
+
+  const wrapper = shallowMount({
+    template: `
+      <div>
+        <input v-model="user.name" data-test="name">
+        <input v-model="user.surname" data-test="surname">
+      </div>
+    `,
+    setup() {
+      const user = useVModel($user)
+
+      return {user}
+    }
+  })
+
+  const nameInput = wrapper.find('[data-test="name"]').element as any
+  const surnameInput = wrapper.find('[data-test="surname"]').element as any
+
+  expect(nameInput.value).toBe('John')
+  expect(surnameInput.value).toBe('Doe')
+
+  await wrapper.find('[data-test="name"]').setValue('Alan')
+  await wrapper.find('[data-test="surname"]').setValue('Boe')
+
+  expect($name.getState()).toEqual('Alan')
+  expect($surname.getState()).toEqual('Boe')
+
+  nameChanged('John')
+  surnameChanged('Doe')
+
+  await wrapper.vm.$nextTick()
+
+  expect(nameInput.value).toBe('John')
+  expect(surnameInput.value).toBe('Doe')
+})