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.
@@ -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,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="TypeScriptCompiler">
4
+ <option name="nodeInterpreterTextField" value="$USER_HOME$/.nvm/versions/node/v20.11.0/bin/node" />
5
+ </component>
6
+ </project>
@@ -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
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="$PROJECT_DIR$" vcs="Git" />
5
+ </component>
6
+ </project>
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,5 @@
1
+ export type {TrackedInstance} from './tracked-instance'
2
+ export type {Collection, CollectionItem} from './collection'
3
+
4
+ export {useTrackedInstance} from './tracked-instance'
5
+ export {useCollection} from './collection'
@@ -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
+ }