Skip to content

Commit 43a06b6

Browse files
authored
Merge branch 'main' into feature/audit-telemetry-gdpr-deletion
2 parents a99e6af + 02813aa commit 43a06b6

38 files changed

Lines changed: 2353 additions & 460 deletions

packages/agentflow/.eslintrc.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ module.exports = {
119119
{
120120
target: './src/atoms',
121121
from: './src/core',
122-
except: ['./types', './theme'],
123-
message: 'Atoms can only import from core/types and core/theme, not utilities or business logic.'
122+
except: ['./types', './theme', './primitives'],
123+
message: 'Atoms can only import from core/types, core/theme, and core/primitives.'
124124
},
125125
// core/ cannot import from anything (leaf node)
126126
{

packages/agentflow/ARCHITECTURE.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ atoms/
4242
- No API calls
4343
- Stateless or minimal local state
4444
- Imported by features, never the reverse
45-
- **Forbidden**: Importing from `features/` or `infrastructure/` (except types from `core/types` for prop definitions and design tokens from `core/theme`)
45+
- **Forbidden**: Importing from `features/` or `infrastructure/` (except types from `core/types` for prop definitions, design tokens from `core/theme`, and primitives from `core/primitives`)
4646

4747
**Goal:** 100% visual consistency.
4848

@@ -110,6 +110,9 @@ features/
110110
core/
111111
├── types/ # Global interfaces (Node, Edge, Flow)
112112
│ └── index.ts
113+
├── primitives/ # Domain-free utilities (safe for atoms)
114+
│ ├── inputDefaults.ts # getDefaultValueForType
115+
│ └── index.ts
113116
├── node-config/ # Node configuration (icons, colors, default types)
114117
│ ├── nodeIcons.ts # AGENTFLOW_ICONS, DEFAULT_AGENTFLOW_NODES
115118
│ └── ...
@@ -124,7 +127,7 @@ core/
124127
│ ├── flowValidation.ts # validateFlow, validateNode
125128
│ ├── connectionValidation.ts # isValidConnectionAgentflowV2
126129
│ └── ...
127-
├── utils/ # Generic utilities
130+
├── utils/ # Domain-aware utilities (NOT importable by atoms)
128131
│ ├── nodeFactory.ts # initNode, getUniqueNodeId
129132
│ └── ...
130133
└── index.ts # Barrel export (use sparingly)
@@ -138,6 +141,15 @@ core/
138141
- Pure functions where possible
139142
- Can be tested in isolation
140143

144+
#### `core/primitives/` vs `core/utils/`
145+
146+
`core/` contains two utility directories with different import permissions:
147+
148+
- **`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/`.**
149+
- **`utils/`** — Domain-aware utilities that understand node structures, flow data, or validation logic (e.g., `initNode`, `buildDynamicOutputAnchors`). **Only importable by `features/` and `infrastructure/`.**
150+
151+
When adding a new utility, ask: _"Does this function need to know what a Node or Flow is?"_ If no → `primitives/`. If yes → `utils/`.
152+
141153
**Goal:** To be the framework-agnostic source of truth.
142154

143155
---
@@ -210,7 +222,7 @@ infrastructure/
210222

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

packages/agentflow/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@
8080
"@uiw/codemirror-theme-vscode": "^4.21.0",
8181
"@uiw/react-codemirror": "^4.21.0",
8282
"axios": "^1.7.2",
83+
"dompurify": "^3.2.6",
8384
"flowise-react-json-view": "^1.21.7",
85+
"html-react-parser": "^3.0.16",
8486
"lodash": "^4.17.21",
8587
"lowlight": "^3.3.0",
8688
"uuid": "^10.0.0"

packages/agentflow/src/atoms/ArrayInput.tsx

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Box, Button, Chip, IconButton } from '@mui/material'
44
import { useTheme } from '@mui/material/styles'
55
import { IconPlus, IconTrash } from '@tabler/icons-react'
66

7+
import { getDefaultValueForType } from '@/core/primitives'
78
import type { InputParam, NodeData } from '@/core/types'
89

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

7677
if (inputParam.array) {
7778
for (const field of inputParam.array) {
78-
if (field.default !== undefined) {
79-
newItem[field.name] = field.default
80-
} else {
81-
switch (field.type) {
82-
case 'number':
83-
newItem[field.name] = 0
84-
break
85-
case 'boolean':
86-
newItem[field.name] = false
87-
break
88-
case 'array':
89-
newItem[field.name] = []
90-
break
91-
default:
92-
newItem[field.name] = ''
93-
}
94-
}
79+
newItem[field.name] = getDefaultValueForType(field)
9580
}
9681
}
9782

packages/agentflow/src/atoms/ConditionBuilder.tsx

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Box, Button, Chip, IconButton, Typography } from '@mui/material'
44
import { useTheme } from '@mui/material/styles'
55
import { IconPlus, IconTrash } from '@tabler/icons-react'
66

