Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/agentflow/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ module.exports = {
{
target: './src/atoms',
from: './src/core',
except: ['./types', './theme'],
message: 'Atoms can only import from core/types and core/theme, not utilities or business logic.'
except: ['./types', './theme', './primitives'],
message: 'Atoms can only import from core/types, core/theme, and core/primitives.'
},
// core/ cannot import from anything (leaf node)
{
Expand Down
18 changes: 15 additions & 3 deletions packages/agentflow/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ atoms/
- No API calls
- Stateless or minimal local state
- Imported by features, never the reverse
- **Forbidden**: Importing from `features/` or `infrastructure/` (except types from `core/types` for prop definitions and design tokens from `core/theme`)
- **Forbidden**: Importing from `features/` or `infrastructure/` (except types from `core/types` for prop definitions, design tokens from `core/theme`, and primitives from `core/primitives`)

**Goal:** 100% visual consistency.

Expand Down Expand Up @@ -110,6 +110,9 @@ features/
core/
├── types/ # Global interfaces (Node, Edge, Flow)
│ └── index.ts
├── primitives/ # Domain-free utilities (safe for atoms)
│ ├── inputDefaults.ts # getDefaultValueForType
│ └── index.ts
├── node-config/ # Node configuration (icons, colors, default types)
│ ├── nodeIcons.ts # AGENTFLOW_ICONS, DEFAULT_AGENTFLOW_NODES
│ └── ...
Expand All @@ -124,7 +127,7 @@ core/
│ ├── flowValidation.ts # validateFlow, validateNode
│ ├── connectionValidation.ts # isValidConnectionAgentflowV2
│ └── ...
├── utils/ # Generic utilities
├── utils/ # Domain-aware utilities (NOT importable by atoms)
│ ├── nodeFactory.ts # initNode, getUniqueNodeId
│ └── ...
└── index.ts # Barrel export (use sparingly)
Expand All @@ -138,6 +141,15 @@ core/
- Pure functions where possible
- Can be tested in isolation

#### `core/primitives/` vs `core/utils/`

`core/` contains two utility directories with different import permissions:

- **`primitives/`** — Domain-free, general-purpose functions with no knowledge of nodes, flows, or any business concept. These are pure data transformations (e.g., computing a default value from a type string). **Safe to import from `atoms/`.**
- **`utils/`** — Domain-aware utilities that understand node structures, flow data, or validation logic (e.g., `initNode`, `buildDynamicOutputAnchors`). **Only importable by `features/` and `infrastructure/`.**

When adding a new utility, ask: _"Does this function need to know what a Node or Flow is?"_ If no → `primitives/`. If yes → `utils/`.

**Goal:** To be the framework-agnostic source of truth.

---
Expand Down Expand Up @@ -210,7 +222,7 @@ infrastructure/

- `features` → `atoms`, `infrastructure`, `core` ✅
- `infrastructure` → `core` ✅
- `atoms` → `core/types` and `core/theme` only (for type definitions and design tokens)
- `atoms` → `core/types`, `core/theme`, and `core/primitives` only
- `core` → nothing (leaf node) ✅
- **Atoms and Core are "leaf" nodes** - they cannot import from `features/` or `infrastructure/`

Expand Down
2 changes: 2 additions & 0 deletions packages/agentflow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@
"@uiw/codemirror-theme-vscode": "^4.21.0",
"@uiw/react-codemirror": "^4.21.0",
"axios": "^1.7.2",
"dompurify": "^3.2.6",
"flowise-react-json-view": "^1.21.7",
"html-react-parser": "^3.0.16",
"lodash": "^4.17.21",
"lowlight": "^3.3.0",
"uuid": "^10.0.0"
Expand Down
19 changes: 2 additions & 17 deletions packages/agentflow/src/atoms/ArrayInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Box, Button, Chip, IconButton } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconPlus, IconTrash } from '@tabler/icons-react'

import { getDefaultValueForType } from '@/core/primitives'
import type { InputParam, NodeData } from '@/core/types'

