tracked-instance 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/tests.yaml +22 -0
- package/.idea/compiler.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/tracked-instance.iml +12 -0
- package/.idea/vcs.xml +6 -0
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/package.json +41 -0
- package/src/collection.ts +82 -0
- package/src/index.ts +5 -0
- package/src/tracked-instance.ts +339 -0
- package/tests/collection.spec.ts +99 -0
- package/tests/tracked-instance.spec.ts +313 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: Run tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ master ]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
test:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
node-version: [20.x]
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v2
|
|
17
|
+
- name: Use Node.js ${{ matrix.node-version }}
|
|
18
|
+
uses: actions/setup-node@v1
|
|
19
|
+
with:
|
|
20
|
+
node-version: ${{ matrix.node-version }}
|
|
21
|
+
- run: npm install
|
|
22
|
+
- run: npm run test
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="ProjectModuleManager">
|
|
4
|
+
<modules>
|
|
5
|
+
<module fileurl="file://$PROJECT_DIR$/.idea/tracked-instance.iml" filepath="$PROJECT_DIR$/.idea/tracked-instance.iml" />
|
|
6
|
+
</modules>
|
|
7
|
+
</component>
|
|
8
|
+
</project>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<module type="WEB_MODULE" version="4">
|
|
3
|
+
<component name="NewModuleRootManager">
|
|
4
|
+
<content url="file://$MODULE_DIR$">
|
|
5
|
+
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
|
6
|
+
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
|
7
|
+
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
|
8
|
+
</content>
|
|
9
|
+
<orderEntry type="inheritedJdk" />
|
|
10
|
+
<orderEntry type="sourceFolder" forTests="false" />
|
|
11
|
+
</component>
|
|
12
|
+
</module>
|
package/.idea/vcs.xml
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-present Dmytro Rudnyk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
# 🚀 Features
|
|
4
|
+
- 🕶 Track what changed in your form
|
|
5
|
+
- 🌎 Send on backend only fields which changed
|
|
6
|
+
- 📦 Build multiple requests only for items that have been changed/removed/added
|
|
7
|
+
- 🦾 Type Strong: Written in TypeScript
|
|
8
|
+
|
|
9
|
+
# Description
|
|
10
|
+
Build large forms and send all requests in one take.
|
|
11
|
+
Combination of useTrackedInstance and useCollection can manage very large form with entities which deeply related each other.
|
|
12
|
+
You can control what data should be sent to the server so that only what has changed is sent.
|
|
13
|
+
|
|
14
|
+
# Install
|
|
15
|
+
> npm i tracked-instance
|
|
16
|
+
|
|
17
|
+
# Support
|
|
18
|
+
Supports Vue 3.x only
|
|
19
|
+
|
|
20
|
+
# Usage
|
|
21
|
+
|
|
22
|
+
## Tracked instance
|
|
23
|
+
|
|
24
|
+
Track everything what was changed
|
|
25
|
+
|
|
26
|
+
```javascript
|
|
27
|
+
const {data, changedData, isDirty, loadData, reset} = useTrackedInstance({
|
|
28
|
+
name: 'Jack',
|
|
29
|
+
isActive: false
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
Do some changes and see only changed field in changedData.
|
|
33
|
+
Then set previous value and see what changedData is empty.
|
|
34
|
+
That guaranty what you always get real changes
|
|
35
|
+
```javascript
|
|
36
|
+
data.value.name = 'John'
|
|
37
|
+
console.log(isDirty.value) // true
|
|
38
|
+
console.log(changedData.value) // {name: 'John'}
|
|
39
|
+
|
|
40
|
+
data.value.name = 'Jack'
|
|
41
|
+
console.log(isDirty.value) // false
|
|
42
|
+
console.log(changedData.value) // undefined
|
|
43
|
+
```
|
|
44
|
+
Rollback initial value:
|
|
45
|
+
```javascript
|
|
46
|
+
data.value.name = 'John'
|
|
47
|
+
reset()
|
|
48
|
+
console.log(data.value) // { name: 'Jack', isActive: false }
|
|
49
|
+
console.log(isDirty.value) // false
|
|
50
|
+
console.log(changedData.value) // undefined
|
|
51
|
+
```
|
|
52
|
+
All changes should be replaced by new loaded data.
|
|
53
|
+
The data will be considered not dirty
|
|
54
|
+
```javascript
|
|
55
|
+
data.value.name = 'John'
|
|
56
|
+
data.value.isActive = true
|
|
57
|
+
loadData({
|
|
58
|
+
name: 'Joe',
|
|
59
|
+
isActive: false
|
|
60
|
+
})
|
|
61
|
+
console.log(isDirty.value) // false
|
|
62
|
+
console.log(data.value) // { name: 'Joe', isActive: false }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Can accept primitive values or arrays
|
|
66
|
+
```javascript
|
|
67
|
+
useTrackedInstance(false)
|
|
68
|
+
useTrackedInstance([1,2,3])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Real-world example
|
|
72
|
+
```vue
|
|
73
|
+
<script setup>
|
|
74
|
+
import {useTrackedInstance} from 'tracked-instance'
|
|
75
|
+
|
|
76
|
+
const {data, changedData, isDirty, reset, loadData} = useTrackedInstance({
|
|
77
|
+
title: '',
|
|
78
|
+
year: null,
|
|
79
|
+
isPublished: false
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
loadData({
|
|
83
|
+
id: 1,
|
|
84
|
+
title: 'The Dark Knight',
|
|
85
|
+
year: 2008,
|
|
86
|
+
isPublished: true
|
|
87
|
+
})
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<template>
|
|
91
|
+
<button @click="reset">reset</button>
|
|
92
|
+
|
|
93
|
+
<form @submit.prevent="console.log(changedData.value)">
|
|
94
|
+
<input v-model="data.title" type="text">
|
|
95
|
+
<input v-model.number="data.year" type="text">
|
|
96
|
+
<input v-model="data.isPublished" type="checkbox">
|
|
97
|
+
|
|
98
|
+
<button type="submit" :disabled="!isDirty">Show changed data</button>
|
|
99
|
+
</form>
|
|
100
|
+
</template>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Collection
|
|
104
|
+
|
|
105
|
+
```javascript
|
|
106
|
+
const {isDirty, add, items, remove, reset, loadData} = useCollection()
|
|
107
|
+
|
|
108
|
+
loadData([{name: 'Jack'}, {name: 'John'}, {name: 'Joe'}])
|
|
109
|
+
```
|
|
110
|
+
Should be dirty on make some changes, remove or add item
|
|
111
|
+
```javascript
|
|
112
|
+
items.value[0].instance.data.value.name = 'Stepan'
|
|
113
|
+
console.log(isDirty.value) // true
|
|
114
|
+
```
|
|
115
|
+
Add new item:
|
|
116
|
+
```javascript
|
|
117
|
+
const addedItem = add({name: 'Taras'})
|
|
118
|
+
console.log(addedItem) // {instance: TrackedInstance<{name: 'Taras'}>, isRemoved: false, isNew: true, meta: {}}}
|
|
119
|
+
```
|
|
120
|
+
Add new item in specific position:
|
|
121
|
+
```javascript
|
|
122
|
+
add({name: 'Taras'}, 0)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Item should be softly removed and can be reverted by reset()
|
|
126
|
+
```javascript
|
|
127
|
+
remove(0)
|
|
128
|
+
remove(0, true) // hard remove
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Reset all changes including changing data on each item
|
|
132
|
+
```javascript
|
|
133
|
+
reset()
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Item meta. Additional custom fields which can watch on item instance.
|
|
137
|
+
If set then should be applied to each item which was added by add() or loadData()
|
|
138
|
+
```javascript
|
|
139
|
+
const {add, items} = useCollection(instance => ({
|
|
140
|
+
isValidName: computed(() => instance.data.value.name.length > 0)
|
|
141
|
+
}))
|
|
142
|
+
|
|
143
|
+
add({name: ''})
|
|
144
|
+
|
|
145
|
+
console.log(items.value[0].meta.isValidName.value) // false
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Real-world example
|
|
149
|
+
```vue
|
|
150
|
+
<script setup>
|
|
151
|
+
import {ref} from 'vue'
|
|
152
|
+
import {useCollection} from 'tracked-instance'
|
|
153
|
+
|
|
154
|
+
const {isDirty, add, items, remove, reset, loadData} = useCollection()
|
|
155
|
+
|
|
156
|
+
loadData([{name: 'Jack'}, {name: 'John'}, {name: 'Joe'}])
|
|
157
|
+
|
|
158
|
+
const newUserName = ref('')
|
|
159
|
+
</script>
|
|
160
|
+
|
|
161
|
+
<template>
|
|
162
|
+
<div>
|
|
163
|
+
isDirty: {{isDirty}}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<button @click="reset">Reset</button>
|
|
167
|
+
|
|
168
|
+
<div>
|
|
169
|
+
Add new user:
|
|
170
|
+
<input v-model="newUserName" type="text">
|
|
171
|
+
<button @click="add({name: newUserName})">➕ Add user</button>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<ul>
|
|
175
|
+
<template v-for="(item, index) in items">
|
|
176
|
+
<li v-if="!item.isRemoved">
|
|
177
|
+
<input v-model="item.instance.data.value.name" type="text">
|
|
178
|
+
<button @click="remove(index)">♻️ Rollback</button>
|
|
179
|
+
<button @click="remove(index)">🗑 Remove</button>
|
|
180
|
+
</li>
|
|
181
|
+
</template>
|
|
182
|
+
</ul>
|
|
183
|
+
|
|
184
|
+
Removed items:
|
|
185
|
+
<ul>
|
|
186
|
+
<li v-for="item in items.filter()">
|
|
187
|
+
{{item.instance.data.name}}
|
|
188
|
+
<button @click="item.isRemoved = false">♻️ Rollback</button>
|
|
189
|
+
</li>
|
|
190
|
+
</ul>
|
|
191
|
+
</template>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
# Documentation
|
|
195
|
+
## TrackedInstance
|
|
196
|
+
- **data** - tracked data
|
|
197
|
+
- **changeData** - includes only modified fields from data, considers nested objects and arrays
|
|
198
|
+
- **isDirty** - weather instance has some changes
|
|
199
|
+
- **loadData** - rewrite data and clear dirty state
|
|
200
|
+
- **reset** - rollback changes at the last point when the instance was not isDirty
|
|
201
|
+
|
|
202
|
+
## Collection
|
|
203
|
+
- **items** - array of `CollectionItem`
|
|
204
|
+
- **isDirty** - weather collection includes some changes (add/remove/change)
|
|
205
|
+
- **add** - add new item
|
|
206
|
+
- **remove** - soft remove item by index. Soft removed items should be deleted permanently after load data. Can be reverted by reset. If passed second param isHardRemove can be deleted permanently.
|
|
207
|
+
- **loadData** - accepts array of data for each item. Rewrite each instance data and clear dirty state
|
|
208
|
+
- **reset** - rollback changes at the last point when the instance was not isDirty
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
interface CollectionItem {
|
|
212
|
+
instance: TrackedInstance
|
|
213
|
+
isRemoved: Ref<boolean>
|
|
214
|
+
isNew: Ref<boolean> //weather is new instance. Field can be changed manually or changed in loadData in second argument
|
|
215
|
+
meta: Record<string, any>
|
|
216
|
+
}
|
|
217
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0.0",
|
|
3
|
+
"name": "tracked-instance",
|
|
4
|
+
"description": "Build large forms and track all changes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"test": "vitest run"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@types/lodash-es": "^4.17.12",
|
|
14
|
+
"typescript": "^5.2.2",
|
|
15
|
+
"vitest": "^1.3.0",
|
|
16
|
+
"vue": "^3.4.19"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"vue": "^3.0.0"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"lodash-es": "^4.17.21"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/rudnik275/tracked-instance"
|
|
27
|
+
},
|
|
28
|
+
"author": "Dmytro Rudnyk",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"vue",
|
|
32
|
+
"vue 3",
|
|
33
|
+
"vue3",
|
|
34
|
+
"vue next",
|
|
35
|
+
"tracked form",
|
|
36
|
+
"track",
|
|
37
|
+
"collection",
|
|
38
|
+
"useTrackedInstance",
|
|
39
|
+
"useCollection"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {computed, shallowRef, triggerRef, ShallowRef, ComputedRef, ref, Ref} from 'vue'
|
|
2
|
+
import {TrackedInstance, useTrackedInstance} from './tracked-instance'
|
|
3
|
+
|
|
4
|
+
export interface CollectionItem<Item extends Record<string, any>, Meta = Record<string, any>> {
|
|
5
|
+
instance: TrackedInstance<Item>
|
|
6
|
+
meta: Meta
|
|
7
|
+
isRemoved: Ref<boolean>
|
|
8
|
+
isNew: Ref<boolean>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Collection<Item extends Record<string, any>, Meta = Record<string, any>> {
|
|
12
|
+
items: ShallowRef<CollectionItem<Item, Meta>[]>
|
|
13
|
+
isDirty: ComputedRef<boolean>
|
|
14
|
+
add: (item: Partial<Item>, afterIndex?: number) => CollectionItem<Item, Meta>
|
|
15
|
+
remove: (index: number, isHardRemove?: boolean) => void
|
|
16
|
+
loadData: (items: Item[]) => void
|
|
17
|
+
reset: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const useCollection = <Item extends Record<string, any>, Meta = Record<string, any>>(
|
|
21
|
+
createItemMeta: (instance: TrackedInstance<Item>) => Meta = () => ({}) as Meta
|
|
22
|
+
): Collection<Item, Meta> => {
|
|
23
|
+
const items = shallowRef<CollectionItem<Item, Meta>[]>([])
|
|
24
|
+
|
|
25
|
+
const isDirty = computed(() =>
|
|
26
|
+
items.value.some(({instance, isRemoved, isNew}) => instance.isDirty.value || isNew.value || isRemoved.value)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const add = (item: Partial<Item>, index: number = items.value.length) => {
|
|
30
|
+
const instance = useTrackedInstance<Item>(item)
|
|
31
|
+
const newItem = {
|
|
32
|
+
isRemoved: ref(false),
|
|
33
|
+
isNew: ref(true),
|
|
34
|
+
instance,
|
|
35
|
+
meta: createItemMeta(instance)
|
|
36
|
+
} as CollectionItem<Item, Meta>
|
|
37
|
+
items.value.splice(index, 0, newItem)
|
|
38
|
+
triggerRef(items)
|
|
39
|
+
return newItem
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const remove = (index: number, isHardRemove = false) => {
|
|
43
|
+
const item = items.value[index]
|
|
44
|
+
if (item.isNew.value || isHardRemove) {
|
|
45
|
+
items.value.splice(index, 1)
|
|
46
|
+
triggerRef(items)
|
|
47
|
+
} else {
|
|
48
|
+
items.value[index].isRemoved.value = true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const loadData = (loadedItems: Item[]) => {
|
|
53
|
+
items.value = loadedItems.map((item) => {
|
|
54
|
+
const instance = useTrackedInstance<Item>(item)
|
|
55
|
+
return {
|
|
56
|
+
isNew: ref(false),
|
|
57
|
+
isRemoved: ref(false),
|
|
58
|
+
instance,
|
|
59
|
+
meta: createItemMeta(instance)
|
|
60
|
+
} as CollectionItem<Item, Meta>
|
|
61
|
+
})
|
|
62
|
+
triggerRef(items)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const reset = () => {
|
|
66
|
+
items.value = items.value.filter(({isNew}) => !isNew.value)
|
|
67
|
+
for (const item of items.value) {
|
|
68
|
+
item.isRemoved.value = false
|
|
69
|
+
item.instance.reset()
|
|
70
|
+
}
|
|
71
|
+
triggerRef(items)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
items,
|
|
76
|
+
isDirty,
|
|
77
|
+
add,
|
|
78
|
+
remove,
|
|
79
|
+
loadData,
|
|
80
|
+
reset
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import {get, has, set, unset} from 'lodash-es'
|
|
2
|
+
import {computed, customRef, Ref} from 'vue'
|
|
3
|
+
|
|
4
|
+
type DeepPartial<T> = T extends object
|
|
5
|
+
? {
|
|
6
|
+
[P in keyof T]?: DeepPartial<T[P]>
|
|
7
|
+
}
|
|
8
|
+
: T
|
|
9
|
+
|
|
10
|
+
export interface TrackedInstance<Data extends Record<string, any>> {
|
|
11
|
+
data: Ref<Data>
|
|
12
|
+
isDirty: Ref<boolean>
|
|
13
|
+
changedData: Ref<DeepPartial<Data>>
|
|
14
|
+
loadData: (newData: DeepPartial<Data>) => void
|
|
15
|
+
reset: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface NestedProxyPathItem {
|
|
19
|
+
target: Record<string, any>
|
|
20
|
+
property: string
|
|
21
|
+
receiver?: Record<string, any>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isObject = (value: unknown) =>
|
|
25
|
+
typeof value === 'object' &&
|
|
26
|
+
value !== null &&
|
|
27
|
+
!Array.isArray(value) &&
|
|
28
|
+
!(value instanceof Date) &&
|
|
29
|
+
!(value instanceof File) &&
|
|
30
|
+
!(value instanceof Map) &&
|
|
31
|
+
!(value instanceof Set)
|
|
32
|
+
|
|
33
|
+
const isEmpty = (value: object) => Object.keys(value).length === 0
|
|
34
|
+
|
|
35
|
+
const iterateObject = function* (
|
|
36
|
+
source: Record<string, any>,
|
|
37
|
+
params: {
|
|
38
|
+
// define condition when need to go deep
|
|
39
|
+
goDeepCondition?: (path: string[], value: any) => boolean
|
|
40
|
+
// include parent into separate step when we go deep
|
|
41
|
+
includeParent?: boolean
|
|
42
|
+
} = {}
|
|
43
|
+
) {
|
|
44
|
+
const {goDeepCondition = (_, value) => isObject(value), includeParent = false} = params
|
|
45
|
+
const iterateObjectDeep = function* (path: string[], obj: Record<string, any>): Generator<[string[], any]> {
|
|
46
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
47
|
+
const currentPath = path.concat(key)
|
|
48
|
+
if (goDeepCondition(currentPath, value)) {
|
|
49
|
+
if (includeParent) {
|
|
50
|
+
yield [currentPath, value]
|
|
51
|
+
}
|
|
52
|
+
yield* iterateObjectDeep(currentPath, value)
|
|
53
|
+
} else {
|
|
54
|
+
yield [currentPath, value]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
yield* iterateObjectDeep([], source)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const createNestedRef = <Source extends Record<string, any>>(
|
|
63
|
+
source: Source,
|
|
64
|
+
handler: (path: NestedProxyPathItem[]) => ProxyHandler<Source>
|
|
65
|
+
) =>
|
|
66
|
+
customRef<Source>((track, trigger) => {
|
|
67
|
+
// make nested objects and arrays is reactive
|
|
68
|
+
const createProxy = <InnerSource extends Record<string, any>>(
|
|
69
|
+
source: InnerSource,
|
|
70
|
+
path: NestedProxyPathItem[] = []
|
|
71
|
+
): InnerSource => {
|
|
72
|
+
const currentProxyHandler = handler(path) as unknown as ProxyHandler<InnerSource>
|
|
73
|
+
return new Proxy(source, {
|
|
74
|
+
...currentProxyHandler,
|
|
75
|
+
get(target, property: string, receiver) {
|
|
76
|
+
track()
|
|
77
|
+
const result = currentProxyHandler.get
|
|
78
|
+
? currentProxyHandler.get(target, property, receiver)
|
|
79
|
+
: Reflect.get(target, property, receiver)
|
|
80
|
+
|
|
81
|
+
if (isObject(result) || Array.isArray(result)) {
|
|
82
|
+
return createProxy(result, path.concat({target, property, receiver}))
|
|
83
|
+
}
|
|
84
|
+
return result
|
|
85
|
+
},
|
|
86
|
+
set(target, property, value, receiver) {
|
|
87
|
+
const result = currentProxyHandler.set
|
|
88
|
+
? currentProxyHandler.set(target, property, value, receiver)
|
|
89
|
+
: Reflect.set(target, property, value, receiver)
|
|
90
|
+
trigger()
|
|
91
|
+
return result
|
|
92
|
+
},
|
|
93
|
+
deleteProperty(target, property) {
|
|
94
|
+
const result = currentProxyHandler.deleteProperty
|
|
95
|
+
? currentProxyHandler.deleteProperty(target, property)
|
|
96
|
+
: Reflect.deleteProperty(target, property)
|
|
97
|
+
trigger()
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
100
|
+
} as ProxyHandler<InnerSource>)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let value = createProxy(source)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
get() {
|
|
107
|
+
track()
|
|
108
|
+
return value
|
|
109
|
+
},
|
|
110
|
+
set(newValue: Source) {
|
|
111
|
+
value = createProxy(newValue)
|
|
112
|
+
trigger()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// array values in originalData should store in default object to avoid removing items on change length
|
|
118
|
+
class ArrayInOriginalData {
|
|
119
|
+
length: number
|
|
120
|
+
|
|
121
|
+
constructor(length: number) {
|
|
122
|
+
this.length = length
|
|
123
|
+
// length should not include in iterations
|
|
124
|
+
Object.defineProperty(this, 'length', {
|
|
125
|
+
enumerable: false,
|
|
126
|
+
value: length
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const setOriginalDataValue = (originalData: Record<string, any>, path: Omit<NestedProxyPathItem, 'receiver'>[]) => {
|
|
132
|
+
let originalDataTarget = originalData
|
|
133
|
+
for (const {target: oldValueParent, property} of path.slice(0, -1)) {
|
|
134
|
+
if (property in originalDataTarget) {
|
|
135
|
+
if (isObject(originalDataTarget[property]) || originalDataTarget[property] instanceof ArrayInOriginalData) {
|
|
136
|
+
originalDataTarget = originalDataTarget[property]
|
|
137
|
+
} else {
|
|
138
|
+
// cancel set originalData value because in this case we try to replace primitive value by object or array value
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
if (Array.isArray(oldValueParent[property])) {
|
|
143
|
+
originalDataTarget = originalDataTarget[property] = new ArrayInOriginalData(oldValueParent[property].length)
|
|
144
|
+
} else if (isObject(oldValueParent[property])) {
|
|
145
|
+
originalDataTarget = originalDataTarget[property] = {}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const lastItem = path.at(-1)!
|
|
151
|
+
originalDataTarget[lastItem.property] = lastItem.target[lastItem.property]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const snapshotValueToOriginalData = (
|
|
155
|
+
originalData: Record<string, any>,
|
|
156
|
+
path: Omit<NestedProxyPathItem, 'receiver'>[],
|
|
157
|
+
value: any
|
|
158
|
+
) => {
|
|
159
|
+
const pathAsString = path.map((i) => i.property)
|
|
160
|
+
const valueInOriginalData = get(originalData, pathAsString)
|
|
161
|
+
|
|
162
|
+
const markRemovedFieldsAsUndefined = (valueInOriginalData?: Record<string, any>, oldValue?: Record<string, any>) => {
|
|
163
|
+
const keysSet = new Set<string>()
|
|
164
|
+
if (valueInOriginalData) {
|
|
165
|
+
for (const key of Object.keys(valueInOriginalData)) {
|
|
166
|
+
keysSet.add(key)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (oldValue) {
|
|
170
|
+
for (const key of Object.keys(oldValue)) {
|
|
171
|
+
keysSet.add(key)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const keys = Array.from(keysSet).filter((key) => !Object.keys(value).includes(key))
|
|
175
|
+
for (const key of keys) {
|
|
176
|
+
snapshotValueToOriginalData(
|
|
177
|
+
originalData,
|
|
178
|
+
path.concat({target: oldValue || value, property: key} as Omit<NestedProxyPathItem, 'receiver'>),
|
|
179
|
+
undefined
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const lastPathItem = path.at(-1)!
|
|
185
|
+
const oldValue = lastPathItem.target[lastPathItem.property]
|
|
186
|
+
if (isObject(value) && (isObject(valueInOriginalData) || isObject(oldValue))) {
|
|
187
|
+
// if value includes in oldValue or originalData need mark removed fields as undefined and recursively run nested objects
|
|
188
|
+
markRemovedFieldsAsUndefined(valueInOriginalData, oldValue)
|
|
189
|
+
for (const key of Object.keys(value)) {
|
|
190
|
+
snapshotValueToOriginalData(originalData, path.concat({target: oldValue || value, property: key}), value[key])
|
|
191
|
+
}
|
|
192
|
+
} else if (Array.isArray(value) && (valueInOriginalData instanceof ArrayInOriginalData || Array.isArray(oldValue))) {
|
|
193
|
+
// do same for arrays
|
|
194
|
+
markRemovedFieldsAsUndefined(valueInOriginalData, oldValue)
|
|
195
|
+
for (const key of value.keys()) {
|
|
196
|
+
snapshotValueToOriginalData(
|
|
197
|
+
originalData,
|
|
198
|
+
path.concat({target: oldValue || value, property: key.toString()}),
|
|
199
|
+
value[key]
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
// in case value is plain then store it into originalData
|
|
204
|
+
if (!has(originalData, pathAsString)) {
|
|
205
|
+
if (oldValue !== value) {
|
|
206
|
+
setOriginalDataValue(originalData, path)
|
|
207
|
+
}
|
|
208
|
+
} else if (valueInOriginalData === value) {
|
|
209
|
+
unset(originalData, pathAsString)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const useTrackedInstance = <Data extends Record<string, any>>(
|
|
215
|
+
initialData: Partial<Data>
|
|
216
|
+
): TrackedInstance<Data> => {
|
|
217
|
+
type InternalData = {root: Data}
|
|
218
|
+
const _originalData = createNestedRef<DeepPartial<InternalData>>({}, (path) => ({
|
|
219
|
+
deleteProperty(target, property) {
|
|
220
|
+
const result = Reflect.deleteProperty(target, property)
|
|
221
|
+
if (path.length) {
|
|
222
|
+
const parent = path.at(-1)!
|
|
223
|
+
if (isEmpty(target)) {
|
|
224
|
+
delete parent.receiver![parent.property]
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return result
|
|
228
|
+
}
|
|
229
|
+
}))
|
|
230
|
+
|
|
231
|
+
const _data = createNestedRef<InternalData>({root: initialData} as InternalData, (parentThree) => ({
|
|
232
|
+
set(target, property: string, value, receiver) {
|
|
233
|
+
const path = parentThree.concat({target, property, receiver})
|
|
234
|
+
const oldValue = target[property as keyof typeof target]
|
|
235
|
+
|
|
236
|
+
const triggerChangingArrayItems = () => {
|
|
237
|
+
// in case length in array has changed then emit changing of value by index
|
|
238
|
+
const originalDataValue = get(
|
|
239
|
+
_originalData.value,
|
|
240
|
+
path.map((i) => i.property)
|
|
241
|
+
) as ArrayInOriginalData | undefined
|
|
242
|
+
|
|
243
|
+
const {length: originalDataLength} = originalDataValue || oldValue
|
|
244
|
+
|
|
245
|
+
if (value < originalDataLength) {
|
|
246
|
+
// when removed new value
|
|
247
|
+
for (let i = value; i < originalDataLength; i++) {
|
|
248
|
+
delete receiver[i]
|
|
249
|
+
}
|
|
250
|
+
} else if (originalDataLength < value) {
|
|
251
|
+
// store all removed values as "undefined" when this array values was in data before do some change
|
|
252
|
+
for (let i = originalDataLength; i < value; i++) {
|
|
253
|
+
receiver[i] = undefined
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (Array.isArray(target) && property === 'length') {
|
|
259
|
+
if (value !== oldValue) {
|
|
260
|
+
triggerChangingArrayItems()
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
snapshotValueToOriginalData(_originalData.value, path, value)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return Reflect.set(target, property, value, receiver)
|
|
267
|
+
},
|
|
268
|
+
deleteProperty(target, property: keyof typeof target) {
|
|
269
|
+
setOriginalDataValue(_originalData.value, parentThree.concat({target, property} as NestedProxyPathItem))
|
|
270
|
+
return Reflect.deleteProperty(target, property)
|
|
271
|
+
}
|
|
272
|
+
}))
|
|
273
|
+
|
|
274
|
+
const data = computed<Data>({
|
|
275
|
+
get: () => _data.value.root,
|
|
276
|
+
set: (value) => (_data.value.root = value)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const isDirty = computed<boolean>(() => Object.keys(_originalData.value).length > 0)
|
|
280
|
+
|
|
281
|
+
const _changedData = computed<DeepPartial<InternalData>>(() => {
|
|
282
|
+
const changedData = {} as DeepPartial<InternalData>
|
|
283
|
+
const originalDataIterator = iterateObject(_originalData.value, {
|
|
284
|
+
goDeepCondition: (path, value) => {
|
|
285
|
+
/*
|
|
286
|
+
* iterate over originalData
|
|
287
|
+
* but avoid going deep in case
|
|
288
|
+
* when value in data have different data type
|
|
289
|
+
* of same value in originalData
|
|
290
|
+
*/
|
|
291
|
+
const valueInData = get(_data.value, path)
|
|
292
|
+
const isBothValuesAsArray = value instanceof ArrayInOriginalData && Array.isArray(valueInData)
|
|
293
|
+
const isBothValuesAsObject = isObject(value) && isObject(valueInData)
|
|
294
|
+
return isBothValuesAsObject || isBothValuesAsArray
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
for (const [path] of originalDataIterator) {
|
|
298
|
+
const valueInData = get(_data.value, path)
|
|
299
|
+
set(changedData, path, valueInData)
|
|
300
|
+
}
|
|
301
|
+
return changedData
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
const changedData = computed(() => _changedData.value.root as DeepPartial<Data>)
|
|
305
|
+
|
|
306
|
+
const loadData = (newData: DeepPartial<Data>) => {
|
|
307
|
+
_data.value = {root: newData} as InternalData
|
|
308
|
+
_originalData.value = {}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const reset = () => {
|
|
312
|
+
const updatedData = JSON.parse(JSON.stringify(_data.value))
|
|
313
|
+
|
|
314
|
+
// iterate over originalData including objects to check array values
|
|
315
|
+
for (const [path, value] of iterateObject(_originalData.value, {includeParent: true})) {
|
|
316
|
+
if (value instanceof ArrayInOriginalData) {
|
|
317
|
+
// reset array length in data to remove new items
|
|
318
|
+
set(updatedData, path.concat('length'), value.length)
|
|
319
|
+
} else if (!isObject(value)) {
|
|
320
|
+
if (value === undefined) {
|
|
321
|
+
unset(updatedData, path)
|
|
322
|
+
} else {
|
|
323
|
+
set(updatedData, path, value)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
_data.value = updatedData
|
|
329
|
+
_originalData.value = {}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
data,
|
|
334
|
+
changedData,
|
|
335
|
+
isDirty,
|
|
336
|
+
loadData,
|
|
337
|
+
reset
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {useCollection} from '../src'
|
|
2
|
+
import {describe, expect, it} from 'vitest'
|
|
3
|
+
|
|
4
|
+
interface Person {
|
|
5
|
+
name: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('Collection', () => {
|
|
9
|
+
describe('Create', () => {
|
|
10
|
+
it('Should add new item at index in data', () => {
|
|
11
|
+
const collection = useCollection<Person>()
|
|
12
|
+
collection.loadData([{name: 'admin'}, {name: 'user'}])
|
|
13
|
+
collection.add({name: 'new user'}, 1)
|
|
14
|
+
expect(collection.items.value.map((item) => item.instance.data.value)).to.deep.equal([
|
|
15
|
+
{name: 'admin'},
|
|
16
|
+
{name: 'new user'},
|
|
17
|
+
{name: 'user'}
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('isDirty', () => {
|
|
23
|
+
it('Should make collection dirty when some item is "isDirty"', () => {
|
|
24
|
+
const collection = useCollection<Person>()
|
|
25
|
+
collection.loadData([{name: 'admin'}, {name: 'user'}])
|
|
26
|
+
collection.items.value[0].instance.data.value.name = 'changed name'
|
|
27
|
+
expect(collection.isDirty.value).equal(true)
|
|
28
|
+
})
|
|
29
|
+
it('Should make collection dirty when some item is removed', () => {
|
|
30
|
+
const collection = useCollection<Person>()
|
|
31
|
+
collection.loadData([{name: 'admin'}, {name: 'user'}])
|
|
32
|
+
collection.remove(0)
|
|
33
|
+
expect(collection.isDirty.value).equal(true)
|
|
34
|
+
})
|
|
35
|
+
it('Should make collection dirty when some item is "isNew"', () => {
|
|
36
|
+
const collection = useCollection<Person>()
|
|
37
|
+
collection.loadData([{name: 'admin'}, {name: 'user'}])
|
|
38
|
+
collection.add({name: 'new user'})
|
|
39
|
+
expect(collection.isDirty.value).equal(true)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('reset', () => {
|
|
44
|
+
const collection = useCollection<Person>()
|
|
45
|
+
collection.loadData([{name: 'admin'}, {name: 'user'}])
|
|
46
|
+
collection.items.value[0].instance.data.value.name = 'admin2'
|
|
47
|
+
collection.add({name: 'new user'})
|
|
48
|
+
collection.remove(0)
|
|
49
|
+
collection.reset()
|
|
50
|
+
|
|
51
|
+
it(`Collection shouldn't dirty`, () => {
|
|
52
|
+
expect(collection.isDirty.value).equal(false)
|
|
53
|
+
})
|
|
54
|
+
it('Should clean deleted items', () => {
|
|
55
|
+
expect(collection.items.value.some((item) => item.isRemoved.value)).equal(false)
|
|
56
|
+
})
|
|
57
|
+
it('Should reset each item in data', () => {
|
|
58
|
+
expect(collection.items.value.some((item) => item.instance.isDirty.value)).equal(false)
|
|
59
|
+
})
|
|
60
|
+
it('should revert deleted items', async () => {
|
|
61
|
+
expect(collection.items.value.map((item) => item.instance.data.value)).deep.eq([{name: 'admin'}, {name: 'user'}])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('delete', () => {
|
|
66
|
+
const collection = useCollection<Person>()
|
|
67
|
+
collection.loadData([{name: 'admin'}, {name: 'user'}])
|
|
68
|
+
collection.add({name: 'new user'})
|
|
69
|
+
collection.remove(2)
|
|
70
|
+
collection.remove(1)
|
|
71
|
+
|
|
72
|
+
it('Should delete new items when removing it', () => {
|
|
73
|
+
expect(collection.items.value[0].instance.data.value.name).equal('admin')
|
|
74
|
+
expect(collection.items.value[1].instance.data.value.name).equal('user')
|
|
75
|
+
expect(collection.items.value[1].isRemoved.value).equal(true)
|
|
76
|
+
expect(collection.items.value[2]).undefined
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('loadData', () => {
|
|
81
|
+
const collection = useCollection<Person>()
|
|
82
|
+
collection.loadData([{name: 'admin'}, {name: 'user'}])
|
|
83
|
+
it('Should load correct data', () => {
|
|
84
|
+
expect(collection.items.value.map((item) => item.instance.data.value)).to.deep.equal([
|
|
85
|
+
{name: 'admin'},
|
|
86
|
+
{name: 'user'}
|
|
87
|
+
])
|
|
88
|
+
})
|
|
89
|
+
it(`Loaded items shouldn't equals "isNew"`, () => {
|
|
90
|
+
expect(collection.items.value.some((item) => item.instance.isDirty.value)).equal(false)
|
|
91
|
+
})
|
|
92
|
+
it('Should clean revert removed items', () => {
|
|
93
|
+
collection.remove(1)
|
|
94
|
+
collection.remove(0)
|
|
95
|
+
collection.loadData([{name: 'admin'}, {name: 'user'}])
|
|
96
|
+
expect(collection.items.value.some((item) => item.isRemoved.value)).equal(false)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import {useTrackedInstance} from '../src'
|
|
2
|
+
import {describe, expect, it} from 'vitest'
|
|
3
|
+
|
|
4
|
+
describe('useTrackedInstance', async () => {
|
|
5
|
+
it('should change data', async () => {
|
|
6
|
+
const instance = useTrackedInstance({
|
|
7
|
+
name: 'John',
|
|
8
|
+
age: 22
|
|
9
|
+
})
|
|
10
|
+
expect(instance.isDirty.value).eq(false)
|
|
11
|
+
expect(instance.changedData.value).undefined
|
|
12
|
+
|
|
13
|
+
instance.data.value.name = 'test'
|
|
14
|
+
|
|
15
|
+
expect(instance.isDirty.value).eq(true)
|
|
16
|
+
expect(instance.changedData.value).deep.eq({
|
|
17
|
+
name: 'test'
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
instance.data.value.name = 'John'
|
|
21
|
+
expect(instance.isDirty.value).eq(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should accept primitive value as root', async () => {
|
|
25
|
+
const instance = useTrackedInstance('John')
|
|
26
|
+
instance.data.value = 'Jane'
|
|
27
|
+
|
|
28
|
+
expect(instance.isDirty.value).eq(true)
|
|
29
|
+
|
|
30
|
+
instance.reset()
|
|
31
|
+
|
|
32
|
+
expect(instance.isDirty.value).eq(false)
|
|
33
|
+
expect(instance.data.value).eq('John')
|
|
34
|
+
|
|
35
|
+
instance.data.value = 'Tom'
|
|
36
|
+
instance.loadData('Jack')
|
|
37
|
+
|
|
38
|
+
expect(instance.isDirty.value).eq(false)
|
|
39
|
+
expect(instance.data.value).eq('Jack')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should change array of string', async () => {
|
|
43
|
+
const instance = useTrackedInstance({
|
|
44
|
+
name: 'John',
|
|
45
|
+
hobbies: ['drift']
|
|
46
|
+
})
|
|
47
|
+
instance.data.value.hobbies.push('films')
|
|
48
|
+
|
|
49
|
+
expect(instance.isDirty.value).eq(true)
|
|
50
|
+
expect(instance.changedData.value).deep.eq({
|
|
51
|
+
hobbies: [undefined, 'films']
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should change nested value in object', async () => {
|
|
56
|
+
const instance = useTrackedInstance({
|
|
57
|
+
name: 'John',
|
|
58
|
+
info: {
|
|
59
|
+
contact: {
|
|
60
|
+
phone: '1234567',
|
|
61
|
+
address: 'Earth'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
instance.data.value.info.contact.phone = 'none'
|
|
67
|
+
expect(instance.changedData.value).deep.eq({
|
|
68
|
+
info: {
|
|
69
|
+
contact: {
|
|
70
|
+
phone: 'none'
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should clean "changedData" after "loadData" ', async () => {
|
|
77
|
+
const instance = useTrackedInstance({
|
|
78
|
+
name: 'John',
|
|
79
|
+
age: 22
|
|
80
|
+
})
|
|
81
|
+
instance.data.value.name = 'none'
|
|
82
|
+
instance.data.value.age = 0
|
|
83
|
+
instance.loadData({
|
|
84
|
+
name: 'Test',
|
|
85
|
+
age: 100
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
expect(instance.data.value).deep.eq({
|
|
89
|
+
name: 'Test',
|
|
90
|
+
age: 100
|
|
91
|
+
})
|
|
92
|
+
expect(instance.isDirty.value).eq(false)
|
|
93
|
+
expect(instance.changedData.value).undefined
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should reset data after do some change', async () => {
|
|
97
|
+
const instance = useTrackedInstance({
|
|
98
|
+
name: 'John',
|
|
99
|
+
info: {
|
|
100
|
+
contact: {
|
|
101
|
+
phone: '1234567',
|
|
102
|
+
address: 'Earth'
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
hobbies: ['drift', 'films']
|
|
106
|
+
})
|
|
107
|
+
instance.data.value.name = 'changed'
|
|
108
|
+
instance.data.value.info.contact.phone = 'none'
|
|
109
|
+
instance.data.value.hobbies.splice(0, 1, 'test', 'test2')
|
|
110
|
+
instance.reset()
|
|
111
|
+
|
|
112
|
+
expect(instance.data.value).deep.eq({
|
|
113
|
+
name: 'John',
|
|
114
|
+
info: {
|
|
115
|
+
contact: {
|
|
116
|
+
phone: '1234567',
|
|
117
|
+
address: 'Earth'
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
hobbies: ['drift', 'films']
|
|
121
|
+
})
|
|
122
|
+
expect(instance.isDirty.value).eq(false)
|
|
123
|
+
expect(instance.changedData.value).undefined
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should display correct changedData after replace some value as object', async () => {
|
|
127
|
+
const instance = useTrackedInstance<{
|
|
128
|
+
contact: null | {
|
|
129
|
+
phone: string
|
|
130
|
+
galaxy: string
|
|
131
|
+
address?: string
|
|
132
|
+
}
|
|
133
|
+
user: null | {
|
|
134
|
+
name: string
|
|
135
|
+
}
|
|
136
|
+
}>({
|
|
137
|
+
contact: {
|
|
138
|
+
phone: '123',
|
|
139
|
+
galaxy: 'Milky way',
|
|
140
|
+
address: 'Earth'
|
|
141
|
+
},
|
|
142
|
+
user: null
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
instance.data.value.contact = null
|
|
146
|
+
|
|
147
|
+
instance.data.value.contact = {
|
|
148
|
+
phone: '1',
|
|
149
|
+
galaxy: 'Milky way'
|
|
150
|
+
}
|
|
151
|
+
expect(instance.changedData.value).deep.eq({
|
|
152
|
+
contact: {
|
|
153
|
+
phone: '1',
|
|
154
|
+
address: undefined
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
instance.data.value.contact = {
|
|
159
|
+
phone: '123',
|
|
160
|
+
galaxy: 'Milky way',
|
|
161
|
+
address: 'Earth'
|
|
162
|
+
}
|
|
163
|
+
expect(instance.isDirty.value).eq(false)
|
|
164
|
+
|
|
165
|
+
instance.data.value.user = {
|
|
166
|
+
name: 'Jack'
|
|
167
|
+
}
|
|
168
|
+
instance.data.value.user.name = 'John'
|
|
169
|
+
expect(instance.changedData.value).deep.eq({
|
|
170
|
+
user: {
|
|
171
|
+
name: 'John'
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('should make whole object prop undefined', async () => {
|
|
177
|
+
const instance = useTrackedInstance<{
|
|
178
|
+
name?: string
|
|
179
|
+
info?: Record<string, string>
|
|
180
|
+
}>({
|
|
181
|
+
name: 'John',
|
|
182
|
+
info: {
|
|
183
|
+
phone: '1234567',
|
|
184
|
+
address: 'Earth'
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
instance.data.value.name = undefined
|
|
188
|
+
instance.data.value.info = undefined
|
|
189
|
+
|
|
190
|
+
expect(instance.changedData.value).deep.eq({
|
|
191
|
+
name: undefined,
|
|
192
|
+
info: undefined
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('should replace primitive value as new object', async () => {
|
|
197
|
+
const instance = useTrackedInstance<{
|
|
198
|
+
user: string | {name: string}
|
|
199
|
+
}>({
|
|
200
|
+
user: 'John'
|
|
201
|
+
})
|
|
202
|
+
instance.data.value.user = 'Jack'
|
|
203
|
+
|
|
204
|
+
expect(instance.changedData.value).deep.eq({
|
|
205
|
+
user: 'Jack'
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
instance.data.value.user = {
|
|
209
|
+
name: 'Peter'
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
expect(instance.changedData.value).deep.eq({
|
|
213
|
+
user: {name: 'Peter'}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
instance.reset()
|
|
217
|
+
expect(instance.data.value).deep.eq({
|
|
218
|
+
user: 'John'
|
|
219
|
+
})
|
|
220
|
+
expect(instance.isDirty.value).eq(false)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('should replace object value as new object', async () => {
|
|
224
|
+
const instance = useTrackedInstance<{
|
|
225
|
+
info: {
|
|
226
|
+
address: string
|
|
227
|
+
phone?: string
|
|
228
|
+
passport: {
|
|
229
|
+
id: number
|
|
230
|
+
country?: string
|
|
231
|
+
year?: number
|
|
232
|
+
owner?: string
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}>({
|
|
236
|
+
info: {
|
|
237
|
+
address: 'Earth',
|
|
238
|
+
phone: '1234567',
|
|
239
|
+
passport: {
|
|
240
|
+
id: 1,
|
|
241
|
+
year: 2000
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
instance.data.value.info.address = 'Mars'
|
|
246
|
+
|
|
247
|
+
expect(instance.changedData.value).deep.eq({
|
|
248
|
+
info: {
|
|
249
|
+
address: 'Mars'
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
instance.data.value.info = {
|
|
254
|
+
address: 'Earth',
|
|
255
|
+
passport: {
|
|
256
|
+
id: 2,
|
|
257
|
+
country: 'Ukraine',
|
|
258
|
+
owner: 'Jack'
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
instance.data.value.info = {
|
|
263
|
+
address: 'Earth',
|
|
264
|
+
passport: {
|
|
265
|
+
id: 2,
|
|
266
|
+
country: 'Ukraine'
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
expect(instance.changedData.value).deep.eq({
|
|
271
|
+
info: {
|
|
272
|
+
phone: undefined,
|
|
273
|
+
passport: {
|
|
274
|
+
id: 2,
|
|
275
|
+
year: undefined,
|
|
276
|
+
country: 'Ukraine'
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
instance.reset()
|
|
282
|
+
|
|
283
|
+
expect(instance.changedData.value).undefined
|
|
284
|
+
expect(instance.data.value).deep.eq({
|
|
285
|
+
info: {
|
|
286
|
+
address: 'Earth',
|
|
287
|
+
phone: '1234567',
|
|
288
|
+
passport: {
|
|
289
|
+
id: 1,
|
|
290
|
+
year: 2000
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('should display correct changedData when change nested value in array of objects', async () => {
|
|
297
|
+
const instance = useTrackedInstance<{id: number; name: string}[]>([
|
|
298
|
+
{
|
|
299
|
+
id: 1,
|
|
300
|
+
name: 'John'
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: 2,
|
|
304
|
+
name: 'Jack'
|
|
305
|
+
}
|
|
306
|
+
])
|
|
307
|
+
|
|
308
|
+
instance.data.value[1].name = 'Joe'
|
|
309
|
+
const expectedData = []
|
|
310
|
+
expectedData[1] = {name: 'Joe'}
|
|
311
|
+
expect(instance.changedData.value).deep.eq(expectedData)
|
|
312
|
+
})
|
|
313
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"allowSyntheticDefaultImports": true,
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"outDir": "./dist",
|
|
12
|
+
"lib": ["ESNext", "DOM"],
|
|
13
|
+
"useDefineForClassFields": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"isolatedModules": true,
|
|
17
|
+
|
|
18
|
+
/* Linting */
|
|
19
|
+
"noUnusedLocals": true,
|
|
20
|
+
"noUnusedParameters": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true
|
|
22
|
+
},
|
|
23
|
+
"include": ["src"],
|
|
24
|
+
}
|