diff --git a/frontend/src/components/shared/TextInput.tsx b/frontend/src/components/shared/TextInput.tsx index fc3e99015..a5dd90569 100644 --- a/frontend/src/components/shared/TextInput.tsx +++ b/frontend/src/components/shared/TextInput.tsx @@ -31,6 +31,8 @@ export interface TextInputProps { readOnly?: boolean; /** Accessibility label */ 'aria-label'?: string; + /** Focus event handler */ + onFocus?: () => void; } export const TextInput = forwardRef(({ @@ -46,6 +48,7 @@ export const TextInput = forwardRef(({ disabled = false, readOnly = false, 'aria-label': ariaLabel, + onFocus, ...props }, ref) => { const { colorScheme } = useMantineColorScheme(); @@ -63,7 +66,7 @@ export const TextInput = forwardRef(({ return (
{icon && ( - @@ -81,6 +84,7 @@ export const TextInput = forwardRef(({ disabled={disabled} readOnly={readOnly} aria-label={ariaLabel} + onFocus={onFocus} style={{ backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF', color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382', diff --git a/frontend/src/components/tools/automate/AutomationCreation.tsx b/frontend/src/components/tools/automate/AutomationCreation.tsx index 49b12c396..ee301ae8e 100644 --- a/frontend/src/components/tools/automate/AutomationCreation.tsx +++ b/frontend/src/components/tools/automate/AutomationCreation.tsx @@ -98,7 +98,7 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o const saveAutomation = async () => { if (!canSaveAutomation()) return; - const automation = { + const automationData = { name: automationName.trim(), description: '', operations: selectedTools.map(tool => ({ @@ -109,7 +109,30 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o try { const { automationStorage } = await import('../../../services/automationStorage'); - const savedAutomation = await automationStorage.saveAutomation(automation); + let savedAutomation; + + if (mode === AutomationMode.EDIT && existingAutomation) { + // For edit mode, check if name has changed + const nameChanged = automationName.trim() !== existingAutomation.name; + + if (nameChanged) { + // Name changed - create new automation + savedAutomation = await automationStorage.saveAutomation(automationData); + } else { + // Name unchanged - update existing automation + const updatedAutomation = { + ...existingAutomation, + ...automationData, + id: existingAutomation.id, + createdAt: existingAutomation.createdAt + }; + savedAutomation = await automationStorage.updateAutomation(updatedAutomation); + } + } else { + // Create mode - always create new automation + savedAutomation = await automationStorage.saveAutomation(automationData); + } + onComplete(savedAutomation); } catch (error) { console.error('Error saving automation:', error); diff --git a/frontend/src/components/tools/automate/ToolList.tsx b/frontend/src/components/tools/automate/ToolList.tsx index 8b24b5c17..b11140ac5 100644 --- a/frontend/src/components/tools/automate/ToolList.tsx +++ b/frontend/src/components/tools/automate/ToolList.tsx @@ -1,14 +1,13 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Text, Stack, Group, ActionIcon } from '@mantine/core'; -import DeleteIcon from '@mui/icons-material/Delete'; -import SettingsIcon from '@mui/icons-material/Settings'; -import CloseIcon from '@mui/icons-material/Close'; -import AddCircleOutline from '@mui/icons-material/AddCircleOutline'; -import { AutomationTool } from '../../../types/automation'; -import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; -import ToolSelector from './ToolSelector'; -import AutomationEntry from './AutomationEntry'; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Text, Stack, Group, ActionIcon } from "@mantine/core"; +import SettingsIcon from "@mui/icons-material/Settings"; +import CloseIcon from "@mui/icons-material/Close"; +import AddCircleOutline from "@mui/icons-material/AddCircleOutline"; +import { AutomationTool } from "../../../types/automation"; +import { ToolRegistryEntry } from "../../../data/toolsTaxonomy"; +import ToolSelector from "./ToolSelector"; +import AutomationEntry from "./AutomationEntry"; interface ToolListProps { tools: AutomationTool[]; @@ -29,35 +28,39 @@ export default function ToolList({ onToolConfigure, onToolAdd, getToolName, - getToolDefaultParameters + getToolDefaultParameters, }: ToolListProps) { const { t } = useTranslation(); const handleToolSelect = (index: number, newOperation: string) => { const defaultParams = getToolDefaultParameters(newOperation); - + onToolUpdate(index, { operation: newOperation, name: getToolName(newOperation), configured: false, - parameters: defaultParams + parameters: defaultParams, }); }; return (
- - {t('automate.creation.tools.selected', 'Selected Tools')} ({tools.length}) + + {t("automate.creation.tools.selected", "Selected Tools")} ({tools.length}) {tools.map((tool, index) => (
{/* Delete X in top right */} @@ -65,26 +68,26 @@ export default function ToolList({ variant="subtle" size="xs" onClick={() => onToolRemove(index)} - title={t('automate.creation.tools.remove', 'Remove tool')} + title={t("automate.creation.tools.remove", "Remove tool")} style={{ - position: 'absolute', - top: '4px', - right: '4px', + position: "absolute", + top: "4px", + right: "4px", zIndex: 1, - color: 'var(--mantine-color-gray-6)' + color: "var(--mantine-color-gray-6)", }} > - + -
+
{/* Tool Selection Dropdown with inline settings cog */}
handleToolSelect(index, newOperation)} - excludeTools={['automate']} + excludeTools={["automate"]} toolRegistry={toolRegistry} selectedValue={tool.operation} placeholder={tool.name} @@ -97,26 +100,37 @@ export default function ToolList({ variant="subtle" size="sm" onClick={() => onToolConfigure(index)} - title={t('automate.creation.tools.configure', 'Configure tool')} - style={{ color: 'var(--mantine-color-gray-6)' }} + title={t("automate.creation.tools.configure", "Configure tool")} + style={{ color: "var(--mantine-color-gray-6)" }} > )} - - {/* Configuration status underneath */} - {tool.operation && !tool.configured && ( - - {t('automate.creation.tools.notConfigured', "! Not Configured")} - - )}
- + {/* Configuration status underneath */} + {tool.operation && !tool.configured && ( +
+ + {t("automate.creation.tools.notConfigured", "! Not Configured")} + +
+ )} {index < tools.length - 1 && ( -
- +
+ + ↓ +
)} @@ -124,19 +138,23 @@ export default function ToolList({ {/* Arrow before Add Tool Button */} {tools.length > 0 && ( -
- +
+ + ↓ +
)} {/* Add Tool Button */} -
+
); -} \ No newline at end of file +} diff --git a/frontend/src/components/tools/automate/ToolSelector.tsx b/frontend/src/components/tools/automate/ToolSelector.tsx index 80b68b0a4..4fb87548f 100644 --- a/frontend/src/components/tools/automate/ToolSelector.tsx +++ b/frontend/src/components/tools/automate/ToolSelector.tsx @@ -1,10 +1,11 @@ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Menu, Stack, Text, ScrollArea } from '@mantine/core'; +import { Stack, Text, ScrollArea } from '@mantine/core'; import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; import { useToolSections } from '../../../hooks/useToolSections'; import { renderToolButtons } from '../shared/renderToolButtons'; import ToolSearch from '../toolPicker/ToolSearch'; +import ToolButton from '../toolPicker/ToolButton'; interface ToolSelectorProps { onSelect: (toolKey: string) => void; @@ -24,6 +25,8 @@ export default function ToolSelector({ const { t } = useTranslation(); const [opened, setOpened] = useState(false); const [searchTerm, setSearchTerm] = useState(''); + const [shouldAutoFocus, setShouldAutoFocus] = useState(false); + const containerRef = useRef(null); // Filter out excluded tools (like 'automate' itself) const baseFilteredTools = useMemo(() => { @@ -66,13 +69,21 @@ export default function ToolSelector({ } if (!sections || sections.length === 0) { + // If no sections, create a simple group from filtered tools + if (baseFilteredTools.length > 0) { + return [{ + name: 'All Tools', + subcategoryId: 'all' as any, + tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool })) + }]; + } return []; } // Find the "all" section which contains all tools without duplicates const allSection = sections.find(s => (s as any).key === 'all'); return allSection?.subcategories || []; - }, [isSearching, searchGroups, sections]); + }, [isSearching, searchGroups, sections, baseFilteredTools]); const handleToolSelect = useCallback((toolKey: string) => { onSelect(toolKey); @@ -88,8 +99,25 @@ export default function ToolSelector({ const handleSearchFocus = () => { setOpened(true); + setShouldAutoFocus(true); // Request auto-focus for the input }; + // Handle click outside to close dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setOpened(false); + setSearchTerm(''); + } + }; + + if (opened) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [opened]); + + const handleSearchChange = (value: string) => { setSearchTerm(value); if (!opened) { @@ -97,6 +125,14 @@ export default function ToolSelector({ } }; + const handleInputFocus = () => { + if (!opened) { + setOpened(true); + } + // Clear auto-focus flag since input is now focused + setShouldAutoFocus(false); + }; + // Get display value for selected tool const getDisplayValue = () => { if (selectedValue && toolRegistry[selectedValue]) { @@ -106,77 +142,63 @@ export default function ToolSelector({ }; return ( -
- { - setOpened(isOpen); - // Clear search term when menu closes to show proper display - if (!isOpen) { - setSearchTerm(''); - } - }} - closeOnClickOutside={true} - closeOnEscape={true} - position="bottom-start" - offset={4} - withinPortal={false} - trapFocus={false} - shadow="sm" - transitionProps={{ duration: 0 }} - > - -
- {selectedValue && toolRegistry[selectedValue] && !opened ? ( - // Show selected tool in AutomationEntry style when tool is selected and not searching -
-
-
- {toolRegistry[selectedValue].icon} -
- - {toolRegistry[selectedValue].name} - -
-
- ) : ( - // Show search input when no tool selected or actively searching - - )} -
-
+
+ {/* Always show the target - either selected tool or search input */} - - - - {displayGroups.length === 0 ? ( - - {isSearching - ? t('tools.noSearchResults', 'No tools found') - : t('tools.noTools', 'No tools available') - } - - ) : ( - renderedTools - )} - - - -
+ {selectedValue && toolRegistry[selectedValue] && !opened ? ( + // Show selected tool in AutomationEntry style when tool is selected and dropdown closed +
+ {}} rounded={true}> +
+ ) : ( + // Show search input when no tool selected OR when dropdown is opened + + )} + + {/* Custom dropdown */} + {opened && ( +
+ + + {displayGroups.length === 0 ? ( + + {isSearching + ? t('tools.noSearchResults', 'No tools found') + : t('tools.noTools', 'No tools available') + } + + ) : ( + renderedTools + )} + + +
+ )}
); } diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index 66bd9489e..185eed5ed 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -9,9 +9,10 @@ interface ToolButtonProps { tool: ToolRegistryEntry; isSelected: boolean; onSelect: (id: string) => void; + rounded?: boolean; } -const ToolButton: React.FC = ({ id, tool, isSelected, onSelect }) => { +const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, rounded = false }) => { const handleClick = (id: string) => { if (tool.link) { // Open external link in new tab @@ -33,7 +34,17 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect fullWidth justify="flex-start" className="tool-button" - styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }} + styles={{ + root: { + borderRadius: rounded ? 'var(--mantine-radius-lg)' : 0, + color: "var(--tools-text-and-icon-color)", + ...(rounded && { + '&:hover': { + borderRadius: 'var(--mantine-radius-lg)', + } + }) + } + }} > void; toolRegistry: Readonly>; onToolSelect?: (toolId: string) => void; - mode: 'filter' | 'dropdown'; + mode: "filter" | "dropdown" | "unstyled"; selectedToolKey?: string | null; placeholder?: string; hideIcon?: boolean; onFocus?: () => void; + autoFocus?: boolean; } const ToolSearch = ({ @@ -23,11 +24,12 @@ const ToolSearch = ({ onChange, toolRegistry, onToolSelect, - mode = 'filter', + mode = "filter", selectedToolKey, placeholder, hideIcon = false, - onFocus + onFocus, + autoFocus = false, }: ToolSearchProps) => { const { t } = useTranslation(); const [dropdownOpen, setDropdownOpen] = useState(false); @@ -38,9 +40,10 @@ const ToolSearch = ({ if (!value.trim()) return []; return Object.entries(toolRegistry) .filter(([id, tool]) => { - if (mode === 'dropdown' && id === selectedToolKey) return false; - return tool.name.toLowerCase().includes(value.toLowerCase()) || - tool.description.toLowerCase().includes(value.toLowerCase()); + if (mode === "dropdown" && id === selectedToolKey) return false; + return ( + tool.name.toLowerCase().includes(value.toLowerCase()) || tool.description.toLowerCase().includes(value.toLowerCase()) + ); }) .slice(0, 6) .map(([id, tool]) => ({ id, tool })); @@ -48,7 +51,7 @@ const ToolSearch = ({ const handleSearchChange = (searchValue: string) => { onChange(searchValue); - if (mode === 'dropdown') { + if (mode === "dropdown") { setDropdownOpen(searchValue.trim().length > 0 && filteredTools.length > 0); } }; @@ -64,12 +67,20 @@ const ToolSearch = ({ setDropdownOpen(false); } }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + // Auto-focus the input when requested + useEffect(() => { + if (autoFocus && searchRef.current) { + setTimeout(() => { + searchRef.current?.focus(); + }, 10); + } + }, [autoFocus]); + const searchInput = ( -
} autoComplete="off" - + onFocus={onFocus} /> -
); - if (mode === 'filter') { + if (mode === "filter") { + return
{searchInput}
; + } + + if (mode === "unstyled") { return searchInput; } return ( -
+
{searchInput} {dropdownOpen && filteredTools.length > 0 && (
- + {filteredTools.map(({ id, tool }) => (
- } + leftSection={
{tool.icon}
} fullWidth justify="flex-start" style={{ - borderRadius: '6px', - color: 'var(--tools-text-and-icon-color)', - padding: '8px 12px' + borderRadius: "6px", + color: "var(--tools-text-and-icon-color)", + padding: "8px 12px", }} > -
+
{tool.name}
- + {tool.description}
diff --git a/frontend/src/hooks/tools/automate/useAutomationForm.ts b/frontend/src/hooks/tools/automate/useAutomationForm.ts index 11464a329..7bbe14d9b 100644 --- a/frontend/src/hooks/tools/automate/useAutomationForm.ts +++ b/frontend/src/hooks/tools/automate/useAutomationForm.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation'; import { AUTOMATION_CONSTANTS } from '../../../constants/automation'; @@ -16,18 +16,18 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us const [automationName, setAutomationName] = useState(''); const [selectedTools, setSelectedTools] = useState([]); - const getToolName = (operation: string) => { + const getToolName = useCallback((operation: string) => { const tool = toolRegistry?.[operation] as any; return tool?.name || t(`tools.${operation}.name`, operation); - }; + }, [toolRegistry, t]); - const getToolDefaultParameters = (operation: string): Record => { + const getToolDefaultParameters = useCallback((operation: string): Record => { const config = toolRegistry[operation]?.operationConfig; if (config?.defaultParameters) { return { ...config.defaultParameters }; } return {}; - }; + }, [toolRegistry]); // Initialize based on mode and existing automation useEffect(() => { @@ -58,7 +58,7 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us })); setSelectedTools(defaultTools); } - }, [mode, existingAutomation, selectedTools.length, t, getToolName]); + }, [mode, existingAutomation, t, getToolName]); const addTool = (operation: string) => { const newTool: AutomationTool = { diff --git a/frontend/src/tools/Automate.tsx b/frontend/src/tools/Automate.tsx index 7e75f0cbf..444cdfce9 100644 --- a/frontend/src/tools/Automate.tsx +++ b/frontend/src/tools/Automate.tsx @@ -14,7 +14,7 @@ import { useAutomateOperation } from "../hooks/tools/automate/useAutomateOperati import { BaseToolProps } from "../types/tool"; import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry"; import { useSavedAutomations } from "../hooks/tools/automate/useSavedAutomations"; -import { AutomationConfig, AutomationStepData, AutomationMode } from "../types/automation"; +import { AutomationConfig, AutomationStepData, AutomationMode, AutomationStep } from "../types/automation"; import { AUTOMATION_STEPS } from "../constants/automation"; const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { @@ -22,7 +22,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { selectedFiles } = useFileSelection(); const { setMode } = useNavigation(); - const [currentStep, setCurrentStep] = useState<'selection' | 'creation' | 'run'>(AUTOMATION_STEPS.SELECTION); + const [currentStep, setCurrentStep] = useState(AUTOMATION_STEPS.SELECTION); const [stepData, setStepData] = useState({ step: AUTOMATION_STEPS.SELECTION }); const automateOperation = useAutomateOperation(); @@ -64,7 +64,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const renderCurrentStep = () => { switch (currentStep) { - case 'selection': + case AUTOMATION_STEPS.SELECTION: return ( { /> ); - case 'creation': + case AUTOMATION_STEPS.CREATION: if (!stepData.mode) { console.error('Creation mode is undefined'); return null; @@ -100,7 +100,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { /> ); - case 'run': + case AUTOMATION_STEPS.RUN: if (!stepData.automation) { console.error('Automation config is undefined'); return null; diff --git a/frontend/src/types/automation.ts b/frontend/src/types/automation.ts index ffbfe36b2..8d2cb5ae8 100644 --- a/frontend/src/types/automation.ts +++ b/frontend/src/types/automation.ts @@ -24,8 +24,10 @@ export interface AutomationTool { parameters?: Record; } +export type AutomationStep = typeof import('../constants/automation').AUTOMATION_STEPS[keyof typeof import('../constants/automation').AUTOMATION_STEPS]; + export interface AutomationStepData { - step: 'selection' | 'creation' | 'run'; + step: AutomationStep; mode?: AutomationMode; automation?: AutomationConfig; }