feat: refactor workflow editor authoring surfaces
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user