teamplay 0.1.4 → 0.1.6
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/README.md +37 -17
- package/index.js +2 -3
- package/orm/Cache.js +4 -0
- package/orm/Doc.js +1 -0
- package/orm/Query.js +1 -0
- package/orm/Reaction.js +1 -0
- package/orm/Value.js +1 -0
- package/package.json +12 -6
- package/schema.js +1 -0
- package/server.js +9 -2
- package/utils/MockFinalizationRegistry.js +66 -0
- package/utils/MockWeakRef.js +16 -0
- package/CHANGELOG.md +0 -45
- package/schema/GUID_PATTERN.js +0 -1
- package/schema/associations.js +0 -51
- package/schema/pickFormFields.js +0 -104
package/README.md
CHANGED
|
@@ -30,14 +30,37 @@ import connect from 'teamplay/connect'
|
|
|
30
30
|
connect()
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
On the server you need to create the teamplay's backend and then create a connection handler for WebSockets:
|
|
34
34
|
|
|
35
35
|
```js
|
|
36
|
-
import { initConnection } from 'teamplay/server'
|
|
37
|
-
const
|
|
36
|
+
import { createBackend, initConnection } from 'teamplay/server'
|
|
37
|
+
const backend = createBackend()
|
|
38
|
+
const { upgrade } = initConnection(backend)
|
|
38
39
|
server.on('upgrade', upgrade) // Node's 'http' server instance
|
|
39
40
|
```
|
|
40
41
|
|
|
42
|
+
- for production use it's recommended to use MongoDB. It's gonna be automatically used if you set the env var `MONGO_URL`
|
|
43
|
+
- when deploying to a cluster with multiple instances you also have to set the env var `REDIS_URL` (Redis)
|
|
44
|
+
|
|
45
|
+
Without setting `MONGO_URL` the alternative `mingo` mock is used instead which persists data into an SQLite file `local.db` in the root of your project.
|
|
46
|
+
|
|
47
|
+
> [!NOTE]
|
|
48
|
+
> teamplay's `createBackend()` is a wrapper around creating a [ShareDB's backend](https://share.github.io/sharedb/api/backend).
|
|
49
|
+
> You can instead manually create a ShareDB backend yourself and pass it to `initConnection()`.
|
|
50
|
+
> `ShareDB` is re-exported from `teamplay/server`, you can get it as `import { ShareDB } from 'teamplay/server'`
|
|
51
|
+
|
|
52
|
+
## `initConnection(backend, options)`
|
|
53
|
+
|
|
54
|
+
**`backend`** - ShareDB backend instance
|
|
55
|
+
|
|
56
|
+
**`options`**:
|
|
57
|
+
|
|
58
|
+
### `fetchOnly` (default: `true`)
|
|
59
|
+
|
|
60
|
+
By default all subscriptions on the server are not reactive. This is strongly recommended.
|
|
61
|
+
|
|
62
|
+
If you need the subscriptions to reactively update data whenever it changes (the same way as they work on client-side), pass `{ fetchOnly: false }`.
|
|
63
|
+
|
|
41
64
|
## Usage
|
|
42
65
|
|
|
43
66
|
TBD
|
|
@@ -45,16 +68,17 @@ TBD
|
|
|
45
68
|
|
|
46
69
|
## Examples
|
|
47
70
|
|
|
71
|
+
For a simple working react app see [/example](/example)
|
|
72
|
+
|
|
48
73
|
### Simplest example with server synchronization
|
|
49
74
|
|
|
50
75
|
On the client we `connect()` to the server, and we have to wrap each React component into `observer()`:
|
|
51
76
|
|
|
52
77
|
```js
|
|
53
78
|
// client.js
|
|
79
|
+
import { createRoot } from 'react-dom/client'
|
|
54
80
|
import connect from 'teamplay/connect'
|
|
55
81
|
import { observer, $, sub } from 'teamplay'
|
|
56
|
-
import { createRoot } from 'react-dom/client'
|
|
57
|
-
import { createElement as el } from 'react'
|
|
58
82
|
|
|
59
83
|
connect()
|
|
60
84
|
|
|
@@ -62,14 +86,12 @@ const App = observer(({ userId }) => {
|
|
|
62
86
|
const $user = sub($.users[userId])
|
|
63
87
|
if (!$user.get()) throw $user.set({ points: 0 })
|
|
64
88
|
const { $points } = $user
|
|
65
|
-
const
|
|
66
|
-
return
|
|
89
|
+
const increment = () => $points.set($points.get() + 1)
|
|
90
|
+
return <button onClick={increment}>Points: {$points.get()}</button>
|
|
67
91
|
})
|
|
68
92
|
|
|
69
93
|
const container = document.body.appendChild(document.createElement('div'))
|
|
70
|
-
createRoot(container).render(
|
|
71
|
-
el(App, { userId: '_1' })
|
|
72
|
-
)
|
|
94
|
+
createRoot(container).render(<App userId='_1' />)
|
|
73
95
|
```
|
|
74
96
|
|
|
75
97
|
On the server we create the ShareDB backend and initialize the WebSocket connections handler:
|
|
@@ -77,21 +99,19 @@ On the server we create the ShareDB backend and initialize the WebSocket connect
|
|
|
77
99
|
```js
|
|
78
100
|
// server.js
|
|
79
101
|
import http from 'http'
|
|
80
|
-
import {
|
|
102
|
+
import { createBackend, initConnection } from 'teamplay/server'
|
|
81
103
|
|
|
82
104
|
const server = http.createServer() // you can pass expressApp here if needed
|
|
83
|
-
const backend =
|
|
105
|
+
const backend = createBackend()
|
|
84
106
|
const { upgrade } = initConnection(backend)
|
|
85
107
|
|
|
86
108
|
server.on('upgrade', upgrade)
|
|
87
109
|
|
|
88
|
-
server.listen(3000)
|
|
110
|
+
server.listen(3000, () => {
|
|
111
|
+
console.log('Server started. Open http://localhost:3000 in your browser')
|
|
112
|
+
})
|
|
89
113
|
```
|
|
90
114
|
|
|
91
|
-
ShareDB is a re-export of [`sharedb`](https://github.com/share/sharedb) library, check its docs for more info.
|
|
92
|
-
- for persistency and queries support pass [`sharedb-mongo`](https://github.com/share/sharedb-mongo) (which uses MongoDB) as `{ db }`
|
|
93
|
-
- when deploying to a cluster with multiple instances you also have to provide `{ pubsub }` like [`sharedb-redis-pubsub`](https://github.com/share/sharedb-redis-pubsub) (which uses Redis)
|
|
94
|
-
|
|
95
115
|
## License
|
|
96
116
|
|
|
97
117
|
MIT
|
package/index.js
CHANGED
|
@@ -16,9 +16,8 @@ export default $
|
|
|
16
16
|
export { default as sub } from './react/universalSub.js'
|
|
17
17
|
export { default as observer } from './react/observer.js'
|
|
18
18
|
export { connection, setConnection, getConnection, fetchOnly, setFetchOnly } from './orm/connection.js'
|
|
19
|
-
export
|
|
20
|
-
export {
|
|
21
|
-
export { default as pickFormFields } from './schema/pickFormFields.js'
|
|
19
|
+
export { GUID_PATTERN, hasMany, hasOne, hasManyFlags, belongsTo, pickFormFields } from '@teamplay/schema'
|
|
20
|
+
export { aggregation, aggregationHeader } from '@teamplay/utils/aggregation'
|
|
22
21
|
|
|
23
22
|
export function getRootSignal (options) {
|
|
24
23
|
return _getRootSignal({
|
package/orm/Cache.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
|
|
2
|
+
import WeakRef, { destroyMockWeakRef } from '../utils/MockWeakRef.js'
|
|
3
|
+
|
|
1
4
|
export default class Cache {
|
|
2
5
|
constructor () {
|
|
3
6
|
this.cache = new Map()
|
|
@@ -20,6 +23,7 @@ export default class Cache {
|
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
delete (key) {
|
|
26
|
+
destroyMockWeakRef(this.cache.get(key)) // TODO: remove this when WeakRef is available in RN
|
|
23
27
|
this.cache.delete(key)
|
|
24
28
|
}
|
|
25
29
|
|
package/orm/Doc.js
CHANGED
|
@@ -2,6 +2,7 @@ import { isObservable, observable } from '@nx-js/observer-util'
|
|
|
2
2
|
import { set as _set, del as _del } from './dataTree.js'
|
|
3
3
|
import { SEGMENTS } from './Signal.js'
|
|
4
4
|
import { getConnection, fetchOnly } from './connection.js'
|
|
5
|
+
import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
|
|
5
6
|
|
|
6
7
|
const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
|
|
7
8
|
|
package/orm/Query.js
CHANGED
|
@@ -4,6 +4,7 @@ import { SEGMENTS } from './Signal.js'
|
|
|
4
4
|
import getSignal from './getSignal.js'
|
|
5
5
|
import { getConnection, fetchOnly } from './connection.js'
|
|
6
6
|
import { docSubscriptions } from './Doc.js'
|
|
7
|
+
import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
|
|
7
8
|
|
|
8
9
|
const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
|
|
9
10
|
export const PARAMS = Symbol('query params')
|
package/orm/Reaction.js
CHANGED
|
@@ -2,6 +2,7 @@ import { observe, unobserve } from '@nx-js/observer-util'
|
|
|
2
2
|
import { SEGMENTS } from './Signal.js'
|
|
3
3
|
import { set as _set, del as _del } from './dataTree.js'
|
|
4
4
|
import { LOCAL } from './Value.js'
|
|
5
|
+
import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
|
|
5
6
|
|
|
6
7
|
// this is `let` to be able to directly change it if needed in tests or in the app
|
|
7
8
|
export let DELETION_DELAY = 0 // eslint-disable-line prefer-const
|
package/orm/Value.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "teamplay",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Full-stack signals ORM with multiplayer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"./connect": "./connect/index.js",
|
|
10
10
|
"./server": "./server.js",
|
|
11
11
|
"./connect-test": "./connect/test.js",
|
|
12
|
-
"./cache": "./cache.js"
|
|
12
|
+
"./cache": "./cache.js",
|
|
13
|
+
"./schema": "./schema.js"
|
|
13
14
|
},
|
|
14
15
|
"publishConfig": {
|
|
15
16
|
"access": "public"
|
|
@@ -22,9 +23,12 @@
|
|
|
22
23
|
},
|
|
23
24
|
"dependencies": {
|
|
24
25
|
"@nx-js/observer-util": "^4.1.3",
|
|
25
|
-
"@teamplay/
|
|
26
|
-
"@teamplay/
|
|
27
|
-
"@teamplay/
|
|
26
|
+
"@teamplay/backend": "^0.1.6",
|
|
27
|
+
"@teamplay/cache": "^0.1.6",
|
|
28
|
+
"@teamplay/channel": "^0.1.6",
|
|
29
|
+
"@teamplay/debug": "^0.1.6",
|
|
30
|
+
"@teamplay/schema": "^0.1.6",
|
|
31
|
+
"@teamplay/utils": "^0.1.6",
|
|
28
32
|
"diff-match-patch": "^1.0.5",
|
|
29
33
|
"events": "^3.3.0",
|
|
30
34
|
"json0-ot-diff": "^1.1.2",
|
|
@@ -37,6 +41,8 @@
|
|
|
37
41
|
"@testing-library/react": "^15.0.7",
|
|
38
42
|
"jest": "^29.7.0",
|
|
39
43
|
"jest-environment-jsdom": "^29.7.0",
|
|
44
|
+
"react": "^18.3.1",
|
|
45
|
+
"react-dom": "^18.3.1",
|
|
40
46
|
"sharedb-mingo-memory": "^3.0.1"
|
|
41
47
|
},
|
|
42
48
|
"peerDependencies": {
|
|
@@ -58,5 +64,5 @@
|
|
|
58
64
|
]
|
|
59
65
|
},
|
|
60
66
|
"license": "MIT",
|
|
61
|
-
"gitHead": "
|
|
67
|
+
"gitHead": "97787add00d5543476304ab577a3550da916debe"
|
|
62
68
|
}
|
package/schema.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@teamplay/schema'
|
package/server.js
CHANGED
|
@@ -2,11 +2,18 @@ import createChannel from '@teamplay/channel/server'
|
|
|
2
2
|
import { connection, setConnection, setFetchOnly } from './orm/connection.js'
|
|
3
3
|
|
|
4
4
|
export { default as ShareDB } from 'sharedb'
|
|
5
|
+
export {
|
|
6
|
+
default as createBackend,
|
|
7
|
+
mongo, mongoClient, createMongoIndex, redis, redlock, sqlite
|
|
8
|
+
} from '@teamplay/backend'
|
|
5
9
|
|
|
6
|
-
export
|
|
10
|
+
export function initConnection (backend, {
|
|
11
|
+
fetchOnly = true,
|
|
12
|
+
...options
|
|
13
|
+
} = {}) {
|
|
7
14
|
if (!backend) throw Error('backend is required')
|
|
8
15
|
if (connection) throw Error('Connection already exists')
|
|
9
16
|
setConnection(backend.connect())
|
|
10
|
-
setFetchOnly(
|
|
17
|
+
setFetchOnly(fetchOnly)
|
|
11
18
|
return createChannel(backend, options)
|
|
12
19
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const REGISTRY_FINALIZE_AFTER = 10_000
|
|
2
|
+
export const REGISTRY_SWEEP_INTERVAL = 10_000
|
|
3
|
+
|
|
4
|
+
// This is a mock implementation of FinalizationRegistry that uses setTimeout to
|
|
5
|
+
// schedule the sweep of outdated objects.
|
|
6
|
+
// It is used in environments where FinalizationRegistry is not available.
|
|
7
|
+
// For now we permanently keep the values in the registry until they are
|
|
8
|
+
// manually unregistered since we don't have a way to know when the object is
|
|
9
|
+
// no longer needed. In the future we might add the control logic to properly
|
|
10
|
+
// invalidate the objects.
|
|
11
|
+
export let PERMANENT = true
|
|
12
|
+
export function setPermanent (permanent) { PERMANENT = permanent }
|
|
13
|
+
|
|
14
|
+
export class TimerBasedFinalizationRegistry {
|
|
15
|
+
registrations = new Map()
|
|
16
|
+
sweepTimeout
|
|
17
|
+
|
|
18
|
+
constructor (finalize) {
|
|
19
|
+
this.finalize = finalize
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Token is actually required with this impl
|
|
23
|
+
register (target, value, token) {
|
|
24
|
+
this.registrations.set(token, {
|
|
25
|
+
value,
|
|
26
|
+
registeredAt: Date.now()
|
|
27
|
+
})
|
|
28
|
+
if (!PERMANENT) this.scheduleSweep()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
unregister (token) {
|
|
32
|
+
this.registrations.delete(token)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Bound so it can be used directly as setTimeout callback.
|
|
36
|
+
sweep = (maxAge = REGISTRY_FINALIZE_AFTER) => {
|
|
37
|
+
// cancel timeout so we can force sweep anytime
|
|
38
|
+
clearTimeout(this.sweepTimeout)
|
|
39
|
+
this.sweepTimeout = undefined
|
|
40
|
+
|
|
41
|
+
const now = Date.now()
|
|
42
|
+
this.registrations.forEach((registration, token) => {
|
|
43
|
+
if (now - registration.registeredAt >= maxAge) {
|
|
44
|
+
this.finalize(registration.value)
|
|
45
|
+
this.registrations.delete(token)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
if (this.registrations.size > 0) {
|
|
50
|
+
this.scheduleSweep()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Bound so it can be exported directly as clearTimers test utility.
|
|
55
|
+
finalizeAllImmediately = () => {
|
|
56
|
+
this.sweep(0)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
scheduleSweep () {
|
|
60
|
+
if (this.sweepTimeout === undefined) {
|
|
61
|
+
this.sweepTimeout = setTimeout(this.sweep, REGISTRY_SWEEP_INTERVAL)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default (typeof FinalizationRegistry !== 'undefined' ? FinalizationRegistry : TimerBasedFinalizationRegistry)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class MockWeakRef {
|
|
2
|
+
constructor (value) {
|
|
3
|
+
this.value = value
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
deref () {
|
|
7
|
+
return this.value
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function destroyMockWeakRef (weakRef) {
|
|
12
|
+
if (!(weakRef instanceof MockWeakRef)) return
|
|
13
|
+
weakRef.value = undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default (typeof WeakRef !== 'undefined' ? WeakRef : MockWeakRef)
|
package/CHANGELOG.md
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
# v0.1.4 (Thu May 23 2024)
|
|
2
|
-
|
|
3
|
-
#### 🐛 Bug Fix
|
|
4
|
-
|
|
5
|
-
- fix: rename old references to startupjs packages into teamplay packages ([@cray0000](https://github.com/cray0000))
|
|
6
|
-
|
|
7
|
-
#### Authors: 1
|
|
8
|
-
|
|
9
|
-
- Pavel Zhukov ([@cray0000](https://github.com/cray0000))
|
|
10
|
-
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
# v0.1.3 (Thu May 23 2024)
|
|
14
|
-
|
|
15
|
-
#### 🐛 Bug Fix
|
|
16
|
-
|
|
17
|
-
- fix: add 'events' since sharedb client requires it to work correctly ([@cray0000](https://github.com/cray0000))
|
|
18
|
-
|
|
19
|
-
#### Authors: 1
|
|
20
|
-
|
|
21
|
-
- Pavel Zhukov ([@cray0000](https://github.com/cray0000))
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
# v0.1.2 (Thu May 23 2024)
|
|
26
|
-
|
|
27
|
-
#### 🐛 Bug Fix
|
|
28
|
-
|
|
29
|
-
- fix: re-export cache as 'teamplay/cache' ([@cray0000](https://github.com/cray0000))
|
|
30
|
-
|
|
31
|
-
#### Authors: 1
|
|
32
|
-
|
|
33
|
-
- Pavel Zhukov ([@cray0000](https://github.com/cray0000))
|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
# v0.1.1 (Thu May 23 2024)
|
|
38
|
-
|
|
39
|
-
#### 🐛 Bug Fix
|
|
40
|
-
|
|
41
|
-
- fix: dummy. trigger version bump ([@cray0000](https://github.com/cray0000))
|
|
42
|
-
|
|
43
|
-
#### Authors: 1
|
|
44
|
-
|
|
45
|
-
- Pavel Zhukov ([@cray0000](https://github.com/cray0000))
|
package/schema/GUID_PATTERN.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export default '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
package/schema/associations.js
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import GUID_PATTERN from './GUID_PATTERN.js'
|
|
2
|
-
|
|
3
|
-
export function belongsTo (collectionName) {
|
|
4
|
-
return {
|
|
5
|
-
type: 'string',
|
|
6
|
-
pattern: GUID_PATTERN,
|
|
7
|
-
$association: {
|
|
8
|
-
type: 'belongsTo',
|
|
9
|
-
collection: collectionName
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function hasMany (collectionName) {
|
|
15
|
-
return {
|
|
16
|
-
type: 'array',
|
|
17
|
-
items: {
|
|
18
|
-
type: 'string',
|
|
19
|
-
pattern: GUID_PATTERN
|
|
20
|
-
},
|
|
21
|
-
$association: {
|
|
22
|
-
type: 'hasMany',
|
|
23
|
-
collection: collectionName
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function hasManyFlags (collectionName) {
|
|
29
|
-
return {
|
|
30
|
-
type: 'object',
|
|
31
|
-
patternProperties: {
|
|
32
|
-
[GUID_PATTERN]: { type: 'boolean' }
|
|
33
|
-
},
|
|
34
|
-
additionalProperties: false,
|
|
35
|
-
$association: {
|
|
36
|
-
type: 'hasManyFlags',
|
|
37
|
-
collection: collectionName
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function hasOne (collectionName) {
|
|
43
|
-
return {
|
|
44
|
-
type: 'string',
|
|
45
|
-
pattern: GUID_PATTERN,
|
|
46
|
-
$association: {
|
|
47
|
-
type: 'hasOne',
|
|
48
|
-
collection: collectionName
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
package/schema/pickFormFields.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pick properties from json-schema to be used in a form.
|
|
3
|
-
* Supports simplified schema (just the properties object) and full schema.
|
|
4
|
-
* Performs extra transformations like auto-generating `label`.
|
|
5
|
-
* `createdAt`, `updatedAt`, `id`, `_id` fields are excluded by default.
|
|
6
|
-
* @param {Object} schema
|
|
7
|
-
* @param {Object|Array} options - exclude or include fields. If array, it's the same as passing { include: [...] }
|
|
8
|
-
* @param {Array} options.include - list of fields to pick (default: all)
|
|
9
|
-
* @param {Array} options.exclude - list of fields to exclude (default: none)
|
|
10
|
-
* @param {Boolean} options.freeze - whether to deep freeze the result (default: true)
|
|
11
|
-
*/
|
|
12
|
-
export default function pickFormFields (schema, options) {
|
|
13
|
-
try {
|
|
14
|
-
let include, exclude, freeze
|
|
15
|
-
if (Array.isArray(options)) {
|
|
16
|
-
include = options
|
|
17
|
-
} else {
|
|
18
|
-
;({ include, exclude, freeze = true } = options || {})
|
|
19
|
-
}
|
|
20
|
-
exclude ??= []
|
|
21
|
-
if (!schema) throw Error('pickFormFields: schema is required')
|
|
22
|
-
schema = JSON.parse(JSON.stringify(schema))
|
|
23
|
-
if (schema.type === 'object') {
|
|
24
|
-
schema = schema.properties
|
|
25
|
-
}
|
|
26
|
-
for (const key in schema) {
|
|
27
|
-
if (shouldIncludeField(key, schema[key], { include, exclude })) {
|
|
28
|
-
const field = schema[key]
|
|
29
|
-
if (!field.label) field.label = camelCaseToLabel(key)
|
|
30
|
-
} else {
|
|
31
|
-
delete schema[key]
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
if (freeze) return new Proxy(schema, deepFreezeHandler)
|
|
35
|
-
return schema
|
|
36
|
-
} catch (err) {
|
|
37
|
-
throw Error(`
|
|
38
|
-
pickFormFields: ${err.message}
|
|
39
|
-
schema:\n${JSON.stringify(schema, null, 2)}
|
|
40
|
-
`)
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Proxy handlers to deep freeze schema to prevent accidental mutations.
|
|
45
|
-
// For this, when we .get() a property, we also return the same recursive Proxy handler if it's an object.
|
|
46
|
-
const deepFreezeHandler = {
|
|
47
|
-
get (target, prop) {
|
|
48
|
-
const value = target[prop]
|
|
49
|
-
if (typeof value === 'object' && value !== null) {
|
|
50
|
-
return new Proxy(value, deepFreezeHandler)
|
|
51
|
-
}
|
|
52
|
-
return value
|
|
53
|
-
},
|
|
54
|
-
set () {
|
|
55
|
-
throw Error(ERRORS.schemaIsFrozen)
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function shouldIncludeField (key, field, { include, exclude = [] } = {}) {
|
|
60
|
-
if (!field) throw Error(`field "${key}" does not have a schema definition`)
|
|
61
|
-
if (include?.includes(key)) return true
|
|
62
|
-
if (exclude.includes(key)) return false
|
|
63
|
-
// if field has 'input' specified then it's an explicit indicator that it can be used in forms,
|
|
64
|
-
// so the default exclusion rules don't apply
|
|
65
|
-
if (!field.input) {
|
|
66
|
-
// exclude some meta fields by default
|
|
67
|
-
if (DEFAULT_EXCLUDE_FORM_FIELDS.includes(key)) return false
|
|
68
|
-
// exclude foreign keys by default
|
|
69
|
-
// Foreign keys have a custom `$association` property set by belongsTo/hasMany/hasOne helpers
|
|
70
|
-
if (field.$association) return false
|
|
71
|
-
}
|
|
72
|
-
// if include array is not explicitly set, include all fields by default
|
|
73
|
-
if (!include) return true
|
|
74
|
-
return false
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const DEFAULT_EXCLUDE_FORM_FIELDS = ['id', '_id', 'createdAt', 'updatedAt']
|
|
78
|
-
|
|
79
|
-
// split into words, capitalize first word, make others lowercase
|
|
80
|
-
function camelCaseToLabel (str) {
|
|
81
|
-
return str
|
|
82
|
-
.replace(/([A-Z])/g, ' $1')
|
|
83
|
-
.toLowerCase()
|
|
84
|
-
.replace(/^./, (s) => s.toUpperCase())
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const ERRORS = {
|
|
88
|
-
schemaIsFrozen: `
|
|
89
|
-
Form fields are immutable.
|
|
90
|
-
If you want to change them, clone them with \`JSON.parse(JSON.stringify(FORM_FIELDS))\`.
|
|
91
|
-
|
|
92
|
-
If you want to do it inside react component, you can use this pattern for the most effective cloning:
|
|
93
|
-
|
|
94
|
-
\`\`\`
|
|
95
|
-
const $fields = useValue$(useMemo(() => JSON.parse(JSON.stringify(FORM_FIELDS)), []))
|
|
96
|
-
\`\`\`
|
|
97
|
-
|
|
98
|
-
and then pass $fields to the Form component like this:
|
|
99
|
-
|
|
100
|
-
\`\`\`
|
|
101
|
-
<Form $fields={$fields} $value={$value} />
|
|
102
|
-
\`\`\`
|
|
103
|
-
`
|
|
104
|
-
}
|