@asteby/metacore-runtime-react 39.2.10 → 41.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/agent-result-registry.d.ts +44 -0
  3. package/dist/agent-result-registry.d.ts.map +1 -0
  4. package/dist/agent-result-registry.js +78 -0
  5. package/dist/attribute-classes.d.ts +46 -0
  6. package/dist/attribute-classes.d.ts.map +1 -0
  7. package/dist/attribute-classes.js +124 -0
  8. package/dist/dialogs/dynamic-record.d.ts +2 -2
  9. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  10. package/dist/dialogs/dynamic-record.js +14 -9
  11. package/dist/dynamic-form-schema.d.ts +6 -0
  12. package/dist/dynamic-form-schema.d.ts.map +1 -1
  13. package/dist/dynamic-form-schema.js +12 -1
  14. package/dist/index.d.ts +6 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -1
  17. package/dist/motion.d.ts +19 -0
  18. package/dist/motion.d.ts.map +1 -0
  19. package/dist/motion.js +35 -0
  20. package/dist/types.d.ts +8 -1
  21. package/dist/types.d.ts.map +1 -1
  22. package/dist/use-flip-animation.d.ts +30 -0
  23. package/dist/use-flip-animation.d.ts.map +1 -0
  24. package/dist/use-flip-animation.js +123 -0
  25. package/dist/use-optimistic-mutation.d.ts +73 -0
  26. package/dist/use-optimistic-mutation.d.ts.map +1 -0
  27. package/dist/use-optimistic-mutation.js +153 -0
  28. package/package.json +3 -3
  29. package/src/__tests__/motion.test.ts +29 -0
  30. package/src/__tests__/use-flip-animation.test.tsx +109 -0
  31. package/src/__tests__/use-optimistic-mutation.test.tsx +201 -0
  32. package/src/agent-result-registry.test.tsx +52 -0
  33. package/src/agent-result-registry.tsx +129 -0
  34. package/src/attribute-classes.test.ts +79 -0
  35. package/src/attribute-classes.ts +147 -0
  36. package/src/dialogs/dynamic-record.tsx +22 -7
  37. package/src/dynamic-form-schema.ts +13 -1
  38. package/src/index.ts +35 -0
  39. package/src/motion.ts +47 -0
  40. package/src/types.ts +8 -1
  41. package/src/use-flip-animation.ts +157 -0
  42. package/src/use-optimistic-mutation.ts +218 -0
  43. package/tsconfig.json +1 -1
