322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||
import { nanoid } from 'nanoid'
|
||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
|
||
import type { Condition } from '@/types/flowchart'
|
||
|
||
type ChoiceOption = {
|
||
id: string
|
||
label: string
|
||
condition?: Condition
|
||
}
|
||
|
||
type ChoiceNodeData = {
|
||
prompt: string
|
||
options: ChoiceOption[]
|
||
}
|
||
|
||
const MIN_OPTIONS = 2
|
||
const MAX_OPTIONS = 6
|
||
|
||
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||
const { setNodes } = useReactFlow()
|
||
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
||
|
||
const updatePrompt = useCallback(
|
||
(e: ChangeEvent<HTMLInputElement>) => {
|
||
setNodes((nodes) =>
|
||
nodes.map((node) =>
|
||
node.id === id
|
||
? { ...node, data: { ...node.data, prompt: e.target.value } }
|
||
: node
|
||
)
|
||
)
|
||
},
|
||
[id, setNodes]
|
||
)
|
||
|
||
const updateOptionLabel = useCallback(
|
||
(optionId: string, label: string) => {
|
||
setNodes((nodes) =>
|
||
nodes.map((node) =>
|
||
node.id === id
|
||
? {
|
||
...node,
|
||
data: {
|
||
...node.data,
|
||
options: node.data.options.map((opt: ChoiceOption) =>
|
||
opt.id === optionId ? { ...opt, label } : opt
|
||
),
|
||
},
|
||
}
|
||
: node
|
||
)
|
||
)
|
||
},
|
||
[id, setNodes]
|
||
)
|
||
|
||
const updateOptionCondition = useCallback(
|
||
(optionId: string, condition: Condition | undefined) => {
|
||
setNodes((nodes) =>
|
||
nodes.map((node) =>
|
||
node.id === id
|
||
? {
|
||
...node,
|
||
data: {
|
||
...node.data,
|
||
options: node.data.options.map((opt: ChoiceOption) =>
|
||
opt.id === optionId ? { ...opt, condition } : opt
|
||
),
|
||
},
|
||
}
|
||
: node
|
||
)
|
||
)
|
||
},
|
||
[id, setNodes]
|
||
)
|
||
|
||
const addOption = useCallback(() => {
|
||
if (data.options.length >= MAX_OPTIONS) return
|
||
setNodes((nodes) =>
|
||
nodes.map((node) =>
|
||
node.id === id
|
||
? {
|
||
...node,
|
||
data: {
|
||
...node.data,
|
||
options: [
|
||
...node.data.options,
|
||
{ id: nanoid(), label: '' },
|
||
],
|
||
},
|
||
}
|
||
: node
|
||
)
|
||
)
|
||
}, [id, data.options.length, setNodes])
|
||
|
||
const removeOption = useCallback(
|
||
(optionId: string) => {
|
||
if (data.options.length <= MIN_OPTIONS) return
|
||
setNodes((nodes) =>
|
||
nodes.map((node) =>
|
||
node.id === id
|
||
? {
|
||
...node,
|
||
data: {
|
||
...node.data,
|
||
options: node.data.options.filter(
|
||
(opt: ChoiceOption) => opt.id !== optionId
|
||
),
|
||
},
|
||
}
|
||
: node
|
||
)
|
||
)
|
||
},
|
||
[id, data.options.length, setNodes]
|
||
)
|
||
|
||
const editingOption = useMemo(() => {
|
||
if (!editingConditionOptionId) return null
|
||
return data.options.find((opt) => opt.id === editingConditionOptionId) || null
|
||
}, [editingConditionOptionId, data.options])
|
||
|
||
const hasInvalidConditionReference = useCallback(
|
||
(option: ChoiceOption) => {
|
||
if (!option.condition?.variableId) return false
|
||
return !variables.some((v) => v.id === option.condition!.variableId)
|
||
},
|
||
[variables]
|
||
)
|
||
|
||
return (
|
||
<>
|
||
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
|
||
<Handle
|
||
type="target"
|
||
position={Position.Top}
|
||
id="input"
|
||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||
/>
|
||
|
||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
|
||
Choice
|
||
</div>
|
||
|
||
<input
|
||
type="text"
|
||
value={data.prompt || ''}
|
||
onChange={updatePrompt}
|
||
placeholder="What do you choose?"
|
||
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||
/>
|
||
|
||
<div className="space-y-2">
|
||
{data.options.map((option, index) => (
|
||
<div key={option.id}>
|
||
<div className="relative flex items-center gap-1">
|
||
<input
|
||
type="text"
|
||
value={option.label}
|
||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||
placeholder={`Option ${index + 1}`}
|
||
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditingConditionOptionId(option.id)}
|
||
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
||
option.condition?.variableId
|
||
? hasInvalidConditionReference(option)
|
||
? 'bg-orange-100 text-orange-600 ring-1 ring-orange-500 dark:bg-orange-900/30 dark:text-orange-400'
|
||
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||
: 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||
}`}
|
||
title={option.condition?.variableId ? 'Edit condition' : 'Add condition'}
|
||
>
|
||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeOption(option.id)}
|
||
disabled={data.options.length <= MIN_OPTIONS}
|
||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
||
title="Remove option"
|
||
>
|
||
×
|
||
</button>
|
||
<Handle
|
||
type="source"
|
||
position={Position.Bottom}
|
||
id={`option-${index}`}
|
||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||
style={{
|
||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
{option.condition?.variableId && (
|
||
<div className={`mt-0.5 ml-1 text-[10px] ${
|
||
hasInvalidConditionReference(option)
|
||
? 'text-orange-500 dark:text-orange-400'
|
||
: 'text-zinc-500 dark:text-zinc-400'
|
||
}`}>
|
||
if {option.condition.variableName} {option.condition.operator} {String(option.condition.value)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={addOption}
|
||
disabled={data.options.length >= MAX_OPTIONS}
|
||
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
|
||
title="Add option"
|
||
>
|
||
+ Add Option
|
||
</button>
|
||
</div>
|
||
|
||
<input
|
||
type="text"
|
||
value={data.prompt || ''}
|
||
onChange={updatePrompt}
|
||
placeholder="What do you choose?"
|
||
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||
/>
|
||
|
||
<div className="space-y-2">
|
||
{data.options.map((option, index) => (
|
||
<div key={option.id}>
|
||
<div className="relative flex items-center gap-1">
|
||
<input
|
||
type="text"
|
||
value={option.label}
|
||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||
placeholder={`Option ${index + 1}`}
|
||
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditingConditionOptionId(option.id)}
|
||
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
||
option.condition
|
||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/50 dark:text-amber-300 dark:hover:bg-amber-900/70'
|
||
: 'text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:text-zinc-500 dark:hover:bg-zinc-700 dark:hover:text-zinc-300'
|
||
}`}
|
||
title={option.condition ? `Condition: ${option.condition.variableName} ${option.condition.operator} ${option.condition.value}` : 'Add condition'}
|
||
>
|
||
{option.condition ? (
|
||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||
</svg>
|
||
) : (
|
||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeOption(option.id)}
|
||
disabled={data.options.length <= MIN_OPTIONS}
|
||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
||
title="Remove option"
|
||
>
|
||
×
|
||
</button>
|
||
<Handle
|
||
type="source"
|
||
position={Position.Bottom}
|
||
id={`option-${index}`}
|
||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||
style={{
|
||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
{option.condition && (
|
||
<div className="ml-1 mt-0.5 flex items-center gap-1">
|
||
<span className="inline-flex items-center rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
|
||
if {option.condition.variableName} {option.condition.operator} {option.condition.value}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={addOption}
|
||
disabled={data.options.length >= MAX_OPTIONS}
|
||
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
|
||
title="Add option"
|
||
>
|
||
+ Add Option
|
||
</button>
|
||
|
||
{editingOption && (
|
||
<OptionConditionEditor
|
||
optionId={editingOption.id}
|
||
optionLabel={editingOption.label}
|
||
condition={editingOption.condition}
|
||
onSave={handleSaveCondition}
|
||
onRemove={handleRemoveCondition}
|
||
onCancel={() => setEditingConditionOptionId(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|