mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
feat(agentflow): enhance typography and improve node palette drawer (#5806)
* feat(agentflow): enhance typography and improve node palette drawer functionality - Add typography settings for h4, h5, and h6 in the theme configuration. - Implement tests for typography to ensure consistency across light and dark modes. - Refactor AddNodesDrawer to improve drag-and-drop functionality and integrate new NodeListItem component. - Introduce useDrawerMaxHeight hook to dynamically calculate drawer height based on viewport. - Clean up unused imports and optimize component structure for better performance. * fix lint issues * address review feeddback
This commit is contained in:
@@ -127,6 +127,32 @@ describe('createAgentflowTheme', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Typography', () => {
|
||||
const theme = createAgentflowTheme(false)
|
||||
|
||||
it('should set h4 to 1rem / 600 weight', () => {
|
||||
expect(theme.typography.h4.fontSize).toBe('1rem')
|
||||
expect(theme.typography.h4.fontWeight).toBe(600)
|
||||
})
|
||||
|
||||
it('should set h5 to 0.875rem / 600 weight', () => {
|
||||
expect(theme.typography.h5.fontSize).toBe('0.875rem')
|
||||
expect(theme.typography.h5.fontWeight).toBe(600)
|
||||
})
|
||||
|
||||
it('should set h6 to 0.75rem / 500 weight', () => {
|
||||
expect(theme.typography.h6.fontSize).toBe('0.75rem')
|
||||
expect(theme.typography.h6.fontWeight).toBe(500)
|
||||
})
|
||||
|
||||
it('should have same typography in both modes', () => {
|
||||
const darkTheme = createAgentflowTheme(true)
|
||||
expect(theme.typography.h4).toEqual(darkTheme.typography.h4)
|
||||
expect(theme.typography.h5).toEqual(darkTheme.typography.h5)
|
||||
expect(theme.typography.h6).toEqual(darkTheme.typography.h6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MUI Theme Integration', () => {
|
||||
const theme = createAgentflowTheme(false)
|
||||
|
||||
|
||||
@@ -34,6 +34,14 @@ export function createAgentflowTheme(isDarkMode: boolean): Theme {
|
||||
main: tokens.colors.background.card[mode]
|
||||
}
|
||||
},
|
||||
typography: {
|
||||
h4: { fontSize: '1rem', fontWeight: 600 },
|
||||
h5: { fontSize: '0.875rem', fontWeight: 600 },
|
||||
h6: { fontSize: '0.75rem', fontWeight: 500 },
|
||||
subtitle1: { fontSize: '0.875rem', fontWeight: 500 },
|
||||
body1: { fontSize: '0.875rem', fontWeight: 400 },
|
||||
body2: { fontSize: '0.75rem', fontWeight: 400 }
|
||||
},
|
||||
spacing: 8, // MUI's default base unit
|
||||
shape: {
|
||||
borderRadius: tokens.borderRadius.md
|
||||
|
||||
@@ -6,16 +6,11 @@ import {
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
Chip,
|
||||
ClickAwayListener,
|
||||
Divider,
|
||||
Fade,
|
||||
InputAdornment,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
OutlinedInput,
|
||||
Paper,
|
||||
Popper,
|
||||
@@ -26,12 +21,16 @@ import { useTheme } from '@mui/material/styles'
|
||||
import { IconMinus, IconPlus, IconSearch, IconX } from '@tabler/icons-react'
|
||||
|
||||
import { MainCard } from '@/atoms'
|
||||
import { AGENTFLOW_ICONS } from '@/core'
|
||||
import { tokens } from '@/core/theme/tokens'
|
||||
import type { NodeData } from '@/core/types'
|
||||
import { useApiContext, useConfigContext } from '@/infrastructure/store'
|
||||
import { useApiContext } from '@/infrastructure/store'
|
||||
|
||||
import { NodeListItem } from './NodeListItem'
|
||||
import { debounce, groupNodesByCategory, searchNodes } from './search'
|
||||
import { StyledFab } from './StyledFab'
|
||||
import { useDrawerMaxHeight } from './useDrawerMaxHeight'
|
||||
|
||||
const Z_INDEX_DRAWER = 1000
|
||||
|
||||
export interface AddNodesDrawerProps {
|
||||
/** Available nodes to display */
|
||||
@@ -48,7 +47,6 @@ export interface AddNodesDrawerProps {
|
||||
function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDrawerProps) {
|
||||
const theme = useTheme()
|
||||
const { apiBaseUrl } = useApiContext()
|
||||
const { isDarkMode: _isDarkMode } = useConfigContext()
|
||||
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [filteredNodes, setFilteredNodes] = useState<Record<string, NodeData[]>>({})
|
||||
@@ -56,7 +54,9 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
|
||||
const [categoryExpanded, setCategoryExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
const anchorRef = useRef<HTMLButtonElement>(null)
|
||||
const paperRef = useRef<HTMLDivElement>(null)
|
||||
const prevOpen = useRef(open)
|
||||
const drawerMaxHeight = useDrawerMaxHeight(open, paperRef)
|
||||
|
||||
// Group nodes by category
|
||||
const groupNodes = useCallback((nodeList: NodeData[], expandAll = false) => {
|
||||
@@ -119,8 +119,6 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
|
||||
}
|
||||
|
||||
const handleDragStart = (event: React.DragEvent, node: NodeData) => {
|
||||
event.dataTransfer.setData('application/reactflow', JSON.stringify(node))
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
onDragStart?.(event, node)
|
||||
}
|
||||
|
||||
@@ -128,15 +126,6 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
|
||||
onNodeClick?.(node)
|
||||
}
|
||||
|
||||
const renderIcon = (node: NodeData) => {
|
||||
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === node.name)
|
||||
if (foundIcon) {
|
||||
const IconComponent = foundIcon.icon
|
||||
return <IconComponent size={30} color={node.color} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Initialize nodes on mount
|
||||
useEffect(() => {
|
||||
if (nodes.length > 0) {
|
||||
@@ -165,42 +154,51 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
|
||||
position: 'absolute',
|
||||
left: 20,
|
||||
top: 20,
|
||||
zIndex: 1000
|
||||
zIndex: Z_INDEX_DRAWER
|
||||
}}
|
||||
>
|
||||
{open ? <IconMinus /> : <IconPlus />}
|
||||
</StyledFab>
|
||||
|
||||
<Popper
|
||||
placement='bottom-start'
|
||||
placement='bottom-end'
|
||||
open={open}
|
||||
anchorEl={anchorRef.current}
|
||||
role={undefined}
|
||||
transition
|
||||
disablePortal
|
||||
modifiers={[
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 14]
|
||||
popperOptions={{
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [-40, 14]
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
sx={{ zIndex: 1000 }}
|
||||
]
|
||||
}}
|
||||
sx={{ zIndex: Z_INDEX_DRAWER }}
|
||||
>
|
||||
{({ TransitionProps }) => (
|
||||
<Fade {...TransitionProps} timeout={200}>
|
||||
<Paper elevation={16}>
|
||||
<Paper
|
||||
ref={paperRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: drawerMaxHeight,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<MainCard
|
||||
border={false}
|
||||
sx={{
|
||||
boxShadow: theme.shadows[16],
|
||||
width: 370,
|
||||
maxWidth: '90vw'
|
||||
}}
|
||||
content={false}
|
||||
boxShadow
|
||||
shadow={theme.shadows[16]}
|
||||
sx={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
|
||||
>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box sx={{ p: 2, flexShrink: 0 }}>
|
||||
<Stack>
|
||||
<Typography variant='h4'>Add Nodes</Typography>
|
||||
</Stack>
|
||||
@@ -243,7 +241,8 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 'calc(100vh - 300px)',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
@@ -252,8 +251,9 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
|
||||
<List
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 370,
|
||||
py: 0,
|
||||
borderRadius: '10px',
|
||||
borderRadius: tokens.borderRadius.lg,
|
||||
'& .MuiListItemSecondaryAction-root': {
|
||||
top: 22
|
||||
},
|
||||
@@ -284,104 +284,14 @@ function AddNodesDrawerComponent({ nodes, onDragStart, onNodeClick }: AddNodesDr
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ p: 0 }}>
|
||||
{filteredNodes[category].map((node, index) => (
|
||||
<div
|
||||
<NodeListItem
|
||||
key={node.name}
|
||||
onDragStart={(event) => handleDragStart(event, node)}
|
||||
draggable
|
||||
>
|
||||
<ListItemButton
|
||||
sx={{
|
||||
p: 0,
|
||||
borderRadius: '8px',
|
||||
cursor: 'grab',
|
||||
'&:active': { cursor: 'grabbing' }
|
||||
}}
|
||||
onClick={() => handleNodeClick(node)}
|
||||
>
|
||||
<ListItem alignItems='center'>
|
||||
{node.color && !node.icon ? (
|
||||
<ListItemAvatar>
|
||||
<div
|
||||
style={{
|
||||
width: 50,
|
||||
height: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{renderIcon(node)}
|
||||
</div>
|
||||
</ListItemAvatar>
|
||||
) : (
|
||||
<ListItemAvatar>
|
||||
<div
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: 10,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
alt={node.name}
|
||||
src={`${apiBaseUrl}/api/v1/node-icon/${node.name}`}
|
||||
/>
|
||||
</div>
|
||||
</ListItemAvatar>
|
||||
)}
|
||||
<ListItemText
|
||||
sx={{ ml: 1 }}
|
||||
primary={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<span>{node.label}</span>
|
||||
{typeof node.badge === 'string' &&
|
||||
node.badge && (
|
||||
<>
|
||||
|
||||
<Chip
|
||||
sx={{
|
||||
width: 'max-content',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.65rem',
|
||||
background:
|
||||
node.badge ===
|
||||
'DEPRECATING'
|
||||
? theme.palette
|
||||
.warning.main
|
||||
: theme.palette
|
||||
.success.main,
|
||||
color:
|
||||
node.badge !==
|
||||
'DEPRECATING'
|
||||
? 'white'
|
||||
: 'inherit'
|
||||
}}
|
||||
size='small'
|
||||
label={node.badge}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
secondary={node.description}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListItemButton>
|
||||
{index === filteredNodes[category].length - 1 ? null : <Divider />}
|
||||
</div>
|
||||
node={node}
|
||||
apiBaseUrl={apiBaseUrl}
|
||||
isLast={index === filteredNodes[category].length - 1}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={handleNodeClick}
|
||||
/>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { memo } from 'react'
|
||||
|
||||
import { Box, Chip, Divider, ListItem, ListItemAvatar, ListItemButton, ListItemText } from '@mui/material'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
|
||||
import { AGENTFLOW_ICONS } from '@/core'
|
||||
import { tokens } from '@/core/theme/tokens'
|
||||
import type { NodeData } from '@/core/types'
|
||||
|
||||
const NODE_ICON_SIZE = 30
|
||||
const NODE_AVATAR_SIZE = 50
|
||||
|
||||
interface NodeListItemProps {
|
||||
node: NodeData
|
||||
apiBaseUrl: string
|
||||
isLast: boolean
|
||||
onDragStart: (event: React.DragEvent, node: NodeData) => void
|
||||
onClick: (node: NodeData) => void
|
||||
}
|
||||
|
||||
function NodeListItemComponent({ node, apiBaseUrl, isLast, onDragStart, onClick }: NodeListItemProps) {
|
||||
const theme = useTheme()
|
||||
|
||||
const handleDragStart = (event: React.DragEvent) => {
|
||||
event.dataTransfer.setData('application/reactflow', JSON.stringify(node))
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
onDragStart(event, node)
|
||||
}
|
||||
|
||||
const renderIcon = () => {
|
||||
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === node.name)
|
||||
if (foundIcon) {
|
||||
const IconComponent = foundIcon.icon
|
||||
return <IconComponent size={NODE_ICON_SIZE} color={node.color} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box onDragStart={handleDragStart} draggable>
|
||||
<ListItemButton
|
||||
sx={{
|
||||
p: 0,
|
||||
borderRadius: tokens.borderRadius.md,
|
||||
cursor: 'grab',
|
||||
'&:active': { cursor: 'grabbing' }
|
||||
}}
|
||||
onClick={() => onClick(node)}
|
||||
>
|
||||
<ListItem alignItems='center'>
|
||||
{node.color && !node.icon ? (
|
||||
<ListItemAvatar>
|
||||
<Box
|
||||
sx={{
|
||||
width: NODE_AVATAR_SIZE,
|
||||
height: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{renderIcon()}
|
||||
</Box>
|
||||
</ListItemAvatar>
|
||||
) : (
|
||||
<ListItemAvatar>
|
||||
<Box
|
||||
sx={{
|
||||
width: NODE_AVATAR_SIZE,
|
||||
height: NODE_AVATAR_SIZE,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component='img'
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
p: '10px',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
alt={node.name}
|
||||
src={`${apiBaseUrl}/api/v1/node-icon/${node.name}`}
|
||||
/>
|
||||
</Box>
|
||||
</ListItemAvatar>
|
||||
)}
|
||||
<ListItemText
|
||||
sx={{ ml: 1 }}
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<span>{node.label}</span>
|
||||
{typeof node.badge === 'string' && node.badge && (
|
||||
<>
|
||||
|
||||
<Chip
|
||||
sx={{
|
||||
width: 'max-content',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.65rem',
|
||||
background:
|
||||
node.badge === 'DEPRECATING' ? theme.palette.warning.main : theme.palette.success.main,
|
||||
color: node.badge !== 'DEPRECATING' ? 'white' : 'inherit'
|
||||
}}
|
||||
size='small'
|
||||
label={node.badge}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={node.description}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListItemButton>
|
||||
{!isLast && <Divider />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const NodeListItem = memo(NodeListItemComponent)
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type RefObject, useEffect, useState } from 'react'
|
||||
|
||||
import { tokens } from '@/core/theme/tokens'
|
||||
|
||||
/**
|
||||
* Calculates the maximum height for a drawer based on its rendered position in the viewport.
|
||||
* Recalculates on window resize.
|
||||
*
|
||||
* @param open - Whether the drawer is currently open
|
||||
* @param ref - Ref to the element whose position is measured
|
||||
* @param bottomPadding - Padding from the viewport bottom (defaults to tokens.spacing.xxl)
|
||||
*/
|
||||
export function useDrawerMaxHeight(
|
||||
open: boolean,
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
bottomPadding = tokens.spacing.xxl
|
||||
): number | undefined {
|
||||
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && ref.current) {
|
||||
const update = () => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setMaxHeight(window.innerHeight - rect.top - bottomPadding)
|
||||
}
|
||||
}
|
||||
// Allow Popper to position first, then measure
|
||||
requestAnimationFrame(update)
|
||||
window.addEventListener('resize', update)
|
||||
return () => window.removeEventListener('resize', update)
|
||||
}
|
||||
setMaxHeight(undefined)
|
||||
}, [open, ref, bottomPadding])
|
||||
|
||||
return maxHeight
|
||||
}
|
||||
Reference in New Issue
Block a user