feat: refactor workflow editor authoring surfaces

This commit is contained in:
2026-04-08 21:44:08 +02:00
parent fe46dabfc5
commit 042f62fe55
25 changed files with 4877 additions and 1823 deletions
@@ -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>
)
}