7+
import { getDefaultValueForType } from '@/core/primitives'
78
import type { InputParam, NodeData } from '@/core/types'
89

910
import { NodeInputHandler } from './NodeInputHandler'
@@ -58,20 +59,7 @@ export function ConditionBuilder({
5859
const newItem: Record<string, unknown> = {}
5960
if (inputParam.array) {
6061
for (const field of inputParam.array) {
61-
if (field.default != null) {
62-
newItem[field.name] = field.default
63-
} else {
64-
switch (field.type) {
65-
case 'number':
66-
newItem[field.name] = 0
67-
break
68-
case 'boolean':
69-
newItem[field.name] = false
70-
break
71-
default:
72-
newItem[field.name] = ''
73-
}
74-
}
62+
newItem[field.name] = getDefaultValueForType(field)
7563
}
7664
}
7765
onDataChange?.({ inputParam, newValue: [...arrayItems, newItem] })
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { fireEvent, render, screen } from '@testing-library/react'
2+
3+
import type { ComponentCredentialSchema } from '@/core/types'
4+
5+
import { CredentialTypeSelector } from './CredentialTypeSelector'
6+
7+
// ─── Mocks ────────────────────────────────────────────────────────────────────
8+
9+
jest.mock('@tabler/icons-react', () => ({
10+
IconKey: () => <span data-testid='icon-key-fallback' />,
11+
IconSearch: () => <span data-testid='icon-search' />,
12+
IconX: (props: { onClick?: () => void }) => <button data-testid='icon-x' onClick={props.onClick} />
13+
}))
14+
15+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
16+
17+
const schemas: ComponentCredentialSchema[] = [
18+
{ label: 'HTTP Basic Auth', name: 'httpBasicAuth', inputs: [] },
19+
{ label: 'HTTP Bearer Token', name: 'httpBearerToken', inputs: [] },
20+
{ label: 'HTTP Api Key', name: 'httpApiKey', inputs: [] }
21+
]
22+
23+
const apiBaseUrl = 'http://localhost:3000'
24+
25+
// ─── Tests ────────────────────────────────────────────────────────────────────
26+
27+
describe('CredentialTypeSelector', () => {
28+
it('renders search input with placeholder', () => {
29+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
30+
31+
expect(screen.getByPlaceholderText('Search credential')).toBeInTheDocument()
32+
})
33+
34+
it('renders all credential cards with labels and icons', () => {
35+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
36+
37+
expect(screen.getByText('HTTP Basic Auth')).toBeInTheDocument()
38+
expect(screen.getByText('HTTP Bearer Token')).toBeInTheDocument()
39+
expect(screen.getByText('HTTP Api Key')).toBeInTheDocument()
40+
41+
const images = screen.getAllByRole('img')
42+
expect(images).toHaveLength(3)
43+
expect(images[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/components-credentials-icon/httpBasicAuth')
44+
expect(images[0]).toHaveAttribute('alt', 'httpBasicAuth')
45+
})
46+
47+
it('calls onSelect with the clicked schema', () => {
48+
const onSelect = jest.fn()
49+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={onSelect} />)
50+
51+
fireEvent.click(screen.getByText('HTTP Bearer Token'))
52+
53+
expect(onSelect).toHaveBeenCalledTimes(1)
54+
expect(onSelect).toHaveBeenCalledWith(schemas[1])
55+
})
56+
57+
it('filters schemas by search input', () => {
58+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
59+
60+
const searchInput = screen.getByPlaceholderText('Search credential')
61+
fireEvent.change(searchInput, { target: { value: 'bearer' } })
62+
63+
expect(screen.getByText('HTTP Bearer Token')).toBeInTheDocument()
64+
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
65+
expect(screen.queryByText('HTTP Api Key')).not.toBeInTheDocument()
66+
})
67+
68+
it('search is case-insensitive', () => {
69+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
70+
71+
fireEvent.change(screen.getByPlaceholderText('Search credential'), { target: { value: 'API' } })
72+
73+
expect(screen.getByText('HTTP Api Key')).toBeInTheDocument()
74+
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
75+
})
76+
77+
it('clears search when clear button is clicked', () => {
78+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
79+
80+
const searchInput = screen.getByPlaceholderText('Search credential')
81+
fireEvent.change(searchInput, { target: { value: 'bearer' } })
82+
83+
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
84+
85+
fireEvent.click(screen.getByTestId('icon-x'))
86+
87+
expect(screen.getByText('HTTP Basic Auth')).toBeInTheDocument()
88+
expect(screen.getByText('HTTP Bearer Token')).toBeInTheDocument()
89+
expect(screen.getByText('HTTP Api Key')).toBeInTheDocument()
90+
})
91+
92+
it('shows no cards when search matches nothing', () => {
93+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
94+
95+
fireEvent.change(screen.getByPlaceholderText('Search credential'), { target: { value: 'nonexistent' } })
96+
97+
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
98+
expect(screen.queryByText('HTTP Bearer Token')).not.toBeInTheDocument()
99+
expect(screen.queryByText('HTTP Api Key')).not.toBeInTheDocument()
100+
expect(screen.queryAllByRole('img')).toHaveLength(0)
101+
})
102+
103+
it('renders empty list when schemas is empty', () => {
104+
render(<CredentialTypeSelector schemas={[]} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
105+
106+
expect(screen.getByPlaceholderText('Search credential')).toBeInTheDocument()
107+
expect(screen.queryAllByRole('img')).toHaveLength(0)
108+
})
109+
110+
it('shows fallback key icon when credential icon fails to load', () => {
111+
render(<CredentialTypeSelector schemas={[schemas[0]]} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
112+
113+
const img = screen.getByAltText('httpBasicAuth')
114+
fireEvent.error(img)
115+
116+
expect(screen.queryByAltText('httpBasicAuth')).not.toBeInTheDocument()
117+
expect(screen.getByTestId('icon-key-fallback')).toBeInTheDocument()
118+
})
119+
})
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { type SyntheticEvent, useState } from 'react'
2+
3+
import { Box, InputAdornment, List, ListItemButton, OutlinedInput, Typography } from '@mui/material'
4+
import { alpha, useTheme } from '@mui/material/styles'
5+
import { IconKey, IconSearch, IconX } from '@tabler/icons-react'
6+
7+
import type { ComponentCredentialSchema } from '@/core/types'
8+
9+
export interface CredentialTypeSelectorProps {
10+
schemas: ComponentCredentialSchema[]
11+
apiBaseUrl: string
12+
onSelect: (schema: ComponentCredentialSchema) => void
13+
}
14+
15+
/**
16+
* Search + grid selector for choosing a credential type.
17+
* Renders a search bar and a 3-column grid of credential cards with icons.
18+
*/
19+
export function CredentialTypeSelector({ schemas, apiBaseUrl, onSelect }: CredentialTypeSelectorProps) {
20+
const theme = useTheme()
21+
const [searchValue, setSearchValue] = useState('')
22+
23+
const filtered = schemas.filter((s) => s.label.toLowerCase().includes(searchValue.toLowerCase()))
24+
25+
return (
26+
<>
27+
<Box sx={{ backgroundColor: theme.palette.background.paper, pt: 2, position: 'sticky', top: 0, zIndex: 10 }}>
28+
<OutlinedInput
29+
sx={{ width: '100%', pr: 2, pl: 2 }}
30+
value={searchValue}
31+
onChange={(e) => setSearchValue(e.target.value)}
32+
placeholder='Search credential'
33+
startAdornment={
34+
<InputAdornment position='start'>
35+
<IconSearch stroke={1.5} size='1rem' color={theme.palette.grey[500]} />
36+
</InputAdornment>
37+
}
38+
endAdornment={
39+
<InputAdornment
40+
position='end'
41+
sx={{ cursor: 'pointer', color: theme.palette.grey[500], '&:hover': { color: theme.palette.grey[900] } }}
42+
title='Clear Search'
43+
>
44+
<IconX stroke={1.5} size='1rem' onClick={() => setSearchValue('')} style={{ cursor: 'pointer' }} />
45+
</InputAdornment>
46+
}
47+
/>
48+
</Box>
49+
<List
50+
sx={{
51+
width: '100%',
52+
display: 'grid',
53+
gridTemplateColumns: 'repeat(3, 1fr)',
54+
gap: 2,
55+
py: 0,
56+
zIndex: 9,
57+
borderRadius: '10px',
58+
[theme.breakpoints.down('md')]: {
59+
maxWidth: 370
60+
}
61+
}}
62+
>
63+
{filtered.map((schema) => (
64+
<ListItemButton
65+
key={schema.name}
66+
onClick={() => onSelect(schema)}
67+
sx={{
68+
border: 1,
69+
borderColor: alpha(theme.palette.grey[900], 0.25),
70+
borderRadius: 2,
71+
display: 'flex',
72+
alignItems: 'center',
73+
justifyContent: 'start',
74+
textAlign: 'left',
75+
gap: 1,
76+
p: 2
77+
}}
78+
>
79+
<CredentialIcon name={schema.name} apiBaseUrl={apiBaseUrl} />
80+
<Typography>{schema.label}</Typography>
81+
</ListItemButton>
82+
))}
83+
</List>
84+
</>
85+
)
86+
}
87+
88+
/** Circular credential icon with fallback to a key icon on load error. */
89+
export function CredentialIcon({ name, apiBaseUrl }: { name: string; apiBaseUrl: string }) {
90+
const theme = useTheme()
91+
const [failed, setFailed] = useState(false)
92+
93+
const handleError = (e: SyntheticEvent<HTMLImageElement>) => {
94+
e.currentTarget.onerror = null
95+
setFailed(true)
96+
}
97+
98+
return (
99+
<div
100+
style={{
101+
width: 50,
102+
height: 50,
103+
borderRadius: '50%',
104+
backgroundColor: theme.palette.common.white,
105+
flexShrink: 0,
106+
display: 'flex',
107+
alignItems: 'center',
108+
justifyContent: 'center'
109+
}}
110+
>
111+
{failed ? (
112+
<IconKey size={30} stroke={1.5} />
113+
) : (
114+
<img
115+
style={{
116+
width: '100%',
117+
height: '100%',
118+
padding: 7,
119+
borderRadius: '50%',
120+
objectFit: 'contain'
121+
}}
122+
alt={name}
123+
src={`${apiBaseUrl}/api/v1/components-credentials-icon/${name}`}
124+
onError={handleError}
125+
/>
126+
)}
127+
</div>
128+
)
129+
}

0 commit comments

Comments
 (0)