@asteby/metacore-runtime-react 37.6.0 → 38.1.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 +19 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +2 -3
- package/dist/assist-interview.d.ts +67 -0
- package/dist/assist-interview.d.ts.map +1 -0
- package/dist/assist-interview.js +214 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +5 -1
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +2 -1
- package/dist/form-layout-ui.d.ts +8 -4
- package/dist/form-layout-ui.d.ts.map +1 -1
- package/dist/form-layout-ui.js +9 -7
- package/dist/form-layout.d.ts +18 -0
- package/dist/form-layout.d.ts.map +1 -1
- package/dist/form-layout.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/package.json +3 -3
- package/src/action-modal-dispatcher.tsx +11 -20
- package/src/assist-interview.tsx +411 -0
- package/src/dialogs/dynamic-record.tsx +31 -8
- package/src/dynamic-form.tsx +13 -2
- package/src/form-layout-ui.tsx +13 -22
- package/src/form-layout.ts +20 -0
- package/src/index.ts +2 -0
package/dist/index.js
CHANGED
|
@@ -85,3 +85,4 @@ export { StatWidget, BarWidget, LineWidget, AreaWidget, PieWidget, DonutWidget,
|
|
|
85
85
|
export { WidgetRenderer, WidgetSkeleton, SIZE_SPAN, SIZE_CLASS, } from './widgets/widget-renderer';
|
|
86
86
|
export { WidgetCard, DeltaChip, WidgetEmpty, WidgetError, } from './widgets/widget-card';
|
|
87
87
|
export { formatWidgetValue, formatAxisTick, formatDelta, accentClasses, paletteColor, CHART_PALETTE, } from './widgets/widget-format';
|
|
88
|
+
export { AssistInterview, AssistCardView } from './assist-interview';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "38.1.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"sonner": ">=1.7",
|
|
39
39
|
"zustand": ">=5",
|
|
40
40
|
"@asteby/metacore-sdk": "^3.7.0",
|
|
41
|
-
"@asteby/metacore-ui": "^2.
|
|
41
|
+
"@asteby/metacore-ui": "^2.18.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@tanstack/react-router": {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"vitest": "^4.0.0",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
70
|
"@asteby/metacore-sdk": "3.7.0",
|
|
71
|
-
"@asteby/metacore-ui": "2.
|
|
71
|
+
"@asteby/metacore-ui": "2.18.0"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
Switch,
|
|
39
39
|
} from '@asteby/metacore-ui/primitives'
|
|
40
40
|
import { Loader2 } from 'lucide-react'
|
|
41
|
+
import { ProcessStepper } from '@asteby/metacore-ui/wizard'
|
|
41
42
|
import { toast } from 'sonner'
|
|
42
43
|
import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldErrorMap } from './server-error'
|
|
43
44
|
import { useBranchCreateGate } from './branch-create-gate'
|
|
@@ -994,26 +995,16 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
994
995
|
<DynamicIcon name={action.icon} className="h-5 w-5" />
|
|
995
996
|
{tl(action.label)}
|
|
996
997
|
</DialogTitle>
|
|
997
|
-
{/*
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
{
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
role="listitem"
|
|
1008
|
-
aria-current={i === stepIndex ? 'step' : undefined}
|
|
1009
|
-
className="h-1.5 flex-1 rounded-full"
|
|
1010
|
-
style={{
|
|
1011
|
-
backgroundColor: i <= stepIndex ? (action.color || 'hsl(var(--primary))') : 'hsl(var(--muted))',
|
|
1012
|
-
}}
|
|
1013
|
-
/>
|
|
1014
|
-
))}
|
|
1015
|
-
</div>
|
|
1016
|
-
<DialogDescription className="pt-2">
|
|
998
|
+
{/* Step indicator: the platform ProcessStepper (same as the
|
|
999
|
+
declarative form_layout wizards and the process modals) +
|
|
1000
|
+
"Paso i/n · título" and the step's description. */}
|
|
1001
|
+
<div className="pt-1">
|
|
1002
|
+
<ProcessStepper
|
|
1003
|
+
steps={steps.map((s, i) => ({ key: String(i), label: s.title ? tl(s.title) : `${t('common.step', { defaultValue: 'Paso' })} ${i + 1}` }))}
|
|
1004
|
+
activeIndex={stepIndex}
|
|
1005
|
+
onStepClick={i => setStepIndex(i)}
|
|
1006
|
+
/>
|
|
1007
|
+
<DialogDescription className="pt-3">
|
|
1017
1008
|
{t('common.step', { defaultValue: 'Paso' })} {stepIndex + 1}/{steps.length}
|
|
1018
1009
|
{step?.title ? ` · ${tl(step.title)}` : ''}
|
|
1019
1010
|
</DialogDescription>
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// AssistInterview — the conversational AI step of a declarative wizard
|
|
2
|
+
// (`form_layout.sections[].assist`, kernel v0.141.0).
|
|
3
|
+
//
|
|
4
|
+
// Link-onboarding style: ONE question at a time as a big typed headline,
|
|
5
|
+
// progress beats on top, a single answer input (or quick-reply chips) at the
|
|
6
|
+
// bottom, and the assistant's work (fetching a website, thinking…) shown as
|
|
7
|
+
// live status. No form inputs: the provider asks for whatever it needs, does
|
|
8
|
+
// the work, previews what it found and returns the fields the form takes.
|
|
9
|
+
// The panel is generic — it renders the turns the provider sends and knows
|
|
10
|
+
// nothing about brands. Protocol (host `/assist/:provider/…`):
|
|
11
|
+
//
|
|
12
|
+
// POST /assist/:provider/sessions { input } → Session
|
|
13
|
+
// GET /assist/:provider/sessions/:id → Session (poll while working)
|
|
14
|
+
// POST /assist/:provider/sessions/:id/reply { key, value } → Session
|
|
15
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
16
|
+
import { Button, Input } from '@asteby/metacore-ui/primitives'
|
|
17
|
+
import { ArrowRight, Check, CheckCircle2, Loader2, RotateCcw, Sparkles, XCircle } from 'lucide-react'
|
|
18
|
+
import { useApi } from './api-context'
|
|
19
|
+
import type { FormAssist } from './form-layout'
|
|
20
|
+
|
|
21
|
+
export interface AssistProgressStep {
|
|
22
|
+
label: string
|
|
23
|
+
state: 'pending' | 'running' | 'done' | 'error'
|
|
24
|
+
detail?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AssistQuestion {
|
|
28
|
+
key: string
|
|
29
|
+
prompt: string
|
|
30
|
+
kind?: 'text' | 'choice' | 'confirm'
|
|
31
|
+
options?: { value: string; label: string }[]
|
|
32
|
+
placeholder?: string
|
|
33
|
+
/** Prefilled suggestion the user can accept with Enter. */
|
|
34
|
+
suggestion?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AssistCard {
|
|
38
|
+
kind: string
|
|
39
|
+
title?: string
|
|
40
|
+
logo?: string
|
|
41
|
+
logo_light?: string
|
|
42
|
+
logo_dark?: string
|
|
43
|
+
colors?: Record<string, string>
|
|
44
|
+
lines?: { label: string; value: string }[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AssistTurn {
|
|
48
|
+
id: string
|
|
49
|
+
type: 'message' | 'progress' | 'card' | 'question' | 'answer' | 'result'
|
|
50
|
+
text?: string
|
|
51
|
+
steps?: AssistProgressStep[]
|
|
52
|
+
card?: AssistCard
|
|
53
|
+
question?: AssistQuestion
|
|
54
|
+
fields?: Record<string, any>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AssistSession {
|
|
58
|
+
id: string
|
|
59
|
+
working: boolean
|
|
60
|
+
done: boolean
|
|
61
|
+
turns: AssistTurn[]
|
|
62
|
+
result?: Record<string, any>
|
|
63
|
+
error?: string
|
|
64
|
+
/** Optional: how many beats the provider expects (progress dots). */
|
|
65
|
+
beats?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AssistInterviewProps {
|
|
69
|
+
assist: FormAssist
|
|
70
|
+
/** Live form values; the declared `input` fields are sent on start. */
|
|
71
|
+
values: Record<string, any>
|
|
72
|
+
/** Called with the provider's result fields (limited to `output`). */
|
|
73
|
+
onApply: (fields: Record<string, any>) => void
|
|
74
|
+
/** Start immediately (the step is the chat itself). Default true. */
|
|
75
|
+
autoStart?: boolean
|
|
76
|
+
/** Small uppercase label above the headline (e.g. the step title). */
|
|
77
|
+
eyebrow?: string
|
|
78
|
+
className?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const POLL_MS = 800
|
|
82
|
+
const THINKING_HINTS = ['Un momento…', 'Sigo trabajando…', 'Casi listo…']
|
|
83
|
+
|
|
84
|
+
function unwrap(res: any): AssistSession {
|
|
85
|
+
const d = res?.data?.data ?? res?.data ?? res
|
|
86
|
+
return d as AssistSession
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function useTypewriter(text: string) {
|
|
90
|
+
const [n, setN] = useState(0)
|
|
91
|
+
const reduce = typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
setN(reduce ? text.length : 0)
|
|
94
|
+
}, [text, reduce])
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (n >= text.length) return
|
|
97
|
+
const t = setTimeout(() => setN(v => Math.min(text.length, v + 3)), 22)
|
|
98
|
+
return () => clearTimeout(t)
|
|
99
|
+
}, [n, text])
|
|
100
|
+
return { shown: text.slice(0, n), typing: n < text.length }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function AssistInterview({ assist, values, onApply, autoStart = true, eyebrow, className }: AssistInterviewProps) {
|
|
104
|
+
const api = useApi()
|
|
105
|
+
const [session, setSession] = useState<AssistSession | null>(null)
|
|
106
|
+
const [starting, setStarting] = useState(false)
|
|
107
|
+
const [answer, setAnswer] = useState('')
|
|
108
|
+
const [applied, setApplied] = useState(false)
|
|
109
|
+
const [error, setError] = useState<string | null>(null)
|
|
110
|
+
const [hintIdx, setHintIdx] = useState(0)
|
|
111
|
+
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
112
|
+
// Answers given so far: if the host loses the session (restart, TTL) we
|
|
113
|
+
// start a new one and replay them, so the user never types twice.
|
|
114
|
+
const answersRef = useRef<{ key: string; value: any }[]>([])
|
|
115
|
+
const base = `/assist/${encodeURIComponent(assist.provider)}/sessions`
|
|
116
|
+
|
|
117
|
+
const inputPayload = useMemo(() => {
|
|
118
|
+
const out: Record<string, any> = {}
|
|
119
|
+
for (const k of assist.input ?? []) out[k] = values?.[k]
|
|
120
|
+
return out
|
|
121
|
+
}, [assist.input, values])
|
|
122
|
+
|
|
123
|
+
const start = useCallback(async () => {
|
|
124
|
+
if (starting) return
|
|
125
|
+
setStarting(true)
|
|
126
|
+
setError(null)
|
|
127
|
+
setApplied(false)
|
|
128
|
+
answersRef.current = []
|
|
129
|
+
try {
|
|
130
|
+
const res = await api.post(base, { input: inputPayload })
|
|
131
|
+
setSession(unwrap(res))
|
|
132
|
+
} catch (e: any) {
|
|
133
|
+
setError(e?.response?.data?.message || e?.message || 'No se pudo iniciar el asistente')
|
|
134
|
+
} finally {
|
|
135
|
+
setStarting(false)
|
|
136
|
+
}
|
|
137
|
+
}, [api, base, inputPayload, starting])
|
|
138
|
+
|
|
139
|
+
const autoFired = useRef(false)
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!autoStart || autoFired.current || session) return
|
|
142
|
+
autoFired.current = true
|
|
143
|
+
void start()
|
|
144
|
+
}, [autoStart, session, start])
|
|
145
|
+
|
|
146
|
+
// Poll while the provider works.
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!session || !session.working || session.done) return
|
|
149
|
+
const t = setInterval(async () => {
|
|
150
|
+
try {
|
|
151
|
+
const res = await api.get(`${base}/${session.id}`)
|
|
152
|
+
setSession(unwrap(res))
|
|
153
|
+
} catch (e: any) {
|
|
154
|
+
if (isGone(e)) {
|
|
155
|
+
try {
|
|
156
|
+
setSession(await recover())
|
|
157
|
+
} catch {
|
|
158
|
+
/* next tick retries */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}, POLL_MS)
|
|
163
|
+
return () => clearInterval(t)
|
|
164
|
+
}, [api, base, session])
|
|
165
|
+
|
|
166
|
+
// Rotate the thinking hint while working.
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!session?.working) return
|
|
169
|
+
setHintIdx(0)
|
|
170
|
+
const t = setInterval(() => setHintIdx(i => Math.min(i + 1, THINKING_HINTS.length - 1)), 2600)
|
|
171
|
+
return () => clearInterval(t)
|
|
172
|
+
}, [session?.working])
|
|
173
|
+
|
|
174
|
+
// Apply the result once.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (!session?.done || !session.result || applied) return
|
|
177
|
+
const allowed = assist.output?.length ? new Set(assist.output) : null
|
|
178
|
+
const out: Record<string, any> = {}
|
|
179
|
+
for (const [k, v] of Object.entries(session.result)) {
|
|
180
|
+
if (allowed && !allowed.has(k)) continue
|
|
181
|
+
if (v === undefined || v === null || v === '') continue
|
|
182
|
+
out[k] = v
|
|
183
|
+
}
|
|
184
|
+
onApply(out)
|
|
185
|
+
setApplied(true)
|
|
186
|
+
}, [session, applied, assist.output, onApply])
|
|
187
|
+
|
|
188
|
+
const isGone = (e: any) => e?.response?.status === 404 || /sesi[oó]n no encontrada/i.test(String(e?.response?.data?.message || e?.message || ''))
|
|
189
|
+
|
|
190
|
+
// Recreate the session and replay every answer (host lost it).
|
|
191
|
+
const recover = useCallback(async () => {
|
|
192
|
+
const res = await api.post(base, { input: inputPayload })
|
|
193
|
+
let s = unwrap(res)
|
|
194
|
+
for (const a of answersRef.current) {
|
|
195
|
+
// wait until the provider is ready for the next answer
|
|
196
|
+
for (let i = 0; i < 120 && s.working && !s.done; i++) {
|
|
197
|
+
await new Promise(r => setTimeout(r, POLL_MS))
|
|
198
|
+
s = unwrap(await api.get(`${base}/${s.id}`))
|
|
199
|
+
}
|
|
200
|
+
if (s.done) break
|
|
201
|
+
s = unwrap(await api.post(`${base}/${s.id}/reply`, { key: a.key, value: a.value }))
|
|
202
|
+
}
|
|
203
|
+
return s
|
|
204
|
+
}, [api, base, inputPayload])
|
|
205
|
+
|
|
206
|
+
const reply = useCallback(
|
|
207
|
+
async (key: string, value: any) => {
|
|
208
|
+
if (!session) return
|
|
209
|
+
setAnswer('')
|
|
210
|
+
setError(null)
|
|
211
|
+
setSession(s => (s ? { ...s, working: true } : s))
|
|
212
|
+
try {
|
|
213
|
+
const res = await api.post(`${base}/${session.id}/reply`, { key, value })
|
|
214
|
+
answersRef.current = [...answersRef.current, { key, value }]
|
|
215
|
+
setSession(unwrap(res))
|
|
216
|
+
} catch (e: any) {
|
|
217
|
+
if (isGone(e)) {
|
|
218
|
+
try {
|
|
219
|
+
answersRef.current = [...answersRef.current, { key, value }]
|
|
220
|
+
setSession(await recover())
|
|
221
|
+
return
|
|
222
|
+
} catch (e2: any) {
|
|
223
|
+
setError(e2?.response?.data?.message || e2?.message || 'No se pudo retomar la conversación')
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
setError(e?.response?.data?.message || e?.message || 'No se pudo enviar la respuesta')
|
|
227
|
+
}
|
|
228
|
+
setSession(s => (s ? { ...s, working: false } : s))
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
[api, base, session, recover],
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
const turns = session?.turns ?? []
|
|
235
|
+
const last = turns[turns.length - 1]
|
|
236
|
+
const pendingQuestion = !session?.working && !session?.done && last?.type === 'question' ? last.question : null
|
|
237
|
+
const lastSpoken = [...turns].reverse().find(t => t.type === 'message' || t.type === 'question')
|
|
238
|
+
const headline = lastSpoken?.type === 'question' ? lastSpoken.question?.prompt ?? '' : lastSpoken?.text ?? ''
|
|
239
|
+
const { shown, typing } = useTypewriter(headline || (starting ? 'Un segundo…' : ''))
|
|
240
|
+
// The latest progress block stays visible as compact chips (done / error
|
|
241
|
+
// included) until the next block replaces it; the card that followed it
|
|
242
|
+
// shows whenever the assistant is waiting on the user or finished.
|
|
243
|
+
const activeProgress = [...turns].reverse().find(t => t.type === 'progress')
|
|
244
|
+
const lastCard = [...turns].reverse().find(t => t.type === 'card')?.card
|
|
245
|
+
const answered = turns.filter(t => t.type === 'answer').length
|
|
246
|
+
const beats = Math.max(session?.beats ?? 5, 2)
|
|
247
|
+
const progress = session?.done ? beats : Math.min(answered + 1, beats)
|
|
248
|
+
const running = activeProgress?.steps?.find(s => s.state === 'running')
|
|
249
|
+
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (pendingQuestion && !typing) inputRef.current?.focus()
|
|
252
|
+
}, [pendingQuestion, typing])
|
|
253
|
+
|
|
254
|
+
const submitText = () => {
|
|
255
|
+
if (!pendingQuestion) return
|
|
256
|
+
const v = answer.trim() || pendingQuestion.suggestion || ''
|
|
257
|
+
if (v) void reply(pendingQuestion.key, v)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div className={'relative overflow-hidden rounded-2xl border bg-gradient-to-b from-primary/[0.06] via-background to-background px-5 py-6 sm:px-8 sm:py-8 ' + (className ?? '')}>
|
|
262
|
+
{/* top: eyebrow + beats */}
|
|
263
|
+
<div className="flex items-center justify-between gap-4">
|
|
264
|
+
<p className="text-xs font-semibold uppercase tracking-wider text-primary">{eyebrow || assist.label || 'Asistente'}</p>
|
|
265
|
+
<div className="flex items-center gap-1.5" aria-label="progreso">
|
|
266
|
+
{Array.from({ length: beats }).map((_, i) => (
|
|
267
|
+
<span key={i} className={'h-1.5 rounded-full transition-all duration-500 ' + (i < progress ? 'w-6 bg-primary' : 'w-3 bg-muted-foreground/25')} />
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{/* the question — one at a time, typed out */}
|
|
273
|
+
<div className="mt-5 flex items-start gap-3 sm:gap-4">
|
|
274
|
+
<span className="mt-1 flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-lg shadow-primary/25 sm:size-10">
|
|
275
|
+
<Sparkles className={'size-4 sm:size-5 ' + (session?.working || starting ? 'animate-pulse' : '')} />
|
|
276
|
+
</span>
|
|
277
|
+
<h3 className="min-h-[3.5rem] text-lg font-semibold leading-snug tracking-tight sm:min-h-[4rem] sm:text-2xl">
|
|
278
|
+
{shown}
|
|
279
|
+
{typing && <span className="ml-0.5 inline-block h-5 w-[2px] animate-pulse bg-primary align-middle" />}
|
|
280
|
+
</h3>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{/* status line */}
|
|
284
|
+
<div className="mt-3 min-h-6 pl-12 sm:pl-14">
|
|
285
|
+
{(session?.working || starting) && !running && (
|
|
286
|
+
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
|
287
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
288
|
+
{THINKING_HINTS[hintIdx]}
|
|
289
|
+
</span>
|
|
290
|
+
)}
|
|
291
|
+
{activeProgress && (
|
|
292
|
+
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1" aria-label="pasos">
|
|
293
|
+
{(activeProgress.steps ?? []).map((s, i) => (
|
|
294
|
+
<li key={i} className={'inline-flex items-center gap-1.5 text-xs ' + (s.state === 'pending' ? 'text-muted-foreground/70' : s.state === 'error' ? 'text-destructive' : 'text-foreground/80')}>
|
|
295
|
+
{s.state === 'done' ? <CheckCircle2 className="size-3.5 text-emerald-500" /> : s.state === 'running' ? <Loader2 className="size-3.5 animate-spin text-primary" /> : s.state === 'error' ? <XCircle className="size-3.5" /> : <span className="size-1.5 rounded-full bg-muted-foreground/40" />}
|
|
296
|
+
{s.label}
|
|
297
|
+
{s.state === 'running' && s.detail && <span className="text-muted-foreground">· {s.detail}</span>}
|
|
298
|
+
</li>
|
|
299
|
+
))}
|
|
300
|
+
</ul>
|
|
301
|
+
)}
|
|
302
|
+
{lastCard && !session?.working && <AssistCardView card={lastCard} />}
|
|
303
|
+
{session?.done && (
|
|
304
|
+
<span className="inline-flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
|
305
|
+
<Check className="size-4" />
|
|
306
|
+
{applied ? 'Listo. Pulsa Siguiente para revisar lo que armé.' : 'Listo.'}
|
|
307
|
+
</span>
|
|
308
|
+
)}
|
|
309
|
+
{(error || session?.error) && <p className="text-sm text-destructive">{error || session?.error}</p>}
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
{/* the answer */}
|
|
313
|
+
{pendingQuestion && (
|
|
314
|
+
<div className="mt-5 pl-0 sm:pl-14">
|
|
315
|
+
{pendingQuestion.kind === 'choice' || pendingQuestion.kind === 'confirm' ? (
|
|
316
|
+
<div className="flex flex-wrap gap-2">
|
|
317
|
+
{(pendingQuestion.options ?? [{ value: 'yes', label: 'Sí' }, { value: 'no', label: 'No' }]).map(o => (
|
|
318
|
+
<Button key={o.value} type="button" variant="outline" className="h-10 rounded-full px-4" onClick={() => void reply(pendingQuestion.key, o.value)}>
|
|
319
|
+
{o.label}
|
|
320
|
+
</Button>
|
|
321
|
+
))}
|
|
322
|
+
</div>
|
|
323
|
+
) : (
|
|
324
|
+
<div className="relative">
|
|
325
|
+
<Input
|
|
326
|
+
ref={inputRef}
|
|
327
|
+
value={answer}
|
|
328
|
+
onChange={e => setAnswer(e.target.value)}
|
|
329
|
+
onKeyDown={e => {
|
|
330
|
+
if (e.key === 'Enter') {
|
|
331
|
+
e.preventDefault()
|
|
332
|
+
submitText()
|
|
333
|
+
}
|
|
334
|
+
}}
|
|
335
|
+
placeholder={pendingQuestion.suggestion || pendingQuestion.placeholder || 'Escribe tu respuesta…'}
|
|
336
|
+
className="h-12 pr-12 text-base"
|
|
337
|
+
autoFocus
|
|
338
|
+
/>
|
|
339
|
+
<Button type="button" size="icon" className="absolute right-1.5 top-1.5 size-9" onClick={submitText} disabled={!answer.trim() && !pendingQuestion.suggestion}>
|
|
340
|
+
<ArrowRight className="size-4" />
|
|
341
|
+
</Button>
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
{pendingQuestion.suggestion && pendingQuestion.kind !== 'choice' && (
|
|
345
|
+
<p className="pt-1.5 text-xs text-muted-foreground">Enter acepta la sugerencia.</p>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
|
|
350
|
+
{/* idle / retry */}
|
|
351
|
+
{!session && !starting && (
|
|
352
|
+
<div className="mt-5 pl-0 sm:pl-14">
|
|
353
|
+
<Button type="button" onClick={() => void start()}>
|
|
354
|
+
<Sparkles className="mr-2 size-4" />
|
|
355
|
+
{assist.label || 'Empezar'}
|
|
356
|
+
</Button>
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
{session?.done && (
|
|
360
|
+
<div className="mt-4 pl-0 sm:pl-14">
|
|
361
|
+
<button type="button" onClick={() => { setSession(null); setApplied(false); autoFired.current = false }} className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
|
362
|
+
<RotateCcw className="size-3.5" /> Empezar de nuevo
|
|
363
|
+
</button>
|
|
364
|
+
</div>
|
|
365
|
+
)}
|
|
366
|
+
</div>
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Preview card: logo on dark and light, colour swatches, key/value lines. */
|
|
371
|
+
export function AssistCardView({ card }: { card: AssistCard }) {
|
|
372
|
+
const colors = Object.entries(card.colors ?? {})
|
|
373
|
+
const logoDark = card.logo_light || card.logo
|
|
374
|
+
const logoLight = card.logo_dark || card.logo
|
|
375
|
+
return (
|
|
376
|
+
<div className="mt-3 rounded-xl border bg-background p-3">
|
|
377
|
+
{card.title && <p className="pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{card.title}</p>}
|
|
378
|
+
{(card.logo || card.logo_light || card.logo_dark) && (
|
|
379
|
+
<div className="grid grid-cols-2 gap-2">
|
|
380
|
+
<div className="flex h-20 items-center justify-center rounded-lg bg-neutral-950 p-3">
|
|
381
|
+
{logoDark && <img src={logoDark} alt="" className="max-h-full max-w-full object-contain" />}
|
|
382
|
+
</div>
|
|
383
|
+
<div className="flex h-20 items-center justify-center rounded-lg border bg-white p-3">
|
|
384
|
+
{logoLight && <img src={logoLight} alt="" className="max-h-full max-w-full object-contain" />}
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
388
|
+
{colors.length > 0 && (
|
|
389
|
+
<div className="flex flex-wrap gap-2 pt-3">
|
|
390
|
+
{colors.map(([k, v]) => (
|
|
391
|
+
<span key={k} className="inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs">
|
|
392
|
+
<span className="size-4 rounded-full border" style={{ backgroundColor: v }} />
|
|
393
|
+
<span className="text-muted-foreground">{k}</span>
|
|
394
|
+
<span className="font-mono">{v}</span>
|
|
395
|
+
</span>
|
|
396
|
+
))}
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
{(card.lines ?? []).length > 0 && (
|
|
400
|
+
<dl className="grid gap-1 pt-3 text-sm">
|
|
401
|
+
{card.lines!.map(l => (
|
|
402
|
+
<div key={l.label} className="grid grid-cols-[6rem_1fr] gap-2">
|
|
403
|
+
<dt className="text-muted-foreground">{l.label}</dt>
|
|
404
|
+
<dd className="min-w-0 break-words">{l.value}</dd>
|
|
405
|
+
</div>
|
|
406
|
+
))}
|
|
407
|
+
</dl>
|
|
408
|
+
)}
|
|
409
|
+
</div>
|
|
410
|
+
)
|
|
411
|
+
}
|
|
@@ -63,6 +63,7 @@ import { getFieldRef, getVisibleWhen, evaluateVisibleWhen } from '../dynamic-for
|
|
|
63
63
|
import type { VisibleWhen } from '../types'
|
|
64
64
|
import { groupFieldsBySection, type FormLayout } from '../form-layout'
|
|
65
65
|
import { FieldSection, WizardProgress } from '../form-layout-ui'
|
|
66
|
+
import { AssistInterview } from '../assist-interview'
|
|
66
67
|
import { FieldCell } from '../field-grid'
|
|
67
68
|
import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
|
|
68
69
|
import { normalizeRefFieldsForSubmit } from './normalize-submit'
|
|
@@ -1080,15 +1081,37 @@ export function DynamicRecordDialog({
|
|
|
1080
1081
|
className="grid gap-y-4"
|
|
1081
1082
|
>
|
|
1082
1083
|
{isSteps && (
|
|
1083
|
-
<WizardProgress groups={groups} stepIndex={clampedStep} />
|
|
1084
|
+
<WizardProgress groups={groups} stepIndex={clampedStep} onStepClick={i => setStepIndex(i)} />
|
|
1085
|
+
)}
|
|
1086
|
+
{(isSteps ? [groups[clampedStep]] : groups).map(group =>
|
|
1087
|
+
// An assisted step IS the interview: no form inputs, the
|
|
1088
|
+
// provider asks what it needs and fills the fields.
|
|
1089
|
+
isSteps && isEditable && group.assist ? (
|
|
1090
|
+
<AssistInterview
|
|
1091
|
+
key={group.key}
|
|
1092
|
+
assist={group.assist}
|
|
1093
|
+
values={formValues}
|
|
1094
|
+
eyebrow={group.title}
|
|
1095
|
+
autoStart={group.assist.trigger !== 'button'}
|
|
1096
|
+
onApply={fields => setFormValues(prev => ({ ...prev, ...fields }))}
|
|
1097
|
+
/>
|
|
1098
|
+
) : (
|
|
1099
|
+
<FieldSection key={group.key} group={group}>
|
|
1100
|
+
<div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
|
1101
|
+
{renderFields(group.fields)}
|
|
1102
|
+
</div>
|
|
1103
|
+
{isEditable && group.assist && (
|
|
1104
|
+
<AssistInterview
|
|
1105
|
+
assist={group.assist}
|
|
1106
|
+
values={formValues}
|
|
1107
|
+
eyebrow={group.title}
|
|
1108
|
+
autoStart={group.assist.trigger === 'auto'}
|
|
1109
|
+
onApply={fields => setFormValues(prev => ({ ...prev, ...fields }))}
|
|
1110
|
+
/>
|
|
1111
|
+
)}
|
|
1112
|
+
</FieldSection>
|
|
1113
|
+
),
|
|
1084
1114
|
)}
|
|
1085
|
-
{(isSteps ? [groups[clampedStep]] : groups).map(group => (
|
|
1086
|
-
<FieldSection key={group.key} group={group}>
|
|
1087
|
-
<div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
|
1088
|
-
{renderFields(group.fields)}
|
|
1089
|
-
</div>
|
|
1090
|
-
</FieldSection>
|
|
1091
|
-
))}
|
|
1092
1115
|
|
|
1093
1116
|
{record?.external_url && (
|
|
1094
1117
|
<div className="grid grid-cols-1 sm:grid-cols-2">
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { useEffect, useMemo, useState } from 'react'
|
|
5
5
|
import { groupFieldsBySection, type FormLayout } from './form-layout'
|
|
6
6
|
import { FieldSection, WizardProgress } from './form-layout-ui'
|
|
7
|
+
import { AssistInterview } from './assist-interview'
|
|
7
8
|
import {
|
|
8
9
|
Input,
|
|
9
10
|
Textarea,
|
|
@@ -211,8 +212,18 @@ export function DynamicForm({
|
|
|
211
212
|
|
|
212
213
|
return (
|
|
213
214
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
|
214
|
-
<WizardProgress groups={groups} stepIndex={stepIndex} />
|
|
215
|
-
{
|
|
215
|
+
<WizardProgress groups={groups} stepIndex={stepIndex} onStepClick={i => setStepIndex(i)} />
|
|
216
|
+
{step.assist ? (
|
|
217
|
+
<AssistInterview
|
|
218
|
+
assist={step.assist}
|
|
219
|
+
values={values}
|
|
220
|
+
eyebrow={step.title}
|
|
221
|
+
autoStart={step.assist.trigger !== 'button'}
|
|
222
|
+
onApply={fields => setValues(prev => ({ ...prev, ...fields }))}
|
|
223
|
+
/>
|
|
224
|
+
) : (
|
|
225
|
+
renderGrid(step.fields)
|
|
226
|
+
)}
|
|
216
227
|
<div className="flex justify-between gap-2 pt-2">
|
|
217
228
|
{stepIndex > 0 ? (
|
|
218
229
|
<Button type="button" variant="outline" onClick={goBack} disabled={submitting || disabled}>
|
package/src/form-layout-ui.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
Button,
|
|
11
11
|
} from '@asteby/metacore-ui/primitives'
|
|
12
12
|
import { ChevronDown } from 'lucide-react'
|
|
13
|
+
import { ProcessStepper } from '@asteby/metacore-ui/wizard'
|
|
13
14
|
import type { FieldGroup } from './form-layout'
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -75,43 +76,33 @@ export function FieldSection({
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
79
|
+
* Step indicator for `mode: "steps"`: the platform's ProcessStepper (numbered
|
|
80
|
+
* circles with a check when done, label underneath, connectors) plus a
|
|
81
|
+
* "Paso i/n · <title>" caption and the step's description. Same component the
|
|
82
|
+
* process modals (workshop, warehouse) use, so a model wizard, an action wizard
|
|
83
|
+
* and a process modal read the same.
|
|
81
84
|
*/
|
|
82
85
|
export function WizardProgress({
|
|
83
86
|
groups,
|
|
84
87
|
stepIndex,
|
|
85
88
|
stepLabel = 'Paso',
|
|
89
|
+
onStepClick,
|
|
86
90
|
}: {
|
|
87
91
|
groups: FieldGroup<unknown>[]
|
|
88
92
|
stepIndex: number
|
|
89
93
|
stepLabel?: string
|
|
94
|
+
/** Jump back to an already completed step (forward jumps are never offered). */
|
|
95
|
+
onStepClick?: (index: number) => void
|
|
90
96
|
}) {
|
|
91
97
|
const current = groups[stepIndex]
|
|
98
|
+
const steps = groups.map((g, i) => ({ key: g.key, label: g.title ?? `${stepLabel} ${i + 1}` }))
|
|
92
99
|
return (
|
|
93
|
-
<div className="pt-
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
<div
|
|
97
|
-
key={g.key}
|
|
98
|
-
role="listitem"
|
|
99
|
-
aria-current={i === stepIndex ? 'step' : undefined}
|
|
100
|
-
className="h-1.5 flex-1 rounded-full"
|
|
101
|
-
style={{
|
|
102
|
-
backgroundColor:
|
|
103
|
-
i <= stepIndex ? 'hsl(var(--primary))' : 'hsl(var(--muted))',
|
|
104
|
-
}}
|
|
105
|
-
/>
|
|
106
|
-
))}
|
|
107
|
-
</div>
|
|
108
|
-
<p className="pt-2 text-sm text-muted-foreground">
|
|
100
|
+
<div className="pt-1">
|
|
101
|
+
<ProcessStepper steps={steps} activeIndex={stepIndex} onStepClick={onStepClick} />
|
|
102
|
+
<p className="pt-3 text-sm text-muted-foreground">
|
|
109
103
|
{stepLabel} {stepIndex + 1}/{groups.length}
|
|
110
104
|
{current?.title ? ` · ${current.title}` : ''}
|
|
111
105
|
</p>
|
|
112
|
-
{current?.description && (
|
|
113
|
-
<p className="pt-1 text-sm text-muted-foreground">{current.description}</p>
|
|
114
|
-
)}
|
|
115
106
|
</div>
|
|
116
107
|
)
|
|
117
108
|
}
|