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,479 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { ArrowRight, Plus, Search } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import {
CATEGORY_COLORS,
CATEGORY_LABELS,
FAMILY_FILTER_LABELS,
FAMILY_FILTER_STYLES,
NODE_KIND_FILTER_LABELS,
NODE_LIBRARY_GROUP_DESCRIPTIONS,
NODE_LIBRARY_GROUP_LABELS,
NODE_LIBRARY_GROUP_STYLES,
getDefinitionBadges,
getDefinitionFamily,
getDefinitionModuleNamespace,
type WorkflowGraphFamily,
type WorkflowNodeFamilyFilter,
type WorkflowNodeKindFilter,
type WorkflowNodeLibraryGroup,
} from './workflowNodeLibrary'
import {
buildWorkflowNodeCatalog,
filterWorkflowNodeDefinitions,
getAvailableFamilyFilters,
} from './workflowNodeCatalog'
type WorkflowNodeCatalogBrowserProps = {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
variant?: 'menu' | 'panel'
onSelectStep?: (step: string) => void
onEmptyAction?: () => void
emptyActionLabel?: string
renderIcon?: (iconName?: string, size?: number) => ReactNode
searchPlaceholder?: string
autoFocusSearch?: boolean
}
type WorkflowNodeCatalogModuleFilter = {
namespace: string
label: string
count: number
}
function readContractList(contract: Record<string, unknown>, key: string) {
const value = contract[key]
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) : []
}
function readContractContext(contract: Record<string, unknown>) {
return typeof contract.context === 'string' ? contract.context : null
}
function formatContractLabel(value: string) {
return value
.split(/[_\s]+/)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
export function WorkflowNodeCatalogBrowser({
definitions,
graphFamily,
variant = 'panel',
onSelectStep,
onEmptyAction,
emptyActionLabel = 'Clear Filters',
renderIcon,
searchPlaceholder = 'Search node label, step, or capability',
autoFocusSearch = false,
}: WorkflowNodeCatalogBrowserProps) {
const [query, setQuery] = useState('')
const [familyFilter, setFamilyFilter] = useState<WorkflowNodeFamilyFilter>(
graphFamily === 'mixed' ? 'all' : graphFamily,
)
const [kindFilter, setKindFilter] = useState<WorkflowNodeKindFilter>('all')
const [moduleFilter, setModuleFilter] = useState<string>('all')
useEffect(() => {
setFamilyFilter(graphFamily === 'mixed' ? 'all' : graphFamily)
setModuleFilter('all')
}, [graphFamily])
const availableFamilyFilters = useMemo(
() => getAvailableFamilyFilters(graphFamily),
[graphFamily],
)
const filteredDefinitions = useMemo(() => {
return filterWorkflowNodeDefinitions(definitions, {
graphFamily,
familyFilter,
kindFilter,
query,
})
}, [definitions, familyFilter, graphFamily, kindFilter, query])
const moduleFilters = useMemo<WorkflowNodeCatalogModuleFilter[]>(() => {
const modules = new Map<string, WorkflowNodeCatalogModuleFilter>()
for (const definition of filteredDefinitions) {
const namespace = getDefinitionModuleNamespace(definition)
const current = modules.get(namespace)
if (current) {
current.count += 1
continue
}
modules.set(namespace, {
namespace,
label: definition.module_key,
count: 1,
})
}
return Array.from(modules.values()).sort((a, b) => a.label.localeCompare(b.label))
}, [filteredDefinitions])
useEffect(() => {
if (moduleFilter !== 'all' && !moduleFilters.some(module => module.namespace === moduleFilter)) {
setModuleFilter('all')
}
}, [moduleFilter, moduleFilters])
const visibleDefinitions = useMemo(() => {
if (moduleFilter === 'all') return filteredDefinitions
return filteredDefinitions.filter(definition => getDefinitionModuleNamespace(definition) === moduleFilter)
}, [filteredDefinitions, moduleFilter])
const catalogSections = useMemo(() => buildWorkflowNodeCatalog(visibleDefinitions), [visibleDefinitions])
const firstVisibleDefinition = visibleDefinitions[0]
const totalModuleCount = moduleFilters.length
return (
<div className="space-y-3">
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
{visibleDefinitions.length} nodes
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
{totalModuleCount} modules
</span>
{graphFamily !== 'mixed' && (
<span className={`rounded-full px-2 py-0.5 font-medium ${FAMILY_FILTER_STYLES[graphFamily]}`}>
{FAMILY_FILTER_LABELS[graphFamily]}
</span>
)}
</div>
{moduleFilter !== 'all' && (
<button
type="button"
onClick={() => setModuleFilter('all')}
className="text-[11px] font-medium text-accent hover:text-accent-hover"
>
Show all modules
</button>
)}
</div>
<div className="relative">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
value={query}
autoFocus={autoFocusSearch}
onChange={event => setQuery(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' && firstVisibleDefinition && onSelectStep) {
event.preventDefault()
onSelectStep(firstVisibleDefinition.step)
}
}}
placeholder={searchPlaceholder}
className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-sm text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{(['all', 'legacy', 'bridge', 'graph'] as WorkflowNodeKindFilter[]).map(filter => (
<button
key={filter}
type="button"
onClick={() => setKindFilter(filter)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
kindFilter === filter
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
{NODE_KIND_FILTER_LABELS[filter]}
</button>
))}
</div>
<div className="flex flex-wrap gap-2">
{availableFamilyFilters.map(filter => (
<button
key={filter}
type="button"
onClick={() => setFamilyFilter(filter)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
familyFilter === filter
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
{FAMILY_FILTER_LABELS[filter]}
</button>
))}
</div>
{moduleFilters.length > 0 && (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
Modules
</p>
<span className="text-[11px] text-content-muted">
family + runtime scoped
</span>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setModuleFilter('all')}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
moduleFilter === 'all'
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
All Modules
</button>
{moduleFilters.map(module => (
<button
key={module.namespace}
type="button"
onClick={() => setModuleFilter(module.namespace)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
moduleFilter === module.namespace
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
title={module.label}
>
{module.namespace}
<span className="ml-1 opacity-70">{module.count}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
const count = catalogSections.find(section => section.group === group)?.definitions.length ?? 0
if (count === 0) return null
return (
<span
key={group}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
title={NODE_LIBRARY_GROUP_DESCRIPTIONS[group]}
>
<span>{NODE_KIND_FILTER_LABELS[group]}</span>
<span>{count}</span>
</span>
)
})}
</div>
{visibleDefinitions.length === 0 && (
<div className="rounded-2xl border border-dashed border-border-default bg-surface-hover/40 px-4 py-8 text-center">
<p className="text-sm font-medium text-content">No matching nodes</p>
<p className="mt-1 text-xs text-content-muted">
Adjust search, runtime, family, or module filters to bring nodes back into view.
</p>
{onEmptyAction && (
<button
type="button"
onClick={onEmptyAction}
className="mt-3 rounded-lg border border-border-default px-3 py-1.5 text-xs font-medium text-content hover:bg-surface-hover"
>
{emptyActionLabel}
</button>
)}
</div>
)}
<div className="space-y-3">
{catalogSections.map(section => {
const group = section.group as WorkflowNodeLibraryGroup
return (
<div key={group} className="rounded-lg border border-border-default bg-surface-hover/40 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}>
{NODE_LIBRARY_GROUP_LABELS[group]}
</span>
<span className="text-xs text-content-muted">{section.definitions.length}</span>
</div>
<p className="mb-2 text-xs text-content-muted">{NODE_LIBRARY_GROUP_DESCRIPTIONS[group]}</p>
<div className="space-y-2">
{section.modules.map(moduleGroup => (
<div
key={`${group}:${moduleGroup.namespace}`}
className="rounded-md border border-border-default bg-surface/70 px-2 py-2"
>
<div className="flex items-center justify-between gap-2 py-1 text-xs text-content-secondary">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="rounded-full border border-border-default bg-surface px-1.5 py-0.5 font-medium">
{moduleGroup.label}
</span>
<span className="truncate rounded-full border border-border-default bg-surface px-1.5 py-0.5 font-mono text-[10px]">
{moduleGroup.namespace}
</span>
{moduleGroup.familyCounts.cad_file > 0 && (
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES.cad_file}`}>
{FAMILY_FILTER_LABELS.cad_file}
</span>
)}
{moduleGroup.familyCounts.order_line > 0 && (
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES.order_line}`}>
{FAMILY_FILTER_LABELS.order_line}
</span>
)}
</div>
<span className="text-content-muted">{moduleGroup.definitions.length}</span>
</div>
<div className="mt-1 space-y-2">
{moduleGroup.categories.map(categorySection => {
const { category, definitions: categoryDefinitions } = categorySection
return (
<div key={`${group}:${moduleGroup.namespace}:${category}`}>
<div className="mb-1 flex items-center justify-between gap-2">
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category]}`}>
{CATEGORY_LABELS[category]}
</span>
<span className="text-[10px] text-content-muted">{categoryDefinitions.length}</span>
</div>
<div className="space-y-1">
{categoryDefinitions.map(definition => {
const family = getDefinitionFamily(definition)
const requiredInputs = readContractList(definition.input_contract, 'requires')
const providedOutputs = readContractList(definition.output_contract, 'provides')
const inputContext = readContractContext(definition.input_contract)
const outputContext = readContractContext(definition.output_contract)
const isActionable = Boolean(onSelectStep)
return (
<div
key={definition.step}
className={`rounded-lg border border-border-default bg-surface px-3 py-2 ${
isActionable ? 'transition-colors hover:bg-surface-hover' : ''
}`}
title={definition.description}
>
<div className="flex items-start gap-2">
{renderIcon && (
<span className="mt-0.5 text-content-secondary">
{renderIcon(definition.icon, variant === 'menu' ? 14 : 13)}
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<p className="truncate text-sm font-medium text-content">{definition.label}</p>
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES[family]}`}>
{FAMILY_FILTER_LABELS[family]}
</span>
{getDefinitionBadges(definition).map(badge => (
<span
key={`${definition.step}-${badge.label}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}
>
{badge.label}
</span>
))}
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-content-muted">{definition.step}</p>
</div>
{isActionable && (
<button
type="button"
onClick={() => onSelectStep?.(definition.step)}
className={`shrink-0 rounded-lg px-2 py-1 text-xs font-medium ${
variant === 'panel'
? 'border border-border-default text-content hover:bg-surface-hover'
: 'bg-accent text-white hover:bg-accent-hover'
}`}
>
{variant === 'panel' ? (
<span className="inline-flex items-center gap-1">
<Plus size={12} />
Insert
</span>
) : (
<span className="inline-flex items-center gap-1">
Use
<ArrowRight size={12} />
</span>
)}
</button>
)}
</div>
<p className="mt-1 line-clamp-2 text-xs text-content-muted">{definition.description}</p>
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
{inputContext && (
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
In {formatContractLabel(inputContext)}
</span>
)}
{outputContext && (
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
Out {formatContractLabel(outputContext)}
</span>
)}
{requiredInputs.slice(0, 2).map(input => (
<span
key={`${definition.step}-requires-${input}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Requires {formatContractLabel(input)}
</span>
))}
{providedOutputs.slice(0, 2).map(output => (
<span
key={`${definition.step}-provides-${output}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Provides {formatContractLabel(output)}
</span>
))}
{definition.artifact_roles_consumed.slice(0, 1).map(artifact => (
<span
key={`${definition.step}-consumes-${artifact}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Consumes {formatContractLabel(artifact)}
</span>
))}
{definition.artifact_roles_produced.slice(0, 1).map(artifact => (
<span
key={`${definition.step}-produces-${artifact}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Produces {formatContractLabel(artifact)}
</span>
))}
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
))}
</div>
</div>
)
})}
</div>
</div>
)
}