@asteby/metacore-runtime-react 39.3.0 → 42.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/CHANGELOG.md +47 -0
- package/dist/agent-result-registry.d.ts +44 -0
- package/dist/agent-result-registry.d.ts.map +1 -0
- package/dist/agent-result-registry.js +78 -0
- package/dist/attribute-classes.d.ts +18 -3
- package/dist/attribute-classes.d.ts.map +1 -1
- package/dist/attribute-classes.js +47 -6
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +3 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/motion.d.ts +19 -0
- package/dist/motion.d.ts.map +1 -0
- package/dist/motion.js +35 -0
- package/dist/use-flip-animation.d.ts +30 -0
- package/dist/use-flip-animation.d.ts.map +1 -0
- package/dist/use-flip-animation.js +123 -0
- package/dist/use-optimistic-mutation.d.ts +73 -0
- package/dist/use-optimistic-mutation.d.ts.map +1 -0
- package/dist/use-optimistic-mutation.js +153 -0
- package/dist/use-persisted-query.d.ts +53 -0
- package/dist/use-persisted-query.d.ts.map +1 -0
- package/dist/use-persisted-query.js +92 -0
- package/package.json +5 -5
- package/src/__tests__/motion.test.ts +29 -0
- package/src/__tests__/use-flip-animation.test.tsx +109 -0
- package/src/__tests__/use-optimistic-mutation.test.tsx +201 -0
- package/src/__tests__/use-persisted-query.test.tsx +132 -0
- package/src/agent-result-registry.test.tsx +52 -0
- package/src/agent-result-registry.tsx +129 -0
- package/src/attribute-classes.test.ts +37 -1
- package/src/attribute-classes.ts +47 -5
- package/src/dialogs/dynamic-record.tsx +3 -2
- package/src/index.ts +35 -0
- package/src/motion.ts +47 -0
- package/src/use-flip-animation.ts +157 -0
- package/src/use-optimistic-mutation.ts +218 -0
- package/src/use-persisted-query.ts +152 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi, afterEach } from 'vitest'
|
|
3
|
+
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
import { useOptimisticMutation } from '../use-optimistic-mutation'
|
|
7
|
+
|
|
8
|
+
type Doc = { template: string }
|
|
9
|
+
const KEY = ['layout']
|
|
10
|
+
|
|
11
|
+
function deferred<T>() {
|
|
12
|
+
let resolve!: (v: T) => void
|
|
13
|
+
let reject!: (e: unknown) => void
|
|
14
|
+
const promise = new Promise<T>((res, rej) => {
|
|
15
|
+
resolve = res
|
|
16
|
+
reject = rej
|
|
17
|
+
})
|
|
18
|
+
return { promise, resolve, reject }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function setup(
|
|
22
|
+
mutationFn: (template: string) => Promise<Doc>,
|
|
23
|
+
extra: { debounceMs?: number; onError?: (e: unknown, v: string) => void } = {},
|
|
24
|
+
) {
|
|
25
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
|
26
|
+
qc.setQueryData<Doc>(KEY, { template: 'default' })
|
|
27
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
28
|
+
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
|
29
|
+
)
|
|
30
|
+
const hook = renderHook(
|
|
31
|
+
() =>
|
|
32
|
+
useOptimisticMutation<Doc, string, Doc>({
|
|
33
|
+
queryKey: KEY,
|
|
34
|
+
mutationFn,
|
|
35
|
+
optimistic: (_current, template) => ({ template }),
|
|
36
|
+
reconcile: (data) => data,
|
|
37
|
+
...extra,
|
|
38
|
+
}),
|
|
39
|
+
{ wrapper },
|
|
40
|
+
)
|
|
41
|
+
return { qc, hook }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.useRealTimers()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('useOptimisticMutation', () => {
|
|
49
|
+
it('patches the cache before the request resolves and reconciles with the response', async () => {
|
|
50
|
+
const req = deferred<Doc>()
|
|
51
|
+
const { qc, hook } = setup(() => req.promise)
|
|
52
|
+
|
|
53
|
+
act(() => hook.result.current.mutate('classic'))
|
|
54
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'classic' })
|
|
55
|
+
expect(hook.result.current.isPending).toBe(true)
|
|
56
|
+
expect(hook.result.current.pendingVariables).toBe('classic')
|
|
57
|
+
|
|
58
|
+
await act(async () => req.resolve({ template: 'classic-server' }))
|
|
59
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
60
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'classic-server' })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('rolls back to the confirmed value and reports the error', async () => {
|
|
64
|
+
const req = deferred<Doc>()
|
|
65
|
+
const onError = vi.fn()
|
|
66
|
+
const { qc, hook } = setup(() => req.promise, { onError })
|
|
67
|
+
|
|
68
|
+
act(() => hook.result.current.mutate('classic'))
|
|
69
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'classic' })
|
|
70
|
+
|
|
71
|
+
await act(async () => req.reject(new Error('boom')))
|
|
72
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
73
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'default' })
|
|
74
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error), 'classic')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('ignores a repeated call while the same write is in flight (double click)', async () => {
|
|
78
|
+
const req = deferred<Doc>()
|
|
79
|
+
const fn = vi.fn(() => req.promise)
|
|
80
|
+
const { hook } = setup(fn)
|
|
81
|
+
|
|
82
|
+
act(() => {
|
|
83
|
+
hook.result.current.mutate('classic')
|
|
84
|
+
hook.result.current.mutate('classic')
|
|
85
|
+
})
|
|
86
|
+
await act(async () => req.resolve({ template: 'classic' }))
|
|
87
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
88
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('runs writes in call order and an older response never overwrites a newer choice', async () => {
|
|
92
|
+
const first = deferred<Doc>()
|
|
93
|
+
const second = deferred<Doc>()
|
|
94
|
+
const calls: string[] = []
|
|
95
|
+
const fn = vi.fn((t: string) => {
|
|
96
|
+
calls.push(t)
|
|
97
|
+
return t === 'classic' ? first.promise : second.promise
|
|
98
|
+
})
|
|
99
|
+
const { qc, hook } = setup(fn)
|
|
100
|
+
|
|
101
|
+
act(() => {
|
|
102
|
+
hook.result.current.mutate('classic')
|
|
103
|
+
hook.result.current.mutate('default')
|
|
104
|
+
})
|
|
105
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'default' })
|
|
106
|
+
|
|
107
|
+
await act(async () => first.resolve({ template: 'classic' }))
|
|
108
|
+
// Serialized: the second write starts only after the first settles.
|
|
109
|
+
await waitFor(() => expect(calls).toEqual(['classic', 'default']))
|
|
110
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'default' })
|
|
111
|
+
expect(hook.result.current.pendingVariables).toBe('default')
|
|
112
|
+
|
|
113
|
+
await act(async () => second.resolve({ template: 'default' }))
|
|
114
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
115
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'default' })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('rolls back to the last server-confirmed value when only the newest write fails', async () => {
|
|
119
|
+
const first = deferred<Doc>()
|
|
120
|
+
const second = deferred<Doc>()
|
|
121
|
+
const { qc, hook } = setup((t) => (t === 'classic' ? first.promise : second.promise))
|
|
122
|
+
|
|
123
|
+
act(() => {
|
|
124
|
+
hook.result.current.mutate('classic')
|
|
125
|
+
hook.result.current.mutate('custom')
|
|
126
|
+
})
|
|
127
|
+
await act(async () => first.resolve({ template: 'classic' }))
|
|
128
|
+
await act(async () => second.reject(new Error('nope')))
|
|
129
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
130
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'classic' })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('keeps a superseded failure silent when the newest write succeeds', async () => {
|
|
134
|
+
const first = deferred<Doc>()
|
|
135
|
+
const second = deferred<Doc>()
|
|
136
|
+
const onError = vi.fn()
|
|
137
|
+
const { qc, hook } = setup((t) => (t === 'classic' ? first.promise : second.promise), { onError })
|
|
138
|
+
|
|
139
|
+
act(() => {
|
|
140
|
+
hook.result.current.mutate('classic')
|
|
141
|
+
hook.result.current.mutate('custom')
|
|
142
|
+
})
|
|
143
|
+
await act(async () => first.reject(new Error('stale')))
|
|
144
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'custom' })
|
|
145
|
+
await act(async () => second.resolve({ template: 'custom' }))
|
|
146
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
147
|
+
expect(onError).not.toHaveBeenCalled()
|
|
148
|
+
expect(hook.result.current.error).toBeNull()
|
|
149
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'custom' })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('debounces bursts: every call paints, only the last one is sent', async () => {
|
|
153
|
+
vi.useFakeTimers()
|
|
154
|
+
const fn = vi.fn((t: string) => Promise.resolve({ template: t }))
|
|
155
|
+
const { qc, hook } = setup(fn, { debounceMs: 400 })
|
|
156
|
+
|
|
157
|
+
act(() => hook.result.current.mutate('a'))
|
|
158
|
+
act(() => hook.result.current.mutate('b'))
|
|
159
|
+
act(() => hook.result.current.mutate('c'))
|
|
160
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'c' })
|
|
161
|
+
expect(fn).not.toHaveBeenCalled()
|
|
162
|
+
|
|
163
|
+
await act(async () => {
|
|
164
|
+
vi.advanceTimersByTime(400)
|
|
165
|
+
})
|
|
166
|
+
vi.useRealTimers()
|
|
167
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
168
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
169
|
+
expect(fn).toHaveBeenCalledWith('c')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('flushes a pending debounced write on unmount', async () => {
|
|
173
|
+
vi.useFakeTimers()
|
|
174
|
+
const fn = vi.fn((t: string) => Promise.resolve({ template: t }))
|
|
175
|
+
const { hook } = setup(fn, { debounceMs: 400 })
|
|
176
|
+
|
|
177
|
+
act(() => hook.result.current.mutate('saved-on-close'))
|
|
178
|
+
hook.unmount()
|
|
179
|
+
vi.useRealTimers()
|
|
180
|
+
await waitFor(() => expect(fn).toHaveBeenCalledWith('saved-on-close'))
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('useOptimisticMutation retry', () => {
|
|
185
|
+
it('re-sends the failed variables and re-applies them optimistically', async () => {
|
|
186
|
+
let fail = true
|
|
187
|
+
const fn = vi.fn((t: string) => (fail ? Promise.reject(new Error('offline')) : Promise.resolve({ template: t })))
|
|
188
|
+
const { qc, hook } = setup(fn)
|
|
189
|
+
|
|
190
|
+
act(() => hook.result.current.mutate('classic'))
|
|
191
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
192
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'default' })
|
|
193
|
+
|
|
194
|
+
fail = false
|
|
195
|
+
act(() => hook.result.current.retry())
|
|
196
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'classic' })
|
|
197
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
198
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
199
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'classic' })
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
|
3
|
+
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
import { createPersistedSnapshot, usePersistedQuery } from '../use-persisted-query'
|
|
7
|
+
|
|
8
|
+
type Layout = { order: string[] }
|
|
9
|
+
|
|
10
|
+
function deferred<T>() {
|
|
11
|
+
let resolve!: (v: T) => void
|
|
12
|
+
const promise = new Promise<T>((res) => {
|
|
13
|
+
resolve = res
|
|
14
|
+
})
|
|
15
|
+
return { promise, resolve }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function wrapperFor(qc: QueryClient) {
|
|
19
|
+
return ({ children }: { children: ReactNode }) => (
|
|
20
|
+
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const newClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
25
|
+
|
|
26
|
+
describe('createPersistedSnapshot', () => {
|
|
27
|
+
beforeEach(() => localStorage.clear())
|
|
28
|
+
|
|
29
|
+
it('round-trips per scope and version', () => {
|
|
30
|
+
const snap = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 1 })
|
|
31
|
+
const ts = Date.now() - 1000
|
|
32
|
+
snap.write('org-a', { order: ['a'] }, { ts })
|
|
33
|
+
expect(snap.read('org-a')).toEqual({ data: { order: ['a'] }, ts })
|
|
34
|
+
expect(snap.read('org-b')).toBeUndefined()
|
|
35
|
+
const v2 = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 2 })
|
|
36
|
+
expect(v2.read('org-a')).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('ignores expired and corrupt entries', () => {
|
|
40
|
+
const snap = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 1, maxAgeMs: 1000 })
|
|
41
|
+
snap.write('org-a', { order: ['a'] }, { ts: Date.now() - 5000 })
|
|
42
|
+
expect(snap.read('org-a')).toBeUndefined()
|
|
43
|
+
localStorage.setItem('test:layout:v1:org-b', '{not json')
|
|
44
|
+
expect(snap.read('org-b')).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('usePersistedQuery', () => {
|
|
49
|
+
beforeEach(() => localStorage.clear())
|
|
50
|
+
|
|
51
|
+
it('paints the persisted value on the first render, then revalidates and persists', async () => {
|
|
52
|
+
const snap = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 1 })
|
|
53
|
+
snap.write('org-a', { order: ['cached'] }, { ts: Date.now() - 60_000 })
|
|
54
|
+
const net = deferred<Layout>()
|
|
55
|
+
const renders: (Layout | undefined)[] = []
|
|
56
|
+
const { result } = renderHook(
|
|
57
|
+
() => {
|
|
58
|
+
const q = usePersistedQuery({
|
|
59
|
+
queryKey: ['layout'],
|
|
60
|
+
queryFn: () => net.promise,
|
|
61
|
+
staleTime: 1000,
|
|
62
|
+
persist: { snapshot: snap, scope: 'org-a' },
|
|
63
|
+
})
|
|
64
|
+
renders.push(q.data)
|
|
65
|
+
return q
|
|
66
|
+
},
|
|
67
|
+
{ wrapper: wrapperFor(newClient()) },
|
|
68
|
+
)
|
|
69
|
+
expect(renders[0]).toEqual({ order: ['cached'] })
|
|
70
|
+
await act(async () => net.resolve({ order: ['fresh'] }))
|
|
71
|
+
await waitFor(() => expect(result.current.data).toEqual({ order: ['fresh'] }))
|
|
72
|
+
expect(snap.read('org-a')?.data).toEqual({ order: ['fresh'] })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('revalidates a seed once per load even when it is younger than staleTime', async () => {
|
|
76
|
+
const snap = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 1 })
|
|
77
|
+
snap.write('org-a', { order: ['cached'] }, { ts: Date.now() - 1000 })
|
|
78
|
+
const queryFn = vi.fn(async () => ({ order: ['changed-elsewhere'] }))
|
|
79
|
+
const { result } = renderHook(
|
|
80
|
+
() =>
|
|
81
|
+
usePersistedQuery({
|
|
82
|
+
queryKey: ['layout'],
|
|
83
|
+
queryFn,
|
|
84
|
+
staleTime: 60_000,
|
|
85
|
+
persist: { snapshot: snap, scope: 'org-a' },
|
|
86
|
+
}),
|
|
87
|
+
{ wrapper: wrapperFor(newClient()) },
|
|
88
|
+
)
|
|
89
|
+
expect(result.current.data).toEqual({ order: ['cached'] })
|
|
90
|
+
await waitFor(() => expect(result.current.data).toEqual({ order: ['changed-elsewhere'] }))
|
|
91
|
+
expect(queryFn).toHaveBeenCalledTimes(1)
|
|
92
|
+
expect(snap.read('org-a')?.data).toEqual({ order: ['changed-elsewhere'] })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('persists the raw payload, not the selected shape, and setQueryData writes back', async () => {
|
|
96
|
+
const snap = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 1 })
|
|
97
|
+
const qc = newClient()
|
|
98
|
+
const { result } = renderHook(
|
|
99
|
+
() =>
|
|
100
|
+
usePersistedQuery({
|
|
101
|
+
queryKey: ['layout'],
|
|
102
|
+
queryFn: async () => ({ order: ['x', 'y'] }),
|
|
103
|
+
select: (d: Layout) => d.order.length,
|
|
104
|
+
persist: { snapshot: snap, scope: 'org-a' },
|
|
105
|
+
}),
|
|
106
|
+
{ wrapper: wrapperFor(qc) },
|
|
107
|
+
)
|
|
108
|
+
await waitFor(() => expect(result.current.data).toBe(2))
|
|
109
|
+
await waitFor(() => expect(snap.read('org-a')?.data).toEqual({ order: ['x', 'y'] }))
|
|
110
|
+
act(() => {
|
|
111
|
+
qc.setQueryData<Layout>(['layout'], { order: ['z'] })
|
|
112
|
+
})
|
|
113
|
+
await waitFor(() => expect(snap.read('org-a')?.data).toEqual({ order: ['z'] }))
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('does not read or write without a scope', async () => {
|
|
117
|
+
const snap = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 1 })
|
|
118
|
+
snap.write('org-a', { order: ['cached'] })
|
|
119
|
+
const { result } = renderHook(
|
|
120
|
+
() =>
|
|
121
|
+
usePersistedQuery({
|
|
122
|
+
queryKey: ['layout'],
|
|
123
|
+
queryFn: async () => ({ order: ['fresh'] }),
|
|
124
|
+
persist: { snapshot: snap, scope: null },
|
|
125
|
+
}),
|
|
126
|
+
{ wrapper: wrapperFor(newClient()) },
|
|
127
|
+
)
|
|
128
|
+
expect(result.current.data).toBeUndefined()
|
|
129
|
+
await waitFor(() => expect(result.current.data).toEqual({ order: ['fresh'] }))
|
|
130
|
+
expect(snap.read('org-a')?.data).toEqual({ order: ['cached'] })
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import {
|
|
4
|
+
AgentResultView,
|
|
5
|
+
clearAgentResultRenderers,
|
|
6
|
+
listAgentResultRenderers,
|
|
7
|
+
registerAgentResultRenderer,
|
|
8
|
+
resolveAgentResultRenderer,
|
|
9
|
+
type AgentResultRendererProps,
|
|
10
|
+
} from './agent-result-registry'
|
|
11
|
+
|
|
12
|
+
const Generic = ({ result }: AgentResultRendererProps) => <span>generic:{String(result.title)}</span>
|
|
13
|
+
const Invoice = ({ result }: AgentResultRendererProps) => <span>invoice:{String(result.title)}</span>
|
|
14
|
+
|
|
15
|
+
afterEach(() => clearAgentResultRenderers())
|
|
16
|
+
|
|
17
|
+
describe('agent result registry', () => {
|
|
18
|
+
it('renders a result through the renderer of its kind', () => {
|
|
19
|
+
registerAgentResultRenderer('record', Generic)
|
|
20
|
+
const html = renderToStaticMarkup(
|
|
21
|
+
<AgentResultView result={{ kind: 'record', model: 'products', title: 'Llanta' }} />,
|
|
22
|
+
)
|
|
23
|
+
expect(html).toBe('<span>generic:Llanta</span>')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('prefers a model-specific renderer over the generic one', () => {
|
|
27
|
+
registerAgentResultRenderer('record', Generic)
|
|
28
|
+
registerAgentResultRenderer('record', Invoice, { model: 'invoices' })
|
|
29
|
+
expect(resolveAgentResultRenderer({ kind: 'record', model: 'invoices' })).toBe(Invoice)
|
|
30
|
+
expect(resolveAgentResultRenderer({ kind: 'record', model: 'products' })).toBe(Generic)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders nothing (never the raw payload) for an unknown kind', () => {
|
|
34
|
+
const html = renderToStaticMarkup(
|
|
35
|
+
<AgentResultView result={{ kind: 'mystery', secret: 'Model: warehouses' }} />,
|
|
36
|
+
)
|
|
37
|
+
expect(html).toBe('')
|
|
38
|
+
expect(
|
|
39
|
+
renderToStaticMarkup(
|
|
40
|
+
<AgentResultView result={{ kind: 'mystery' }} fallback={<i>sin vista</i>} />,
|
|
41
|
+
),
|
|
42
|
+
).toBe('<i>sin vista</i>')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('unregister only removes its own registration', () => {
|
|
46
|
+
const off = registerAgentResultRenderer('count', Generic)
|
|
47
|
+
registerAgentResultRenderer('count', Invoice)
|
|
48
|
+
off()
|
|
49
|
+
expect(resolveAgentResultRenderer({ kind: 'count' })).toBe(Invoice)
|
|
50
|
+
expect(listAgentResultRenderers()).toEqual(['count'])
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Agent result renderer registry. An agent reply carries structured tool
|
|
2
|
+
// results (a record, a filtered view, a count, a chart, a plan of changes, a
|
|
3
|
+
// suggested action…) next to its prose. Each result declares a `kind`; the
|
|
4
|
+
// host renders it through whatever component is registered for that kind, so
|
|
5
|
+
// the chat never prints a raw tool payload and an addon can teach the chat
|
|
6
|
+
// to render its own results without touching the host:
|
|
7
|
+
//
|
|
8
|
+
// import { registerAgentResultRenderer } from '@asteby/metacore-runtime-react'
|
|
9
|
+
//
|
|
10
|
+
// registerAgentResultRenderer('record', RecordCard) // any model
|
|
11
|
+
// registerAgentResultRenderer('record', InvoiceCard, { model: 'invoices' })
|
|
12
|
+
// registerAgentResultRenderer('shipment_quote', QuoteCard) // addon kind
|
|
13
|
+
//
|
|
14
|
+
// Resolution is most specific first: `kind` + `model`, then `kind`. A result
|
|
15
|
+
// with no renderer renders nothing (never a JSON dump) unless the caller
|
|
16
|
+
// passes a fallback.
|
|
17
|
+
//
|
|
18
|
+
// Module-level singleton like the model-extension registry, but observable:
|
|
19
|
+
// federated addons register after the host has already painted, so views
|
|
20
|
+
// subscribe and re-render when a renderer lands.
|
|
21
|
+
import * as React from 'react'
|
|
22
|
+
|
|
23
|
+
/** A structured tool result shown under an agent reply. */
|
|
24
|
+
export interface AgentResult {
|
|
25
|
+
/** Result type, e.g. record | view | count | chart | image | plan | proposal | action. */
|
|
26
|
+
kind: string
|
|
27
|
+
/** Model key the result belongs to, when it has one. */
|
|
28
|
+
model?: string
|
|
29
|
+
[key: string]: unknown
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AgentResultRendererProps<R extends AgentResult = AgentResult> {
|
|
33
|
+
result: R
|
|
34
|
+
/**
|
|
35
|
+
* Interactive results (approve a plan, launch a guide) report back through
|
|
36
|
+
* here; the host decides what an action means.
|
|
37
|
+
*/
|
|
38
|
+
onAction?: (action: string, payload?: unknown) => void
|
|
39
|
+
/** Host is processing an action for this result. */
|
|
40
|
+
busy?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type AgentResultRenderer<R extends AgentResult = AgentResult> = React.ComponentType<
|
|
44
|
+
AgentResultRendererProps<R>
|
|
45
|
+
>
|
|
46
|
+
|
|
47
|
+
export interface AgentResultRendererOptions {
|
|
48
|
+
/** Only for results of this model (overrides the generic renderer of the kind). */
|
|
49
|
+
model?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const renderers = new Map<string, AgentResultRenderer<any>>()
|
|
53
|
+
const listeners = new Set<() => void>()
|
|
54
|
+
let version = 0
|
|
55
|
+
|
|
56
|
+
function keyOf(kind: string, model?: string): string {
|
|
57
|
+
return model ? `${kind}:${model}` : kind
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function emit(): void {
|
|
61
|
+
version++
|
|
62
|
+
listeners.forEach((l) => l())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Registers the component that renders results of `kind` (optionally only
|
|
67
|
+
* for one model). Returns an unregister function; a later registration for
|
|
68
|
+
* the same key replaces the earlier one.
|
|
69
|
+
*/
|
|
70
|
+
export function registerAgentResultRenderer<R extends AgentResult = AgentResult>(
|
|
71
|
+
kind: string,
|
|
72
|
+
renderer: AgentResultRenderer<R>,
|
|
73
|
+
options: AgentResultRendererOptions = {},
|
|
74
|
+
): () => void {
|
|
75
|
+
const key = keyOf(kind, options.model)
|
|
76
|
+
renderers.set(key, renderer)
|
|
77
|
+
emit()
|
|
78
|
+
return () => {
|
|
79
|
+
if (renderers.get(key) === renderer) {
|
|
80
|
+
renderers.delete(key)
|
|
81
|
+
emit()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Most specific renderer for a result: kind+model, then kind. */
|
|
87
|
+
export function resolveAgentResultRenderer(
|
|
88
|
+
result: Pick<AgentResult, 'kind' | 'model'>,
|
|
89
|
+
): AgentResultRenderer | undefined {
|
|
90
|
+
if (!result?.kind) return undefined
|
|
91
|
+
return (result.model && renderers.get(keyOf(result.kind, result.model))) || renderers.get(result.kind)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Registered keys (`kind` or `kind:model`), for diagnostics. */
|
|
95
|
+
export function listAgentResultRenderers(): string[] {
|
|
96
|
+
return [...renderers.keys()]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function clearAgentResultRenderers(): void {
|
|
100
|
+
renderers.clear()
|
|
101
|
+
emit()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function subscribe(listener: () => void): () => void {
|
|
105
|
+
listeners.add(listener)
|
|
106
|
+
return () => listeners.delete(listener)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Re-renders the caller whenever a renderer is (un)registered. */
|
|
110
|
+
export function useAgentResultRegistryVersion(): number {
|
|
111
|
+
return React.useSyncExternalStore(
|
|
112
|
+
subscribe,
|
|
113
|
+
() => version,
|
|
114
|
+
() => version,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface AgentResultViewProps extends AgentResultRendererProps {
|
|
119
|
+
/** Rendered when no renderer matches. Defaults to nothing. */
|
|
120
|
+
fallback?: React.ReactNode
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Renders one agent result through the registry. */
|
|
124
|
+
export function AgentResultView({ result, fallback = null, ...rest }: AgentResultViewProps) {
|
|
125
|
+
useAgentResultRegistryVersion()
|
|
126
|
+
const Renderer = resolveAgentResultRenderer(result)
|
|
127
|
+
if (!Renderer) return <>{fallback}</>
|
|
128
|
+
return <Renderer result={result} {...rest} />
|
|
129
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { resolveAttributeClasses, resetAttributeClassCache } from './attribute-classes'
|
|
2
|
+
import { classesCarriedByRecord, resolveAttributeClasses, resetAttributeClassCache } from './attribute-classes'
|
|
3
3
|
import { ATTRIBUTE_CLASSES_KEY, evaluateVisibleWhen, getVisibleWhen } from './dynamic-form-schema'
|
|
4
4
|
import { filterVisibleFields } from './dialogs/dynamic-record'
|
|
5
5
|
|
|
@@ -41,3 +41,39 @@ describe('resolveAttributeClasses', () => {
|
|
|
41
41
|
expect(get).toHaveBeenCalledWith('/data/Category/c1')
|
|
42
42
|
})
|
|
43
43
|
})
|
|
44
|
+
|
|
45
|
+
describe('classesCarriedByRecord', () => {
|
|
46
|
+
const meta = {
|
|
47
|
+
attribute_classes: [
|
|
48
|
+
{ key: 'tire', sections: [{ key: 'size', fields: ['section_width_mm', 'rim_diameter_in'] }] },
|
|
49
|
+
{ key: 'battery', sections: [{ key: 'b', fields: ['capacity_ah'] }] },
|
|
50
|
+
],
|
|
51
|
+
fields: [
|
|
52
|
+
{ key: 'name' },
|
|
53
|
+
{ key: 'TireSpec.section_width_mm' },
|
|
54
|
+
{ key: 'TireSpec.rim_diameter_in' },
|
|
55
|
+
{ key: 'BatterySpec.capacity_ah' },
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it('a saved tire with a spec sheet keeps its fields without a category class', () => {
|
|
60
|
+
expect(classesCarriedByRecord(meta, { name: 'x', 'TireSpec.rim_diameter_in': 16 })).toEqual(['tire'])
|
|
61
|
+
// nested shape
|
|
62
|
+
expect(classesCarriedByRecord(meta, { TireSpec: { section_width_mm: 205 } })).toEqual(['tire'])
|
|
63
|
+
const fields = [
|
|
64
|
+
{ key: 'name', label: 'Nombre', type: 'text' },
|
|
65
|
+
{ key: 'TireSpec.rim_diameter_in', label: 'Rin', type: 'number', visible_when: { class: 'tire' } },
|
|
66
|
+
] as never
|
|
67
|
+
const carried = classesCarriedByRecord(meta, { 'TireSpec.rim_diameter_in': 16 })
|
|
68
|
+
expect(filterVisibleFields(fields, 'edit', { name: 'x' }, carried).map((f) => f.key)).toEqual([
|
|
69
|
+
'name',
|
|
70
|
+
'TireSpec.rim_diameter_in',
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('a record without data in the class fields carries nothing', () => {
|
|
75
|
+
expect(classesCarriedByRecord(meta, { name: 'x', 'TireSpec.rim_diameter_in': null, 'TireSpec.section_width_mm': '' })).toEqual([])
|
|
76
|
+
expect(classesCarriedByRecord(meta, null)).toEqual([])
|
|
77
|
+
expect(classesCarriedByRecord({ fields: meta.fields }, { 'TireSpec.rim_diameter_in': 16 })).toEqual([])
|
|
78
|
+
})
|
|
79
|
+
})
|
package/src/attribute-classes.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// Attribute classes (CONTRACT-item-master §3.2): an extension table's columns
|
|
2
2
|
// are grouped into classes, and each organization assigns classes to its
|
|
3
3
|
// categories. A field with `visible_when.class` shows when the record's
|
|
4
|
-
// category (or one of its parents) carries the class
|
|
4
|
+
// category (or one of its parents) carries the class, or when the saved record
|
|
5
|
+
// already has data in the class's fields (existing data is never hidden).
|
|
5
6
|
//
|
|
6
7
|
// The host serves the classes on the modal metadata (`attribute_classes`) and,
|
|
7
8
|
// optionally, the field whose referenced record carries them
|
|
8
9
|
// (`attribute_class_field`, default `category_id`). The referenced record
|
|
9
10
|
// exposes its classes as `attribute_classes` (array of keys) and its parent as
|
|
10
11
|
// `parent_id`.
|
|
11
|
-
import { useEffect, useState } from 'react'
|
|
12
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
12
13
|
import type { ApiClient } from './api-context'
|
|
13
14
|
|
|
14
15
|
export interface AttributeClassSection {
|
|
@@ -73,14 +74,54 @@ export function resetAttributeClassCache(): void {
|
|
|
73
74
|
cache.clear()
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
function readPath(rec: Record<string, unknown>, key: string): unknown {
|
|
78
|
+
if (key in rec) return rec[key]
|
|
79
|
+
let cur: unknown = rec
|
|
80
|
+
for (const part of key.split('.')) {
|
|
81
|
+
if (cur == null || typeof cur !== 'object') return undefined
|
|
82
|
+
cur = (cur as Record<string, unknown>)[part]
|
|
83
|
+
}
|
|
84
|
+
return cur
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The classes a saved record already carries by its own data: a class whose
|
|
89
|
+
* section fields hold a value in the record. Its fields stay visible even when
|
|
90
|
+
* the category lacks the class (a tire with a spec sheet but no category, or a
|
|
91
|
+
* category the org has not tagged yet) — hiding a class must never hide data
|
|
92
|
+
* that exists. Field keys are matched as served, bare or `<Extension>.<column>`.
|
|
93
|
+
*/
|
|
94
|
+
export function classesCarriedByRecord(
|
|
95
|
+
meta: { attribute_classes?: AttributeClass[]; fields?: { key: string }[] } | null | undefined,
|
|
96
|
+
record: Record<string, unknown> | null | undefined,
|
|
97
|
+
): string[] {
|
|
98
|
+
if (!record || !Array.isArray(meta?.attribute_classes)) return []
|
|
99
|
+
const keys = (meta?.fields ?? []).map((f) => f.key)
|
|
100
|
+
const out: string[] = []
|
|
101
|
+
for (const cls of meta!.attribute_classes!) {
|
|
102
|
+
const cols = new Set((cls.sections ?? []).flatMap((s) => s.fields ?? []))
|
|
103
|
+
const carried = keys.some((k) => {
|
|
104
|
+
const col = k.includes('.') ? k.slice(k.lastIndexOf('.') + 1) : k
|
|
105
|
+
if (!cols.has(col) && !cols.has(k)) return false
|
|
106
|
+
const v = readPath(record, k)
|
|
107
|
+
return v != null && v !== ''
|
|
108
|
+
})
|
|
109
|
+
if (carried) out.push(cls.key)
|
|
110
|
+
}
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
76
114
|
/**
|
|
77
|
-
* The classes that apply to the form
|
|
78
|
-
*
|
|
115
|
+
* The classes that apply to the form: those of its current category plus
|
|
116
|
+
* those the saved record carries by its data (classesCarriedByRecord). `meta`
|
|
117
|
+
* is the modal metadata; `fields` its field list (to find the class field's
|
|
118
|
+
* ref). `record` is the record as loaded (null on create).
|
|
79
119
|
*/
|
|
80
120
|
export function useAttributeClasses(
|
|
81
121
|
api: ApiClient,
|
|
82
122
|
meta: { attribute_classes?: AttributeClass[]; attribute_class_field?: string; fields?: { key: string; ref?: string }[] } | null | undefined,
|
|
83
123
|
formValues: Record<string, unknown>,
|
|
124
|
+
record?: Record<string, unknown> | null,
|
|
84
125
|
): string[] {
|
|
85
126
|
const hasClasses = Array.isArray(meta?.attribute_classes) && meta!.attribute_classes!.length > 0
|
|
86
127
|
const classField = meta?.attribute_class_field || 'category_id'
|
|
@@ -101,5 +142,6 @@ export function useAttributeClasses(
|
|
|
101
142
|
alive = false
|
|
102
143
|
}
|
|
103
144
|
}, [api, model, idStr])
|
|
104
|
-
|
|
145
|
+
const carried = useMemo(() => classesCarriedByRecord(meta, record), [meta, record])
|
|
146
|
+
return useMemo(() => (carried.length ? [...new Set([...classes, ...carried])] : classes), [classes, carried])
|
|
105
147
|
}
|
|
@@ -644,8 +644,9 @@ export function DynamicRecordDialog({
|
|
|
644
644
|
const [relations, setRelations] = useState<RelationMeta[]>([])
|
|
645
645
|
const [record, setRecord] = useState<any | null>(null)
|
|
646
646
|
const [formValues, setFormValues] = useState<Record<string, any>>({})
|
|
647
|
-
// Classes of the record's category, for
|
|
648
|
-
|
|
647
|
+
// Classes of the record's category (plus those its saved data carries), for
|
|
648
|
+
// `visible_when.class` fields.
|
|
649
|
+
const attributeClasses = useAttributeClasses(api, modalMeta, formValues, record)
|
|
649
650
|
// Per-field validation errors (localized strings), keyed by field.key. Shown
|
|
650
651
|
// inline under each input; populated from a 422 `errors` map or the client
|
|
651
652
|
// required-field check, cleared per-field on change and wholesale on reopen.
|