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