@asteby/metacore-runtime-react 39.3.0 → 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.
@@ -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
+ })
@@ -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's current category. `meta` is the modal
78
- * metadata; `fields` its field list (to find the class field's ref).
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
- return classes
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 `visible_when.class` fields.
648
- const attributeClasses = useAttributeClasses(api, modalMeta, formValues)
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.
package/src/index.ts CHANGED
@@ -129,6 +129,20 @@ export {
129
129
  type UseDynamicFiltersResult,
130
130
  } from './use-dynamic-filters'
131
131
  export { useDebouncedValue, SEARCH_DEBOUNCE_MS } from './use-debounced-value'
132
+ export {
133
+ useOptimisticMutation,
134
+ type UseOptimisticMutationOptions,
135
+ type UseOptimisticMutationResult,
136
+ } from './use-optimistic-mutation'
137
+ export { useFlipAnimation, type UseFlipAnimationOptions } from './use-flip-animation'
138
+ export {
139
+ motionDuration,
140
+ motionEasing,
141
+ prefersReducedMotion,
142
+ MOTION_DEFAULTS,
143
+ type MotionDuration,
144
+ type MotionEasing,
145
+ } from './motion'
132
146
  export {
133
147
  useResource,
134
148
  useMutation,
@@ -385,6 +399,19 @@ export {
385
399
  type ModelExtension,
386
400
  type ModelExtensionProps,
387
401
  } from './model-extension-registry'
402
+ export {
403
+ registerAgentResultRenderer,
404
+ resolveAgentResultRenderer,
405
+ listAgentResultRenderers,
406
+ clearAgentResultRenderers,
407
+ useAgentResultRegistryVersion,
408
+ AgentResultView,
409
+ type AgentResult,
410
+ type AgentResultRenderer,
411
+ type AgentResultRendererOptions,
412
+ type AgentResultRendererProps,
413
+ type AgentResultViewProps,
414
+ } from './agent-result-registry'
388
415
  export {
389
416
  isColumnVisibleInTable,
390
417
  isColumnVisibleInModal,
package/src/motion.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Runtime access to the theme's motion tokens (`--motion-duration-*`,
3
+ * `--motion-ease-*` from @asteby/metacore-theme/tokens.css). Hosts that do
4
+ * not ship those variables get the same defaults, so JS-driven animations
5
+ * (WAAPI, dnd-kit) and CSS transitions stay on one scale.
6
+ */
7
+ export type MotionDuration = 'instant' | 'fast' | 'moderate' | 'slow'
8
+ export type MotionEasing = 'standard' | 'emphasized' | 'exit'
9
+
10
+ export const MOTION_DEFAULTS = {
11
+ duration: { instant: 100, fast: 150, moderate: 220, slow: 320 } as Record<MotionDuration, number>,
12
+ easing: {
13
+ standard: 'cubic-bezier(0.2, 0, 0, 1)',
14
+ emphasized: 'cubic-bezier(0.3, 0, 0, 1)',
15
+ exit: 'cubic-bezier(0.4, 0, 1, 1)',
16
+ } as Record<MotionEasing, string>,
17
+ }
18
+
19
+ function cssVar(name: string): string {
20
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return ''
21
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
22
+ }
23
+
24
+ /** True when the user asked the OS for reduced motion. */
25
+ export function prefersReducedMotion(): boolean {
26
+ return (
27
+ typeof window !== 'undefined' &&
28
+ typeof window.matchMedia === 'function' &&
29
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
30
+ )
31
+ }
32
+
33
+ /** Duration token in ms (0 under prefers-reduced-motion). */
34
+ export function motionDuration(token: MotionDuration): number {
35
+ if (prefersReducedMotion()) return 0
36
+ const raw = cssVar(`--motion-duration-${token}`)
37
+ if (raw) {
38
+ const n = parseFloat(raw)
39
+ if (Number.isFinite(n)) return raw.endsWith('ms') || !raw.endsWith('s') ? n : n * 1000
40
+ }
41
+ return MOTION_DEFAULTS.duration[token]
42
+ }
43
+
44
+ /** Easing token as a CSS timing function. */
45
+ export function motionEasing(token: MotionEasing): string {
46
+ return cssVar(`--motion-ease-${token}`) || MOTION_DEFAULTS.easing[token]
47
+ }
@@ -0,0 +1,157 @@
1
+ import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'
2
+ import {
3
+ motionDuration,
4
+ motionEasing,
5
+ prefersReducedMotion,
6
+ type MotionDuration,
7
+ type MotionEasing,
8
+ } from './motion'
9
+
10
+ export interface UseFlipAnimationOptions {
11
+ /** Elements to animate, queried inside the root. */
12
+ selector?: string
13
+ /** Stable identity of an element across renders. Default: data-flip-key, then href, then text. */
14
+ keyOf?: (el: HTMLElement) => string | null
15
+ /**
16
+ * Scroll container used as the coordinate origin, so scrolling between two
17
+ * snapshots does not read as movement. Default: the root.
18
+ */
19
+ scrollContainer?: (root: HTMLElement) => HTMLElement | null
20
+ /** Motion token (default `moderate`) or explicit ms. */
21
+ duration?: MotionDuration | number
22
+ /** Motion token (default `standard`) or a CSS timing function. */
23
+ easing?: MotionEasing | string
24
+ /** Off switch; the hook also stays still under prefers-reduced-motion. */
25
+ disabled?: boolean
26
+ }
27
+
28
+ const DEFAULT_SELECTOR = '[data-flip-key]'
29
+ const MOTION_EASING_KEYS = { standard: 1, emphasized: 1, exit: 1 }
30
+
31
+ const defaultKeyOf = (el: HTMLElement): string | null =>
32
+ el.dataset.flipKey ?? el.getAttribute('href') ?? (el.textContent?.trim() || null)
33
+
34
+ type Positions = Map<string, { top: number; left: number }>
35
+
36
+ /**
37
+ * FLIP reorder animation: when `trigger` changes, elements that exist before
38
+ * and after the change glide from their old position to the new one.
39
+ *
40
+ * Positions are snapshotted when the list is at rest (after mount, after each
41
+ * animation, and after pointer interaction inside the root), never in the
42
+ * render path, so a re-render costs nothing. Uses the Web Animations API on
43
+ * `transform` only; with prefers-reduced-motion the change is instant.
44
+ */
45
+ export function useFlipAnimation(
46
+ rootRef: RefObject<HTMLElement | null>,
47
+ trigger: unknown,
48
+ options: UseFlipAnimationOptions = {},
49
+ ): void {
50
+ const {
51
+ selector = DEFAULT_SELECTOR,
52
+ keyOf = defaultKeyOf,
53
+ scrollContainer,
54
+ duration = 'moderate',
55
+ easing = 'standard',
56
+ disabled = false,
57
+ } = options
58
+ const positionsRef = useRef<Positions | null>(null)
59
+ const triggerRef = useRef(trigger)
60
+ const configRef = useRef({ selector, keyOf, scrollContainer })
61
+ useEffect(() => {
62
+ configRef.current = { selector, keyOf, scrollContainer }
63
+ })
64
+
65
+ const measure = (): Positions | null => {
66
+ const root = rootRef.current
67
+ if (!root) return null
68
+ const { selector: sel, keyOf: key, scrollContainer: sc } = configRef.current
69
+ const origin = (sc ? sc(root) : null) ?? root
70
+ const box = origin.getBoundingClientRect()
71
+ const out: Positions = new Map()
72
+ root.querySelectorAll<HTMLElement>(sel).forEach((el) => {
73
+ const k = key(el)
74
+ if (!k || out.has(k)) return
75
+ const r = el.getBoundingClientRect()
76
+ if (r.width === 0 && r.height === 0) return
77
+ out.set(k, {
78
+ top: r.top - box.top + origin.scrollTop,
79
+ left: r.left - box.left + origin.scrollLeft,
80
+ })
81
+ })
82
+ return out
83
+ }
84
+ const measureRef = useRef(measure)
85
+ useEffect(() => {
86
+ measureRef.current = measure
87
+ })
88
+
89
+ // Snapshot at rest: after mount and after interactions that move things
90
+ // without a trigger change (expanding a collapsible, for instance).
91
+ // Listens on the document and resolves the root per event, so a ref whose
92
+ // element is swapped (remount, mobile sheet) keeps working.
93
+ useEffect(() => {
94
+ let timer: ReturnType<typeof setTimeout> | null = null
95
+ const schedule = () => {
96
+ if (timer) clearTimeout(timer)
97
+ timer = setTimeout(() => {
98
+ positionsRef.current = measureRef.current()
99
+ }, 350)
100
+ }
101
+ const onInteraction = (e: Event) => {
102
+ const root = rootRef.current
103
+ if (root && e.target instanceof Node && root.contains(e.target)) schedule()
104
+ }
105
+ schedule()
106
+ document.addEventListener('pointerup', onInteraction, true)
107
+ document.addEventListener('keyup', onInteraction, true)
108
+ window.addEventListener('resize', schedule)
109
+ return () => {
110
+ if (timer) clearTimeout(timer)
111
+ document.removeEventListener('pointerup', onInteraction, true)
112
+ document.removeEventListener('keyup', onInteraction, true)
113
+ window.removeEventListener('resize', schedule)
114
+ }
115
+ }, [rootRef])
116
+
117
+ useLayoutEffect(() => {
118
+ if (Object.is(triggerRef.current, trigger)) return
119
+ triggerRef.current = trigger
120
+ const before = positionsRef.current
121
+ const root = rootRef.current
122
+ if (!root) return
123
+ const after = measureRef.current()
124
+ positionsRef.current = after
125
+ if (!before || !after || disabled || prefersReducedMotion()) return
126
+ const durationMs = typeof duration === 'number' ? duration : motionDuration(duration)
127
+ if (durationMs <= 0) return
128
+ const timing = easing in MOTION_EASING_KEYS ? motionEasing(easing as MotionEasing) : easing
129
+
130
+ const { selector: sel, keyOf: key } = configRef.current
131
+ // A stale snapshot can report huge jumps; those would read as a glitch.
132
+ const maxJump = root.clientHeight > 0 ? root.clientHeight * 1.5 : Infinity
133
+ root.querySelectorAll<HTMLElement>(sel).forEach((el) => {
134
+ const k = key(el)
135
+ if (!k) return
136
+ const from = before.get(k)
137
+ const to = after.get(k)
138
+ if (!to || typeof el.animate !== 'function') return
139
+ if (!from) {
140
+ // Entered with this change: fade in instead of popping.
141
+ el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: durationMs, easing: timing })
142
+ return
143
+ }
144
+ const dy = from.top - to.top
145
+ const dx = from.left - to.left
146
+ if (Math.abs(dy) < 1 && Math.abs(dx) < 1) return
147
+ if (Math.abs(dy) > maxJump) return
148
+ el.animate(
149
+ [
150
+ { transform: `translate(${dx}px, ${dy}px)` },
151
+ { transform: 'translate(0, 0)' },
152
+ ],
153
+ { duration: durationMs, easing: timing },
154
+ )
155
+ })
156
+ }, [trigger, rootRef, disabled, duration, easing])
157
+ }