@@ -0,0 +1,109 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi, afterEach } from 'vitest'
3
+ import { render } from '@testing-library/react'
4
+ import { useRef } from 'react'
5
+ import { useFlipAnimation } from '../use-flip-animation'
6
+
7
+ function List({ order, reduced }: { order: string[]; reduced?: boolean }) {
8
+ const ref = useRef<HTMLUListElement>(null)
9
+ useFlipAnimation(ref, order.join(','), { disabled: reduced })
10
+ return (
11
+ <ul ref={ref}>
12
+ {order.map((k) => (
13
+ <li key={k} data-flip-key={k}>
14
+ {k}
15
+ </li>
16
+ ))}
17
+ </ul>
18
+ )
19
+ }
20
+
21
+ // happy-dom has no layout: place each item by its index in the list.
22
+ function fakeLayout() {
23
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
24
+ const parent = this.parentElement
25
+ const index = parent && this.dataset.flipKey ? Array.from(parent.children).indexOf(this) : 0
26
+ const top = this.dataset.flipKey ? index * 40 : 0
27
+ return { top, left: 0, width: 100, height: 40, right: 100, bottom: top + 40, x: 0, y: top, toJSON() {} } as DOMRect
28
+ })
29
+ }
30
+
31
+ afterEach(() => {
32
+ vi.restoreAllMocks()
33
+ vi.useRealTimers()
34
+ })
35
+
36
+ describe('useFlipAnimation', () => {
37
+ it('animates moved items from their old position', () => {
38
+ vi.useFakeTimers()
39
+ fakeLayout()
40
+ const animate = vi.fn()
41
+ HTMLElement.prototype.animate = animate as never
42
+
43
+ const view = render(<List order={['a', 'b', 'c']} />)
44
+ vi.advanceTimersByTime(400) // at-rest snapshot
45
+ view.rerender(<List order={['c', 'a', 'b']} />)
46
+
47
+ const moves = animate.mock.calls.map(([frames]) => (frames as Keyframe[])[0]!.transform)
48
+ // c: 80 → 0, a: 0 → 40, b: 40 → 80
49
+ expect(moves).toContain('translate(0px, 80px)')
50
+ expect(moves).toContain('translate(0px, -40px)')
51
+ expect(animate).toHaveBeenCalledTimes(3)
52
+ })
53
+
54
+ it('stays still when disabled (reduced motion path)', () => {
55
+ vi.useFakeTimers()
56
+ fakeLayout()
57
+ const animate = vi.fn()
58
+ HTMLElement.prototype.animate = animate as never
59
+
60
+ const view = render(<List order={['a', 'b']} reduced />)
61
+ vi.advanceTimersByTime(400)
62
+ view.rerender(<List order={['b', 'a']} reduced />)
63
+ expect(animate).not.toHaveBeenCalled()
64
+ })
65
+
66
+ it('stays still under prefers-reduced-motion', () => {
67
+ vi.useFakeTimers()
68
+ fakeLayout()
69
+ const animate = vi.fn()
70
+ HTMLElement.prototype.animate = animate as never
71
+ vi.spyOn(window, 'matchMedia').mockImplementation(
72
+ (query: string) => ({ matches: query.includes('reduce'), media: query }) as MediaQueryList,
73
+ )
74
+
75
+ const view = render(<List order={['a', 'b']} />)
76
+ vi.advanceTimersByTime(400)
77
+ view.rerender(<List order={['b', 'a']} />)
78
+ expect(animate).not.toHaveBeenCalled()
79
+ })
80
+
81
+ it('works with a root resolved from the DOM instead of a wrapper element', () => {
82
+ vi.useFakeTimers()
83
+ fakeLayout()
84
+ const animate = vi.fn()
85
+ HTMLElement.prototype.animate = animate as never
86
+ const domRef = {
87
+ get current() {
88
+ return document.querySelector<HTMLElement>('[data-slot="list"]')
89
+ },
90
+ }
91
+ function Resolved({ order }: { order: string[] }) {
92
+ useFlipAnimation(domRef, order.join(','))
93
+ return (
94
+ <ul data-slot="list">
95
+ {order.map((k) => (
96
+ <li key={k} data-flip-key={k}>
97
+ {k}
98
+ </li>
99
+ ))}
100
+ </ul>
101
+ )
102
+ }
103
+
104
+ const view = render(<Resolved order={['a', 'b']} />)
105
+ vi.advanceTimersByTime(400)
106
+ view.rerender(<Resolved order={['b', 'a']} />)
107
+ expect(animate).toHaveBeenCalledTimes(2)
108
+ })
109
+ })
@@ -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,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
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { classesCarriedByRecord, resolveAttributeClasses, resetAttributeClassCache } from './attribute-classes'
3
+ import { ATTRIBUTE_CLASSES_KEY, evaluateVisibleWhen, getVisibleWhen } from './dynamic-form-schema'
4
+ import { filterVisibleFields } from './dialogs/dynamic-record'
5
+
6
+ describe('visible_when.class', () => {
7
+ it('shows a class field only when the record carries the class', () => {
8
+ const cond = getVisibleWhen({ visible_when: { class: 'tire' } })
9
+ expect(cond).toEqual({ class: 'tire' })
10
+ expect(evaluateVisibleWhen(cond, { [ATTRIBUTE_CLASSES_KEY]: ['tire'] })).toBe(true)
11
+ expect(evaluateVisibleWhen(cond, { [ATTRIBUTE_CLASSES_KEY]: ['battery'] })).toBe(false)
12
+ expect(evaluateVisibleWhen(cond, {})).toBe(false)
13
+ })
14
+
15
+ it('filters dialog fields by the resolved classes and keeps the rest', () => {
16
+ const fields = [
17
+ { key: 'name', label: 'Nombre', type: 'text' },
18
+ { key: 'TireSpec.rim_diameter_in', label: 'Rin', type: 'number', visible_when: { class: 'tire' } },
19
+ ] as never
20
+ const values = { name: 'x' }
21
+ expect(filterVisibleFields(fields, 'create', values, []).map((f) => f.key)).toEqual(['name'])
22
+ expect(filterVisibleFields(fields, 'create', values, ['tire']).map((f) => f.key)).toEqual([
23
+ 'name',
24
+ 'TireSpec.rim_diameter_in',
25
+ ])
26
+ })
27
+ })
28
+
29
+ describe('resolveAttributeClasses', () => {
30
+ it('walks the category parents and caches each node', async () => {
31
+ resetAttributeClassCache()
32
+ const rows: Record<string, unknown> = {
33
+ c1: { attribute_classes: [], parent_id: 'c0' },
34
+ c0: { attribute_classes: ['tire'], parent_id: null },
35
+ }
36
+ const get = vi.fn(async (url: string) => ({ data: { data: rows[url.split('/').pop() as string] } }))
37
+ const api = { get, post: vi.fn(), put: vi.fn(), delete: vi.fn() }
38
+ expect(await resolveAttributeClasses(api as never, 'Category', 'c1')).toEqual(['tire'])
39
+ await resolveAttributeClasses(api as never, 'Category', 'c1')
40
+ expect(get).toHaveBeenCalledTimes(2)
41
+ expect(get).toHaveBeenCalledWith('/data/Category/c1')
42
+ })
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
+ })