246 lines
10 KiB
TypeScript
246 lines
10 KiB
TypeScript
import { useMemo, type ChangeEvent } from 'react'
|
|
import type { WorkflowNodeDefinition, WorkflowNodeFieldDefinition, WorkflowParams } from '../../api/workflows'
|
|
import {
|
|
FAMILY_FILTER_LABELS,
|
|
FAMILY_FILTER_STYLES,
|
|
getDefinitionFamily,
|
|
getDefinitionModuleLabel,
|
|
groupDefinitionsForStepSelect,
|
|
isDefinitionAllowedForGraphFamily,
|
|
type WorkflowGraphFamily,
|
|
} from './workflowNodeLibrary'
|
|
import { WorkflowNodeContractCard } from './WorkflowNodeContractCard'
|
|
|
|
function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) {
|
|
return fields.reduce<Record<string, WorkflowNodeFieldDefinition[]>>((sections, field) => {
|
|
const section = field.section || 'General'
|
|
sections[section] = [...(sections[section] ?? []), field]
|
|
return sections
|
|
}, {})
|
|
}
|
|
|
|
function getContractValues(contract: Record<string, unknown> | undefined, key: string): string[] {
|
|
const value = contract?.[key]
|
|
if (!Array.isArray(value)) return []
|
|
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
|
}
|
|
|
|
function getContractContextLabel(contract: Record<string, unknown> | undefined): string | null {
|
|
const value = contract?.context
|
|
if (value !== 'cad_file' && value !== 'order_line') return null
|
|
return value === 'cad_file' ? 'CAD File' : 'Order Line'
|
|
}
|
|
|
|
type WorkflowNodeInspectorProps = {
|
|
params: WorkflowParams
|
|
onChange: (params: WorkflowParams) => void
|
|
nodeDefinition?: WorkflowNodeDefinition
|
|
step?: string
|
|
onStepChange?: (step: string) => void
|
|
nodeDefinitions: WorkflowNodeDefinition[]
|
|
graphFamily: WorkflowGraphFamily
|
|
}
|
|
|
|
export function WorkflowNodeInspector({
|
|
params,
|
|
onChange,
|
|
nodeDefinition,
|
|
step,
|
|
onStepChange,
|
|
nodeDefinitions,
|
|
graphFamily,
|
|
}: WorkflowNodeInspectorProps) {
|
|
const customRenderSettingsEnabled = Boolean(params.use_custom_render_settings)
|
|
const selectableNodeDefinitions = useMemo(
|
|
() =>
|
|
nodeDefinitions.filter(definition =>
|
|
isDefinitionAllowedForGraphFamily(definition, graphFamily),
|
|
),
|
|
[graphFamily, nodeDefinitions],
|
|
)
|
|
const nodeSelectionGroups = groupDefinitionsForStepSelect(selectableNodeDefinitions)
|
|
|
|
const updateField = (field: WorkflowNodeFieldDefinition, value: unknown) => {
|
|
onChange({
|
|
...params,
|
|
[field.key]: value,
|
|
})
|
|
}
|
|
|
|
const handleNumberChange = (field: WorkflowNodeFieldDefinition, event: ChangeEvent<HTMLInputElement>) => {
|
|
const rawValue = event.target.value
|
|
if (rawValue === '') {
|
|
const nextParams = { ...params }
|
|
delete nextParams[field.key]
|
|
onChange(nextParams)
|
|
return
|
|
}
|
|
updateField(field, Number(rawValue))
|
|
}
|
|
|
|
const fieldsBySection = groupFieldsBySection(nodeDefinition?.fields ?? [])
|
|
const inputContextLabel = getContractContextLabel(nodeDefinition?.input_contract as Record<string, unknown> | undefined)
|
|
const outputContextLabel = getContractContextLabel(nodeDefinition?.output_contract as Record<string, unknown> | undefined)
|
|
const requiredInputs = getContractValues(nodeDefinition?.input_contract as Record<string, unknown> | undefined, 'requires')
|
|
const providedOutputs = getContractValues(nodeDefinition?.output_contract as Record<string, unknown> | undefined, 'provides')
|
|
const consumedArtifacts = nodeDefinition?.artifact_roles_consumed ?? []
|
|
const producedArtifacts = nodeDefinition?.artifact_roles_produced ?? []
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<h3 className="font-semibold text-content">Node Configuration</h3>
|
|
|
|
{nodeDefinitions.length > 0 && onStepChange && (
|
|
<div>
|
|
<label className="text-sm text-content-secondary mb-2 block">Workflow Node</label>
|
|
<select
|
|
value={step ?? ''}
|
|
onChange={event => onStepChange(event.target.value)}
|
|
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
|
>
|
|
{nodeSelectionGroups.map(group => (
|
|
<optgroup key={group.label} label={group.label}>
|
|
{group.options.map(definition => (
|
|
<option key={definition.step} value={definition.step}>
|
|
{definition.label}
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
))}
|
|
</select>
|
|
{nodeDefinition && (
|
|
<div className="mt-2 space-y-1">
|
|
<p className="text-xs text-content-muted">{nodeDefinition.description}</p>
|
|
{graphFamily !== 'mixed' && (
|
|
<p className="text-xs text-content-muted">
|
|
Step selection is scoped to {FAMILY_FILTER_LABELS[graphFamily]} nodes for this workflow.
|
|
</p>
|
|
)}
|
|
<span
|
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
|
nodeDefinition.execution_kind === 'bridge'
|
|
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
|
|
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
|
}`}
|
|
>
|
|
{nodeDefinition.execution_kind === 'bridge' ? 'Legacy Bridge' : 'Native Node'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{nodeDefinition && (
|
|
<WorkflowNodeContractCard
|
|
moduleLabel={getDefinitionModuleLabel(nodeDefinition)}
|
|
moduleKey={nodeDefinition.module_key}
|
|
familyLabel={FAMILY_FILTER_LABELS[getDefinitionFamily(nodeDefinition)]}
|
|
familyClassName={FAMILY_FILTER_STYLES[getDefinitionFamily(nodeDefinition)]}
|
|
runtimeLabel={nodeDefinition.execution_kind === 'bridge' ? 'Bridge Runtime' : 'Graph Runtime'}
|
|
runtimeClassName={
|
|
nodeDefinition.execution_kind === 'bridge'
|
|
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
|
|
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
|
}
|
|
legacyCompatible={nodeDefinition.legacy_compatible}
|
|
legacySource={nodeDefinition.legacy_source}
|
|
inputContextLabel={inputContextLabel}
|
|
outputContextLabel={outputContextLabel}
|
|
requiredInputs={requiredInputs}
|
|
consumedArtifacts={consumedArtifacts}
|
|
providedOutputs={providedOutputs}
|
|
producedArtifacts={producedArtifacts}
|
|
/>
|
|
)}
|
|
|
|
{Object.keys(fieldsBySection).length === 0 && (
|
|
<p className="text-sm text-content-muted">
|
|
This node currently has no configurable settings in the editor.
|
|
</p>
|
|
)}
|
|
|
|
{Object.entries(fieldsBySection).map(([section, fields]) => (
|
|
<div key={section} className="space-y-3">
|
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
|
{section}
|
|
</h4>
|
|
{fields.map(field => {
|
|
const rawValue = params[field.key]
|
|
const value = rawValue ?? field.default
|
|
const disableRenderOverrideField =
|
|
(step === 'blender_still' || step === 'blender_turntable') &&
|
|
!customRenderSettingsEnabled &&
|
|
field.key !== 'use_custom_render_settings' &&
|
|
(field.section === 'Render' || field.section === 'Output')
|
|
|
|
return (
|
|
<div key={field.key}>
|
|
<label className="text-sm text-content-secondary mb-1 block">
|
|
{field.label}
|
|
{field.unit ? ` (${field.unit})` : ''}
|
|
</label>
|
|
{field.type === 'select' && (
|
|
<select
|
|
value={String(value ?? '')}
|
|
onChange={event => updateField(field, event.target.value)}
|
|
disabled={disableRenderOverrideField}
|
|
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
|
>
|
|
{field.options.map(option => (
|
|
<option key={String(option.value)} value={String(option.value)}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
{field.type === 'number' && (
|
|
<input
|
|
type="number"
|
|
min={field.min ?? undefined}
|
|
max={field.max ?? undefined}
|
|
step={field.step ?? undefined}
|
|
value={typeof value === 'number' ? value : value == null ? '' : Number(value)}
|
|
onChange={event => handleNumberChange(field, event)}
|
|
disabled={disableRenderOverrideField}
|
|
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
|
/>
|
|
)}
|
|
{field.type === 'boolean' && (
|
|
<label className="flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm text-content">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(value)}
|
|
onChange={event => updateField(field, event.target.checked)}
|
|
disabled={disableRenderOverrideField}
|
|
className="accent-accent"
|
|
/>
|
|
<span>{Boolean(value) ? 'Enabled' : 'Disabled'}</span>
|
|
</label>
|
|
)}
|
|
{field.type === 'text' && (
|
|
<input
|
|
type="text"
|
|
value={value == null ? '' : String(value)}
|
|
onChange={event => updateField(field, event.target.value)}
|
|
disabled={disableRenderOverrideField}
|
|
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
|
/>
|
|
)}
|
|
{field.description && (
|
|
<p className="mt-1 text-xs text-content-muted">{field.description}</p>
|
|
)}
|
|
{disableRenderOverrideField && (
|
|
<p className="mt-1 text-xs text-content-muted">
|
|
In Graph/Shadow mode this field inherits from Output Type and Template until
|
|
Custom Render Settings is enabled.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|