Selection and creation

This commit is contained in:
Connor Yoh 2025-08-20 18:30:33 +01:00
parent 0ac5b20488
commit 775723f560
8 changed files with 311 additions and 146 deletions

View File

@ -22,8 +22,14 @@ import ToolConfigurationModal from './ToolConfigurationModal';
import ToolSelector from './ToolSelector';
import AutomationEntry from './AutomationEntry';
export enum AutomationMode {
CREATE = 'create',
EDIT = 'edit',
SUGGESTED = 'suggested'
}
interface AutomationCreationProps {
mode: 'custom' | 'suggested' | 'create';
mode: AutomationMode;
existingAutomation?: any;
onBack: () => void;
onComplete: (automation: any) => void;
@ -49,19 +55,24 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
// Initialize based on mode and existing automation
useEffect(() => {
if (mode === 'suggested' && existingAutomation) {
setAutomationName(existingAutomation.name);
if ((mode === AutomationMode.SUGGESTED || mode === AutomationMode.EDIT) && existingAutomation) {
setAutomationName(existingAutomation.name || '');
const tools = existingAutomation.operations.map((op: string) => ({
id: `${op}-${Date.now()}`,
operation: op,
name: getToolName(op),
configured: false,
parameters: {}
}));
// Handle both string array (suggested) and object array (custom) operations
const operations = existingAutomation.operations || [];
const tools = operations.map((op: any, index: number) => {
const operation = typeof op === 'string' ? op : op.operation;
return {
id: `${operation}-${Date.now()}-${index}`,
operation: operation,
name: getToolName(operation),
configured: mode === AutomationMode.EDIT ? true : (typeof op === 'object' ? op.configured || false : false),
parameters: typeof op === 'object' ? op.parameters || {} : {}
};
});
setSelectedTools(tools);
} else if (mode === 'create' && selectedTools.length === 0) {
} else if (mode === AutomationMode.CREATE && selectedTools.length === 0) {
// Initialize with 2 empty tools for new automation
const defaultTools = [
{
@ -217,60 +228,72 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
style={{
border: '1px solid var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
backgroundColor: 'white'
position: 'relative',
padding: 'var(--mantine-spacing-xs)'
}}
>
<Group gap="xs" align="center" wrap="nowrap" style={{ width: '100%' }}>
{/* Delete X in top right */}
<ActionIcon
variant="subtle"
size="xs"
onClick={() => removeTool(index)}
title={t('automate.creation.tools.remove', 'Remove tool')}
style={{
position: 'absolute',
top: '4px',
right: '4px',
zIndex: 1,
color: 'var(--mantine-color-gray-6)'
}}
>
<CloseIcon style={{ fontSize: 12 }} />
</ActionIcon>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
{/* Tool Selection Dropdown */}
<ToolSelector
key={`tool-selector-${tool.id}`}
onSelect={(newOperation) => {
const updatedTools = [...selectedTools];
updatedTools[index] = {
...updatedTools[index],
operation: newOperation,
name: getToolName(newOperation),
configured: false,
parameters: {}
};
setSelectedTools(updatedTools);
}}
excludeTools={['automate']}
toolRegistry={toolRegistry}
selectedValue={tool.operation}
placeholder={tool.name}
/>
</div>
<div style={{ paddingRight: '1.25rem' }}>
{/* Tool Selection Dropdown with inline settings cog */}
<Group gap="xs" align="center" wrap="nowrap">
<div style={{ flex: 1, minWidth: 0 }}>
<ToolSelector
key={`tool-selector-${tool.id}`}
onSelect={(newOperation) => {
const updatedTools = [...selectedTools];
updatedTools[index] = {
...updatedTools[index],
operation: newOperation,
name: getToolName(newOperation),
configured: false,
parameters: {}
};
setSelectedTools(updatedTools);
}}
excludeTools={['automate']}
toolRegistry={toolRegistry}
selectedValue={tool.operation}
placeholder={tool.name}
/>
</div>
<Group gap="xs" style={{ flexShrink: 0 }}>
{tool.configured ? (
<CheckIcon style={{ fontSize: 14, color: 'green' }} />
) : (
<CloseIcon style={{ fontSize: 14, color: 'orange' }} />
{/* Settings cog - only show if tool is selected, aligned right */}
{tool.operation && (
<ActionIcon
variant="subtle"
size="sm"
onClick={() => configureTool(index)}
title={t('automate.creation.tools.configure', 'Configure tool')}
style={{ color: 'var(--mantine-color-gray-6)' }}
>
<SettingsIcon style={{ fontSize: 16 }} />
</ActionIcon>
)}
<ActionIcon
variant="subtle"
size="sm"
onClick={() => configureTool(index)}
title={t('automate.creation.tools.configure', 'Configure tool')}
>
<SettingsIcon style={{ fontSize: 16 }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
color="red"
onClick={() => removeTool(index)}
title={t('automate.creation.tools.remove', 'Remove tool')}
>
<DeleteIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Group>
</Group>
{/* Configuration status underneath */}
{tool.operation && !tool.configured && (
<Text pl="md" size="xs" c="dimmed" mt="xs">
{t('automate.creation.tools.notConfigured', "! Not Configured")}
</Text>
)}
</div>
</div>
{index < selectedTools.length - 1 && (

View File

@ -1,6 +1,9 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Group, Text, Badge } from '@mantine/core';
import { Button, Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
interface AutomationEntryProps {
/** Optional title for the automation (usually for custom ones) */
@ -13,6 +16,12 @@ interface AutomationEntryProps {
onClick: () => void;
/** Whether to keep the icon at normal color (for special cases like "Add New") */
keepIconColor?: boolean;
/** Show menu for saved/suggested automations */
showMenu?: boolean;
/** Edit handler */
onEdit?: () => void;
/** Delete handler */
onDelete?: () => void;
}
export default function AutomationEntry({
@ -20,9 +29,17 @@ export default function AutomationEntry({
badgeIcon: BadgeIcon,
operations,
onClick,
keepIconColor = false
keepIconColor = false,
showMenu = false,
onEdit,
onDelete
}: AutomationEntryProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
const renderContent = () => {
if (title) {
@ -32,11 +49,11 @@ export default function AutomationEntry({
{BadgeIcon && (
<BadgeIcon
style={{
color: keepIconColor ? 'inherit' : 'var(--mantine-color-text)'
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
}}
/>
)}
<Text size="sm" style={{ flex: 1, textAlign: 'left', color: 'var(--mantine-color-text)' }}>
<Text size="xs" style={{ flex: 1, textAlign: 'left', color: 'var(--mantine-color-text)' }}>
{title}
</Text>
</Group>
@ -48,14 +65,14 @@ export default function AutomationEntry({
{BadgeIcon && (
<BadgeIcon
style={{
color: keepIconColor ? 'inherit' : 'var(--mantine-color-text)'
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
}}
/>
)}
<Group gap="xs" justify="flex-start" style={{ flex: 1 }}>
{operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}>
<Text size="sm" style={{ color: 'var(--mantine-color-text)' }}>
<Text size="xs" style={{ color: 'var(--mantine-color-text)' }}>
{t(`${op}.title`, op)}
</Text>
@ -73,20 +90,74 @@ export default function AutomationEntry({
};
return (
<Button
variant="subtle"
fullWidth
onClick={onClick}
<Box
style={{
height: 'auto',
backgroundColor: shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'transparent',
borderRadius: 'var(--mantine-radius-md)',
transition: 'background-color 0.15s ease',
padding: '0.75rem 1rem',
justifyContent: 'flex-start',
display: 'flex'
cursor: 'pointer'
}}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-start' }}>
{renderContent()}
</div>
</Button>
<Group gap="md" align="center" justify="space-between" style={{ width: '100%' }}>
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start' }}>
{renderContent()}
</div>
{showMenu && (
<Menu
position="bottom-end"
withinPortal
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="dimmed"
size="md"
onClick={(e) => e.stopPropagation()}
style={{
opacity: shouldShowHovered ? 1 : 0,
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
pointerEvents: shouldShowHovered ? 'auto' : 'none'
}}
>
<MoreVertIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onEdit && (
<Menu.Item
leftSection={<EditIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
{t('common.edit', 'Edit')}
</Menu.Item>
)}
{onDelete && (
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
{t('common.delete', 'Delete')}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Box>
);
}

View File

@ -5,17 +5,23 @@ import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
import SettingsIcon from "@mui/icons-material/Settings";
import AutomationEntry from "./AutomationEntry";
import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations";
import { useSavedAutomations } from "../../../hooks/tools/automate/useSavedAutomations";
interface AutomationSelectionProps {
onSelectCustom: () => void;
onSelectSuggested: (automation: any) => void;
savedAutomations: any[];
onCreateNew: () => void;
onRun: (automation: any) => void;
onEdit: (automation: any) => void;
onDelete: (automation: any) => void;
}
export default function AutomationSelection({ onSelectCustom, onSelectSuggested, onCreateNew }: AutomationSelectionProps) {
export default function AutomationSelection({
savedAutomations,
onCreateNew,
onRun,
onEdit,
onDelete
}: AutomationSelectionProps) {
const { t } = useTranslation();
const { savedAutomations } = useSavedAutomations();
const suggestedAutomations = useSuggestedAutomations();
return (
@ -39,7 +45,10 @@ export default function AutomationSelection({ onSelectCustom, onSelectSuggested,
title={automation.name}
badgeIcon={SettingsIcon}
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
onClick={() => onSelectCustom()}
onClick={() => onRun(automation)}
showMenu={true}
onEdit={() => onEdit(automation)}
onDelete={() => onDelete(automation)}
/>
))}
<Divider pb='sm' />
@ -55,7 +64,7 @@ export default function AutomationSelection({ onSelectCustom, onSelectSuggested,
key={automation.id}
badgeIcon={automation.icon}
operations={automation.operations}
onClick={() => onSelectSuggested(automation)}
onClick={() => onRun(automation)}
/>
))}
</Stack>

View File

@ -128,10 +128,10 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel
if (tool.parameters) {
setParameters(tool.parameters);
} else if (parameterHook) {
// If we have a parameter hook, use it to get default values
// If we have a parameter module, use its default parameters
try {
const defaultParams = parameterHook();
setParameters(defaultParams.parameters || {});
const defaultParams = parameterHook.defaultParameters || {};
setParameters(defaultParams);
} catch (error) {
console.warn(`Error getting default parameters for ${tool.operation}:`, error);
setParameters({});

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Menu, Stack, Text, ScrollArea } from '@mantine/core';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
@ -14,10 +14,10 @@ interface ToolSelectorProps {
placeholder?: string; // Custom placeholder text
}
export default function ToolSelector({
onSelect,
excludeTools = [],
toolRegistry,
export default function ToolSelector({
onSelect,
excludeTools = [],
toolRegistry,
selectedValue,
placeholder
}: ToolSelectorProps) {
@ -35,7 +35,7 @@ export default function ToolSelector({
if (!searchTerm.trim()) {
return baseFilteredTools;
}
const lowercaseSearch = searchTerm.toLowerCase();
return baseFilteredTools.filter(([key, tool]) => {
return (
@ -57,28 +57,34 @@ export default function ToolSelector({
// Use the same tool sections logic as the main ToolPicker
const { sections, searchGroups } = useToolSections(filteredTools);
// Determine what to display: search results or organized sections
const isSearching = searchTerm.trim().length > 0;
const displayGroups = useMemo(() => {
if (isSearching) {
return searchGroups || [];
}
if (!sections || sections.length === 0) {
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]);
const handleToolSelect = (toolKey: string) => {
const handleToolSelect = useCallback((toolKey: string) => {
onSelect(toolKey);
setOpened(false);
setSearchTerm('');
};
setSearchTerm(''); // Clear search to show the selected tool display
}, [onSelect]);
const renderedTools = useMemo(() =>
displayGroups.map((subcategory) =>
renderToolButtons(subcategory, null, handleToolSelect, !isSearching)
), [displayGroups, handleToolSelect, isSearching]
);
const handleSearchFocus = () => {
setOpened(true);
@ -100,44 +106,77 @@ export default function ToolSelector({
};
return (
<Menu
opened={opened}
onChange={setOpened}
width="300px"
position="bottom-start"
withinPortal
>
<Menu.Target>
<div onClick={handleSearchFocus} style={{ cursor: 'text' }}>
<ToolSearch
value={searchTerm}
onChange={handleSearchChange}
toolRegistry={filteredToolRegistry}
mode="filter"
placeholder={getDisplayValue()}
hideIcon={true}
/>
</div>
</Menu.Target>
<div style={{ position: 'relative', width: '100%' }}>
<Menu
opened={opened}
onChange={(isOpen) => {
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 }}
>
<Menu.Target>
<div style={{ width: '100%' }}>
{selectedValue && toolRegistry[selectedValue] && !opened ? (
// Show selected tool in AutomationEntry style when tool is selected and not searching
<div onClick={handleSearchFocus} style={{ cursor: 'pointer' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--mantine-spacing-sm)',
padding: '0 0.5rem',
borderRadius: 'var(--mantine-radius-sm)',
}}>
<div style={{ color: 'var(--mantine-color-text)', fontSize: '1.2rem' }}>
{toolRegistry[selectedValue].icon}
</div>
<Text size="sm" style={{ flex: 1, color: 'var(--mantine-color-text)' }}>
{toolRegistry[selectedValue].name}
</Text>
</div>
</div>
) : (
// Show search input when no tool selected or actively searching
<ToolSearch
value={searchTerm}
onChange={handleSearchChange}
toolRegistry={filteredToolRegistry}
mode="filter"
placeholder={getDisplayValue()}
hideIcon={true}
onFocus={handleSearchFocus}
/>
)}
</div>
</Menu.Target>
<Menu.Dropdown p={0}>
<Menu.Dropdown p={0} style={{ minWidth: '16rem' }}>
<ScrollArea h={350}>
<Stack gap="sm" p="sm">
{displayGroups.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" p="md">
{isSearching
{isSearching
? t('tools.noSearchResults', 'No tools found')
: t('tools.noTools', 'No tools available')
}
</Text>
) : (
displayGroups.map((subcategory) =>
renderToolButtons(subcategory, null, handleToolSelect, !isSearching)
)
renderedTools
)}
</Stack>
</ScrollArea>
</Menu.Dropdown>
</Menu>
</div>
);
}
}

View File

@ -14,21 +14,24 @@ interface ToolSearchProps {
selectedToolKey?: string | null;
placeholder?: string;
hideIcon?: boolean;
onFocus?: () => void;
}
const ToolSearch = ({
value,
onChange,
toolRegistry,
onToolSelect,
const ToolSearch = ({
value,
onChange,
toolRegistry,
onToolSelect,
mode = 'filter',
selectedToolKey,
placeholder,
hideIcon = false
hideIcon = false,
onFocus
}: ToolSearchProps) => {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const filteredTools = useMemo(() => {
if (!value.trim()) return [];
@ -51,7 +54,12 @@ const ToolSearch = ({
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
if (
searchRef.current &&
dropdownRef.current &&
!searchRef.current.contains(event.target as Node) &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
@ -68,6 +76,7 @@ const ToolSearch = ({
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
icon={hideIcon ? undefined : <span className="material-symbols-rounded">search</span>}
autoComplete="off"
/>
</div>
);
@ -81,19 +90,19 @@ const ToolSearch = ({
{searchInput}
{dropdownOpen && filteredTools.length > 0 && (
<div
ref={dropdownRef}
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 1000,
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderRadius: '8px',
marginTop: '4px',
backgroundColor: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: '6px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
maxHeight: '300px',
overflowY: 'auto',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
overflowY: 'auto'
}}
>
<Stack gap="xs" style={{ padding: '8px' }}>
@ -101,7 +110,10 @@ const ToolSearch = ({
<Button
key={id}
variant="subtle"
onClick={() => onToolSelect && onToolSelect(id)}
onClick={() => {
onToolSelect && onToolSelect(id);
setDropdownOpen(false);
}}
leftSection={
<div style={{ color: 'var(--tools-text-and-icon-color)' }}>
{tool.icon}
@ -130,4 +142,4 @@ const ToolSearch = ({
);
};
export default ToolSearch;
export default ToolSearch;

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import SplitPdfPanel from "../tools/Split";
import CompressPdfPanel from "../tools/Compress";
@ -21,7 +21,7 @@ import RemoveCertificateSign from '../tools/RemoveCertificateSign';
export function useFlatToolRegistry(): ToolRegistry {
const { t } = useTranslation();
return {
return useMemo(() => ({
// Signing
"certSign": {
@ -618,5 +618,5 @@ export function useFlatToolRegistry(): ToolRegistry {
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL
},
};
}), [t]);
}

View File

@ -5,12 +5,13 @@ import { useToolFileSelection } from "../contexts/FileSelectionContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import AutomationSelection from "../components/tools/automate/AutomationSelection";
import AutomationCreation from "../components/tools/automate/AutomationCreation";
import AutomationCreation, { AutomationMode } from "../components/tools/automate/AutomationCreation";
import ToolSequence from "../components/tools/automate/ToolSequence";
import { useAutomateOperation } from "../hooks/tools/automate/useAutomateOperation";
import { BaseToolProps } from "../types/tool";
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
import { useSavedAutomations } from "../hooks/tools/automate/useSavedAutomations";
const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@ -22,6 +23,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const automateOperation = useAutomateOperation();
const toolRegistry = useFlatToolRegistry();
const { savedAutomations, deleteAutomation } = useSavedAutomations();
const handleStepChange = (data: any) => {
setStepData(data);
@ -40,9 +42,18 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
case 'selection':
return (
<AutomationSelection
onSelectCustom={() => handleStepChange({ step: 'creation', mode: 'custom' })}
onSelectSuggested={(automation: any) => handleStepChange({ step: 'creation', mode: 'suggested', automation })}
onCreateNew={() => handleStepChange({ step: 'creation', mode: 'create' })}
savedAutomations={savedAutomations}
onCreateNew={() => handleStepChange({ step: 'creation', mode: AutomationMode.CREATE })}
onRun={(automation: any) => handleStepChange({ step: 'sequence', automation })}
onEdit={(automation: any) => handleStepChange({ step: 'creation', mode: AutomationMode.EDIT, automation })}
onDelete={async (automation: any) => {
try {
await deleteAutomation(automation.id);
} catch (error) {
console.error('Failed to delete automation:', error);
onError?.(`Failed to delete automation: ${automation.name}`);
}
}}
/>
);