import { type AsyncInputProps, type ConfigInputComponentProps, NodeInputHandler } from './NodeInputHandler'
Expand Down Expand Up @@ -75,23 +76,7 @@ export function ArrayInput({

if (inputParam.array) {
for (const field of inputParam.array) {
if (field.default !== undefined) {
newItem[field.name] = field.default
} else {
switch (field.type) {
case 'number':
newItem[field.name] = 0
break
case 'boolean':
newItem[field.name] = false
break
case 'array':
newItem[field.name] = []
break
default:
newItem[field.name] = ''
}
}
newItem[field.name] = getDefaultValueForType(field)
}
}

Expand Down
16 changes: 2 additions & 14 deletions packages/agentflow/src/atoms/ConditionBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Box, Button, Chip, IconButton, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconPlus, IconTrash } from '@tabler/icons-react'

import { getDefaultValueForType } from '@/core/primitives'
import type { InputParam, NodeData } from '@/core/types'

import { NodeInputHandler } from './NodeInputHandler'
Expand Down Expand Up @@ -58,20 +59,7 @@ export function ConditionBuilder({
const newItem: Record<string, unknown> = {}
if (inputParam.array) {
for (const field of inputParam.array) {
if (field.default != null) {
newItem[field.name] = field.default
} else {
switch (field.type) {
case 'number':
newItem[field.name] = 0
break
case 'boolean':
newItem[field.name] = false
break
default:
newItem[field.name] = ''
}
}
newItem[field.name] = getDefaultValueForType(field)
}
}
onDataChange?.({ inputParam, newValue: [...arrayItems, newItem] })
Expand Down
119 changes: 119 additions & 0 deletions packages/agentflow/src/atoms/CredentialTypeSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { fireEvent, render, screen } from '@testing-library/react'

import type { ComponentCredentialSchema } from '@/core/types'

import { CredentialTypeSelector } from './CredentialTypeSelector'

// ─── Mocks ────────────────────────────────────────────────────────────────────

jest.mock('@tabler/icons-react', () => ({
IconKey: () => <span data-testid='icon-key-fallback' />,
IconSearch: () => <span data-testid='icon-search' />,
IconX: (props: { onClick?: () => void }) => <button data-testid='icon-x' onClick={props.onClick} />
}))

// ─── Fixtures ─────────────────────────────────────────────────────────────────

const schemas: ComponentCredentialSchema[] = [
{ label: 'HTTP Basic Auth', name: 'httpBasicAuth', inputs: [] },
{ label: 'HTTP Bearer Token', name: 'httpBearerToken', inputs: [] },
{ label: 'HTTP Api Key', name: 'httpApiKey', inputs: [] }
]

const apiBaseUrl = 'http://localhost:3000'

// ─── Tests ────────────────────────────────────────────────────────────────────

describe('CredentialTypeSelector', () => {
it('renders search input with placeholder', () => {
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)

expect(screen.getByPlaceholderText('Search credential')).toBeInTheDocument()
})

it('renders all credential cards with labels and icons', () => {
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)

expect(screen.getByText('HTTP Basic Auth')).toBeInTheDocument()
expect(screen.getByText('HTTP Bearer Token')).toBeInTheDocument()
expect(screen.getByText('HTTP Api Key')).toBeInTheDocument()

const images = screen.getAllByRole('img')
expect(images).toHaveLength(3)
expect(images[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/components-credentials-icon/httpBasicAuth')
expect(images[0]).toHaveAttribute('alt', 'httpBasicAuth')
})

it('calls onSelect with the clicked schema', () => {
const onSelect = jest.fn()
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={onSelect} />)

fireEvent.click(screen.getByText('HTTP Bearer Token'))

expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(schemas[1])
})

it('filters schemas by search input', () => {
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)

const searchInput = screen.getByPlaceholderText('Search credential')
fireEvent.change(searchInput, { target: { value: 'bearer' } })

expect(screen.getByText('HTTP Bearer Token')).toBeInTheDocument()
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
expect(screen.queryByText('HTTP Api Key')).not.toBeInTheDocument()
})

it('search is case-insensitive', () => {
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)

fireEvent.change(screen.getByPlaceholderText('Search credential'), { target: { value: 'API' } })

expect(screen.getByText('HTTP Api Key')).toBeInTheDocument()
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
})

it('clears search when clear button is clicked', () => {
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)

const searchInput = screen.getByPlaceholderText('Search credential')
fireEvent.change(searchInput, { target: { value: 'bearer' } })

expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()

fireEvent.click(screen.getByTestId('icon-x'))

expect(screen.getByText('HTTP Basic Auth')).toBeInTheDocument()
expect(screen.getByText('HTTP Bearer Token')).toBeInTheDocument()
expect(screen.getByText('HTTP Api Key')).toBeInTheDocument()
})

it('shows no cards when search matches nothing', () => {
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)

fireEvent.change(screen.getByPlaceholderText('Search credential'), { target: { value: 'nonexistent' } })

expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
expect(screen.queryByText('HTTP Bearer Token')).not.toBeInTheDocument()
expect(screen.queryByText('HTTP Api Key')).not.toBeInTheDocument()
expect(screen.queryAllByRole('img')).toHaveLength(0)
})

it('renders empty list when schemas is empty', () => {
render(<CredentialTypeSelector schemas={[]} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)

expect(screen.getByPlaceholderText('Search credential')).toBeInTheDocument()
expect(screen.queryAllByRole('img')).toHaveLength(0)
})

it('shows fallback key icon when credential icon fails to load', () => {
render(<CredentialTypeSelector schemas={[schemas[0]]} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)

const img = screen.getByAltText('httpBasicAuth')
fireEvent.error(img)

expect(screen.queryByAltText('httpBasicAuth')).not.toBeInTheDocument()
expect(screen.getByTestId('icon-key-fallback')).toBeInTheDocument()
})
})
129 changes: 129 additions & 0 deletions packages/agentflow/src/atoms/CredentialTypeSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { type SyntheticEvent, useState } from 'react'

import { Box, InputAdornment, List, ListItemButton, OutlinedInput, Typography } from '@mui/material'
import { alpha, useTheme } from '@mui/material/styles'
import { IconKey, IconSearch, IconX } from '@tabler/icons-react'

import type { ComponentCredentialSchema } from '@/core/types'

export interface CredentialTypeSelectorProps {
schemas: ComponentCredentialSchema[]
apiBaseUrl: string
onSelect: (schema: ComponentCredentialSchema) => void
}

/**
* Search + grid selector for choosing a credential type.
* Renders a search bar and a 3-column grid of credential cards with icons.
*/
export function CredentialTypeSelector({ schemas, apiBaseUrl, onSelect }: CredentialTypeSelectorProps) {
const theme = useTheme()
const [searchValue, setSearchValue] = useState('')

const filtered = schemas.filter((s) => s.label.toLowerCase().includes(searchValue.toLowerCase()))

return (
<>
<Box sx={{ backgroundColor: theme.palette.background.paper, pt: 2, position: 'sticky', top: 0, zIndex: 10 }}>
<OutlinedInput
sx={{ width: '100%', pr: 2, pl: 2 }}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder='Search credential'
startAdornment={
<InputAdornment position='start'>
<IconSearch stroke={1.5} size='1rem' color={theme.palette.grey[500]} />
</InputAdornment>
}
endAdornment={
<InputAdornment
position='end'
sx={{ cursor: 'pointer', color: theme.palette.grey[500], '&:hover': { color: theme.palette.grey[900] } }}
title='Clear Search'
>
<IconX stroke={1.5} size='1rem' onClick={() => setSearchValue('')} style={{ cursor: 'pointer' }} />
</InputAdornment>
}
/>
</Box>
<List
sx={{
width: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 2,
py: 0,
zIndex: 9,
borderRadius: '10px',
[theme.breakpoints.down('md')]: {
maxWidth: 370
}
}}
>
{filtered.map((schema) => (
<ListItemButton
key={schema.name}
onClick={() => onSelect(schema)}
sx={{
border: 1,
borderColor: alpha(theme.palette.grey[900], 0.25),
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'start',
textAlign: 'left',
gap: 1,
p: 2
}}
>
<CredentialIcon name={schema.name} apiBaseUrl={apiBaseUrl} />
<Typography>{schema.label}</Typography>
</ListItemButton>
))}
</List>
</>
)
}

/** Circular credential icon with fallback to a key icon on load error. */
export function CredentialIcon({ name, apiBaseUrl }: { name: string; apiBaseUrl: string }) {
const theme = useTheme()
const [failed, setFailed] = useState(false)

const handleError = (e: SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.onerror = null
setFailed(true)
}

return (
<div
style={{
width: 50,
height: 50,
borderRadius: '50%',
backgroundColor: theme.palette.common.white,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{failed ? (
<IconKey size={30} stroke={1.5} />
) : (
<img
style={{
width: '100%',
height: '100%',
padding: 7,
borderRadius: '50%',
objectFit: 'contain'
}}
alt={name}
src={`${apiBaseUrl}/api/v1/components-credentials-icon/${name}`}
onError={handleError}
/>
)}
</div>
)
}
Loading