mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Feature/v2/automate (#4248)
* automate feature * Moved all providers to app level to simplify homepage * Circular dependency fixes * You will see that now toolRegistry gets a tool config and a tool settings object. These enable automate to run the tools using as much static code as possible. --------- Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
@@ -111,7 +111,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onClose={closeFilesModal}
|
||||
size={isMobile ? "100%" : "auto"}
|
||||
centered
|
||||
radius={30}
|
||||
radius="md"
|
||||
className="overflow-hidden p-0"
|
||||
withCloseButton={false}
|
||||
styles={{
|
||||
@@ -144,7 +144,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
borderRadius: '30px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-file-manager)'
|
||||
}}
|
||||
styles={{
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { Text, Button, Stack } from '@mantine/core';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ComponentType<{error?: Error; retry: () => void}>;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
retry = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
const Fallback = this.props.fallback;
|
||||
return <Fallback error={this.state.error} retry={this.retry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack align="center" justify="center" style={{ minHeight: '200px', padding: '2rem' }}>
|
||||
<Text size="lg" fw={500} c="red">Something went wrong</Text>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<Text size="sm" c="dimmed" style={{ textAlign: 'center', fontFamily: 'monospace' }}>
|
||||
{this.state.error.message}
|
||||
</Text>
|
||||
)}
|
||||
<Button onClick={this.retry} variant="light">
|
||||
Try Again
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
|
||||
import { Box, Text, Stack } from "@mantine/core";
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSubcategoryLabel, ToolRegistryEntry } from "../../data/toolsTaxonomy";
|
||||
import ToolButton from "./toolPicker/ToolButton";
|
||||
import { ToolRegistryEntry } from "../../data/toolsTaxonomy";
|
||||
import "./toolPicker/ToolPicker.css";
|
||||
import { SubcategoryGroup, useToolSections } from "../../hooks/useToolSections";
|
||||
import SubcategoryHeader from "./shared/SubcategoryHeader";
|
||||
import { useToolSections } from "../../hooks/useToolSections";
|
||||
import NoToolsFound from "./shared/NoToolsFound";
|
||||
import { TFunction } from "i18next";
|
||||
import { renderToolButtons } from "./shared/renderToolButtons";
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
@@ -16,32 +14,6 @@ interface ToolPickerProps {
|
||||
isSearching?: boolean;
|
||||
}
|
||||
|
||||
// Helper function to render tool buttons for a subcategory
|
||||
const renderToolButtons = (
|
||||
t: TFunction,
|
||||
subcategory: SubcategoryGroup,
|
||||
selectedToolKey: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
showSubcategoryHeader: boolean = true
|
||||
) => (
|
||||
<Box key={subcategory.subcategoryId} w="100%">
|
||||
{showSubcategoryHeader && (
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
|
||||
)}
|
||||
<Stack gap="xs">
|
||||
{subcategory.tools.map(({ id, tool }: { id: string; tool: any }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* AddWatermarkSingleStepSettings - Used for automation only
|
||||
*
|
||||
* This component combines all watermark settings into a single step interface
|
||||
* for use in the automation system. It includes type selection and all relevant
|
||||
* settings in one unified component.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
|
||||
import WatermarkTypeSettings from "./WatermarkTypeSettings";
|
||||
import WatermarkWording from "./WatermarkWording";
|
||||
import WatermarkTextStyle from "./WatermarkTextStyle";
|
||||
import WatermarkImageFile from "./WatermarkImageFile";
|
||||
import WatermarkFormatting from "./WatermarkFormatting";
|
||||
|
||||
interface AddWatermarkSingleStepSettingsProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddWatermarkSingleStepSettings = ({ parameters, onParameterChange, disabled = false }: AddWatermarkSingleStepSettingsProps) => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Watermark Type Selection */}
|
||||
<WatermarkTypeSettings
|
||||
watermarkType={parameters.watermarkType}
|
||||
onWatermarkTypeChange={(type) => onParameterChange("watermarkType", type)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Conditional settings based on watermark type */}
|
||||
{parameters.watermarkType === "text" && (
|
||||
<>
|
||||
<WatermarkWording
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<WatermarkTextStyle
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{parameters.watermarkType === "image" && (
|
||||
<WatermarkImageFile
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Formatting settings for both text and image */}
|
||||
{parameters.watermarkType && (
|
||||
<WatermarkFormatting
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddWatermarkSingleStepSettings;
|
||||
@@ -6,7 +6,7 @@ import NumberInputWithUnit from "../shared/NumberInputWithUnit";
|
||||
|
||||
interface WatermarkFormattingProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import FileUploadButton from "../../shared/FileUploadButton";
|
||||
|
||||
interface WatermarkImageFileProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const WatermarkImageFile = ({ parameters, onParameterChange, disabled = false }:
|
||||
<Stack gap="sm">
|
||||
<FileUploadButton
|
||||
file={parameters.watermarkImage}
|
||||
onChange={(file) => onParameterChange('watermarkImage', file)}
|
||||
onChange={(file) => onParameterChange('watermarkImage', file || undefined)}
|
||||
accept="image/*"
|
||||
disabled={disabled}
|
||||
placeholder={t('watermark.settings.image.choose', 'Choose Image')}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAdd
|
||||
|
||||
interface WatermarkStyleSettingsProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
|
||||
<Text size="sm" fw={500}>{t('watermark.settings.rotation', 'Rotation (degrees)')}</Text>
|
||||
<NumberInput
|
||||
value={parameters.rotation}
|
||||
onChange={(value) => onParameterChange('rotation', value || 0)}
|
||||
onChange={(value) => onParameterChange('rotation', typeof value === 'number' ? value : (parseInt(value as string, 10) || 0))}
|
||||
min={-360}
|
||||
max={360}
|
||||
disabled={disabled}
|
||||
@@ -28,7 +28,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
|
||||
<Text size="sm" fw={500}>{t('watermark.settings.opacity', 'Opacity (%)')}</Text>
|
||||
<NumberInput
|
||||
value={parameters.opacity}
|
||||
onChange={(value) => onParameterChange('opacity', value || 50)}
|
||||
onChange={(value) => onParameterChange('opacity', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
|
||||
min={0}
|
||||
max={100}
|
||||
disabled={disabled}
|
||||
@@ -40,7 +40,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
|
||||
<Text size="sm" fw={500}>{t('watermark.settings.spacing.width', 'Width Spacing')}</Text>
|
||||
<NumberInput
|
||||
value={parameters.widthSpacer}
|
||||
onChange={(value) => onParameterChange('widthSpacer', value || 50)}
|
||||
onChange={(value) => onParameterChange('widthSpacer', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
|
||||
min={0}
|
||||
max={200}
|
||||
disabled={disabled}
|
||||
@@ -49,7 +49,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
|
||||
<Text size="sm" fw={500}>{t('watermark.settings.spacing.height', 'Height Spacing')}</Text>
|
||||
<NumberInput
|
||||
value={parameters.heightSpacer}
|
||||
onChange={(value) => onParameterChange('heightSpacer', value || 50)}
|
||||
onChange={(value) => onParameterChange('heightSpacer', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
|
||||
min={0}
|
||||
max={200}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { alphabetOptions } from "../../../constants/addWatermarkConstants";
|
||||
|
||||
interface WatermarkTextStyleProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { removeEmojis } from "../../../utils/textUtils";
|
||||
|
||||
interface WatermarkWordingProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Text,
|
||||
Stack,
|
||||
Group,
|
||||
TextInput,
|
||||
Divider,
|
||||
Modal
|
||||
} from '@mantine/core';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
import ToolConfigurationModal from './ToolConfigurationModal';
|
||||
import ToolList from './ToolList';
|
||||
import { AutomationConfig, AutomationMode, AutomationTool } from '../../../types/automation';
|
||||
import { useAutomationForm } from '../../../hooks/tools/automate/useAutomationForm';
|
||||
|
||||
|
||||
interface AutomationCreationProps {
|
||||
mode: AutomationMode;
|
||||
existingAutomation?: AutomationConfig;
|
||||
onBack: () => void;
|
||||
onComplete: (automation: AutomationConfig) => void;
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
}
|
||||
|
||||
export default function AutomationCreation({ mode, existingAutomation, onBack, onComplete, toolRegistry }: AutomationCreationProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
automationName,
|
||||
setAutomationName,
|
||||
selectedTools,
|
||||
addTool,
|
||||
removeTool,
|
||||
updateTool,
|
||||
hasUnsavedChanges,
|
||||
canSaveAutomation,
|
||||
getToolName,
|
||||
getToolDefaultParameters
|
||||
} = useAutomationForm({ mode, existingAutomation, toolRegistry });
|
||||
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [configuraingToolIndex, setConfiguringToolIndex] = useState(-1);
|
||||
const [unsavedWarningOpen, setUnsavedWarningOpen] = useState(false);
|
||||
|
||||
|
||||
const configureTool = (index: number) => {
|
||||
setConfiguringToolIndex(index);
|
||||
setConfigModalOpen(true);
|
||||
};
|
||||
|
||||
const handleToolConfigSave = (parameters: Record<string, any>) => {
|
||||
if (configuraingToolIndex >= 0) {
|
||||
updateTool(configuraingToolIndex, {
|
||||
configured: true,
|
||||
parameters
|
||||
});
|
||||
}
|
||||
setConfigModalOpen(false);
|
||||
setConfiguringToolIndex(-1);
|
||||
};
|
||||
|
||||
const handleToolConfigCancel = () => {
|
||||
setConfigModalOpen(false);
|
||||
setConfiguringToolIndex(-1);
|
||||
};
|
||||
|
||||
const handleToolAdd = () => {
|
||||
const newTool: AutomationTool = {
|
||||
id: `tool-${Date.now()}`,
|
||||
operation: '',
|
||||
name: t('automate.creation.tools.selectTool', 'Select a tool...'),
|
||||
configured: false,
|
||||
parameters: {}
|
||||
};
|
||||
updateTool(selectedTools.length, newTool);
|
||||
};
|
||||
|
||||
const handleBackClick = () => {
|
||||
if (hasUnsavedChanges()) {
|
||||
setUnsavedWarningOpen(true);
|
||||
} else {
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmBack = () => {
|
||||
setUnsavedWarningOpen(false);
|
||||
onBack();
|
||||
};
|
||||
|
||||
const handleCancelBack = () => {
|
||||
setUnsavedWarningOpen(false);
|
||||
};
|
||||
|
||||
const saveAutomation = async () => {
|
||||
if (!canSaveAutomation()) return;
|
||||
|
||||
const automation = {
|
||||
name: automationName.trim(),
|
||||
description: '',
|
||||
operations: selectedTools.map(tool => ({
|
||||
operation: tool.operation,
|
||||
parameters: tool.parameters || {}
|
||||
}))
|
||||
};
|
||||
|
||||
try {
|
||||
const { automationStorage } = await import('../../../services/automationStorage');
|
||||
const savedAutomation = await automationStorage.saveAutomation(automation);
|
||||
onComplete(savedAutomation);
|
||||
} catch (error) {
|
||||
console.error('Error saving automation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const currentConfigTool = configuraingToolIndex >= 0 ? selectedTools[configuraingToolIndex] : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text size="sm" mb="md" p="md" style={{borderRadius:'var(--mantine-radius-md)', background: 'var(--color-gray-200)', color: 'var(--mantine-color-text)' }}>
|
||||
{t("automate.creation.description", "Automations run tools sequentially. To get started, add tools in the order you want them to run.")}
|
||||
</Text>
|
||||
<Divider mb="md" />
|
||||
|
||||
<Stack gap="md">
|
||||
{/* Automation Name */}
|
||||
<TextInput
|
||||
placeholder={t('automate.creation.name.placeholder', 'Automation name')}
|
||||
value={automationName}
|
||||
onChange={(e) => setAutomationName(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
|
||||
{/* Selected Tools List */}
|
||||
{selectedTools.length > 0 && (
|
||||
<ToolList
|
||||
tools={selectedTools}
|
||||
toolRegistry={toolRegistry}
|
||||
onToolUpdate={updateTool}
|
||||
onToolRemove={removeTool}
|
||||
onToolConfigure={configureTool}
|
||||
onToolAdd={handleToolAdd}
|
||||
getToolName={getToolName}
|
||||
getToolDefaultParameters={getToolDefaultParameters}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Save Button */}
|
||||
<Button
|
||||
leftSection={<CheckIcon />}
|
||||
onClick={saveAutomation}
|
||||
disabled={!canSaveAutomation()}
|
||||
fullWidth
|
||||
>
|
||||
{t('automate.creation.save', 'Save Automation')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Tool Configuration Modal */}
|
||||
{currentConfigTool && (
|
||||
<ToolConfigurationModal
|
||||
opened={configModalOpen}
|
||||
tool={currentConfigTool}
|
||||
onSave={handleToolConfigSave}
|
||||
onCancel={handleToolConfigCancel}
|
||||
toolRegistry={toolRegistry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unsaved Changes Warning Modal */}
|
||||
<Modal
|
||||
opened={unsavedWarningOpen}
|
||||
onClose={handleCancelBack}
|
||||
title={t('automate.creation.unsavedChanges.title', 'Unsaved Changes')}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t('automate.creation.unsavedChanges.message', 'You have unsaved changes. Are you sure you want to go back? All changes will be lost.')}
|
||||
</Text>
|
||||
<Group gap="md" justify="flex-end">
|
||||
<Button variant="outline" onClick={handleCancelBack}>
|
||||
{t('automate.creation.unsavedChanges.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleConfirmBack}>
|
||||
{t('automate.creation.unsavedChanges.confirm', 'Go Back')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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) */
|
||||
title?: string;
|
||||
/** MUI Icon component for the badge */
|
||||
badgeIcon?: React.ComponentType<any>;
|
||||
/** Array of tool operation names in the workflow */
|
||||
operations: string[];
|
||||
/** Click handler */
|
||||
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({
|
||||
title,
|
||||
badgeIcon: BadgeIcon,
|
||||
operations,
|
||||
onClick,
|
||||
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) {
|
||||
// Custom automation with title
|
||||
return (
|
||||
<Group gap="md" align="center" justify="flex-start" style={{ width: '100%' }}>
|
||||
{BadgeIcon && (
|
||||
<BadgeIcon
|
||||
style={{
|
||||
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" style={{ flex: 1, textAlign: 'left', color: 'var(--mantine-color-text)' }}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
} else {
|
||||
// Suggested automation showing tool chain
|
||||
return (
|
||||
<Group gap="md" align="center" justify="flex-start" style={{ width: '100%' }}>
|
||||
{BadgeIcon && (
|
||||
<BadgeIcon
|
||||
style={{
|
||||
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="xs" style={{ color: 'var(--mantine-color-text)' }}>
|
||||
{t(`${op}.title`, op)}
|
||||
</Text>
|
||||
|
||||
{index < operations.length - 1 && (
|
||||
<Text size="xs" c="dimmed" style={{ color: 'var(--mantine-color-text)' }}>
|
||||
→
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'transparent',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
transition: 'background-color 0.15s ease',
|
||||
padding: '0.75rem 1rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<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('edit', 'Edit')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
{t('delete', 'Delete')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import { useFileSelection } from "../../../contexts/FileContext";
|
||||
import { useFlatToolRegistry } from "../../../data/useTranslatedToolRegistry";
|
||||
import { AutomationConfig, ExecutionStep } from "../../../types/automation";
|
||||
import { AUTOMATION_CONSTANTS, EXECUTION_STATUS } from "../../../constants/automation";
|
||||
import { useResourceCleanup } from "../../../utils/resourceManager";
|
||||
|
||||
interface AutomationRunProps {
|
||||
automation: AutomationConfig;
|
||||
onComplete: () => void;
|
||||
automateOperation?: any; // TODO: Type this properly when available
|
||||
}
|
||||
|
||||
export default function AutomationRun({ automation, onComplete, automateOperation }: AutomationRunProps) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const toolRegistry = useFlatToolRegistry();
|
||||
const cleanup = useResourceCleanup();
|
||||
|
||||
// Progress tracking state
|
||||
const [executionSteps, setExecutionSteps] = useState<ExecutionStep[]>([]);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
||||
|
||||
// Use the operation hook's loading state
|
||||
const isExecuting = automateOperation?.isLoading || false;
|
||||
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
|
||||
|
||||
// Initialize execution steps from automation
|
||||
React.useEffect(() => {
|
||||
if (automation?.operations) {
|
||||
const steps = automation.operations.map((op: any, index: number) => {
|
||||
const tool = toolRegistry[op.operation];
|
||||
return {
|
||||
id: `${op.operation}-${index}`,
|
||||
operation: op.operation,
|
||||
name: tool?.name || op.operation,
|
||||
status: EXECUTION_STATUS.PENDING
|
||||
};
|
||||
});
|
||||
setExecutionSteps(steps);
|
||||
setCurrentStepIndex(-1);
|
||||
}
|
||||
}, [automation, toolRegistry]);
|
||||
|
||||
// Cleanup when component unmounts
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
// Reset progress state when component unmounts
|
||||
setExecutionSteps([]);
|
||||
setCurrentStepIndex(-1);
|
||||
// Clean up any blob URLs
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
const executeAutomation = async () => {
|
||||
if (!selectedFiles || selectedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!automateOperation) {
|
||||
console.error('No automateOperation provided');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset progress tracking
|
||||
setCurrentStepIndex(0);
|
||||
setExecutionSteps(prev => prev.map(step => ({ ...step, status: EXECUTION_STATUS.PENDING, error: undefined })));
|
||||
|
||||
try {
|
||||
// Use the automateOperation.executeOperation to handle file consumption properly
|
||||
await automateOperation.executeOperation(
|
||||
{
|
||||
automationConfig: automation,
|
||||
onStepStart: (stepIndex: number, operationName: string) => {
|
||||
setCurrentStepIndex(stepIndex);
|
||||
setExecutionSteps(prev => prev.map((step, idx) =>
|
||||
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING } : step
|
||||
));
|
||||
},
|
||||
onStepComplete: (stepIndex: number, resultFiles: File[]) => {
|
||||
setExecutionSteps(prev => prev.map((step, idx) =>
|
||||
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED } : step
|
||||
));
|
||||
},
|
||||
onStepError: (stepIndex: number, error: string) => {
|
||||
setExecutionSteps(prev => prev.map((step, idx) =>
|
||||
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.ERROR, error } : step
|
||||
));
|
||||
}
|
||||
},
|
||||
selectedFiles
|
||||
);
|
||||
|
||||
// Mark all as completed and reset current step
|
||||
setCurrentStepIndex(-1);
|
||||
console.log(`✅ Automation completed successfully`);
|
||||
} catch (error: any) {
|
||||
console.error("Automation execution failed:", error);
|
||||
setCurrentStepIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const getProgress = () => {
|
||||
if (executionSteps.length === 0) return 0;
|
||||
const completedSteps = executionSteps.filter(step => step.status === EXECUTION_STATUS.COMPLETED).length;
|
||||
return (completedSteps / executionSteps.length) * 100;
|
||||
};
|
||||
|
||||
const getStepIcon = (step: ExecutionStep) => {
|
||||
switch (step.status) {
|
||||
case EXECUTION_STATUS.COMPLETED:
|
||||
return <CheckIcon style={{ fontSize: 16, color: 'green' }} />;
|
||||
case EXECUTION_STATUS.ERROR:
|
||||
return <span style={{ fontSize: 16, color: 'red' }}>✕</span>;
|
||||
case EXECUTION_STATUS.RUNNING:
|
||||
return <div style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid #ccc',
|
||||
borderTop: '2px solid #007bff',
|
||||
borderRadius: '50%',
|
||||
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
|
||||
}} />;
|
||||
default:
|
||||
return <div style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid #ccc',
|
||||
borderRadius: '50%'
|
||||
}} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Stack gap="md">
|
||||
{/* Automation Info */}
|
||||
<Card padding="md" withBorder>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{automation?.name || t("automate.sequence.unnamed", "Unnamed Automation")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("automate.sequence.steps", "{{count}} steps", { count: executionSteps.length })}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{isExecuting && (
|
||||
<div>
|
||||
<Text size="sm" mb="xs">
|
||||
Progress: {currentStepIndex + 1}/{executionSteps.length}
|
||||
</Text>
|
||||
<Progress value={getProgress()} size="lg" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution Steps */}
|
||||
<Stack gap="xs">
|
||||
{executionSteps.map((step, index) => (
|
||||
<Group key={step.id} gap="sm" align="center">
|
||||
<Text size="xs" c="dimmed" style={{ minWidth: "1rem", textAlign: "center" }}>
|
||||
{index + 1}
|
||||
</Text>
|
||||
|
||||
{getStepIcon(step)}
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
color: step.status === EXECUTION_STATUS.RUNNING ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-text)',
|
||||
fontWeight: step.status === EXECUTION_STATUS.RUNNING ? 500 : 400
|
||||
}}
|
||||
>
|
||||
{step.name}
|
||||
</Text>
|
||||
{step.error && (
|
||||
<Text size="xs" c="red" mt="xs">
|
||||
{step.error}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button
|
||||
leftSection={<PlayArrowIcon />}
|
||||
onClick={executeAutomation}
|
||||
disabled={isExecuting || !selectedFiles || selectedFiles.length === 0}
|
||||
loading={isExecuting}
|
||||
>
|
||||
{isExecuting
|
||||
? t("automate.sequence.running", "Running Automation...")
|
||||
: t("automate.sequence.run", "Run Automation")}
|
||||
</Button>
|
||||
|
||||
{hasResults && (
|
||||
<Button variant="light" onClick={onComplete}>
|
||||
{t("automate.sequence.finish", "Finish")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Title, Stack, Divider } from "@mantine/core";
|
||||
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 { AutomationConfig } from "../../../types/automation";
|
||||
|
||||
interface AutomationSelectionProps {
|
||||
savedAutomations: AutomationConfig[];
|
||||
onCreateNew: () => void;
|
||||
onRun: (automation: AutomationConfig) => void;
|
||||
onEdit: (automation: AutomationConfig) => void;
|
||||
onDelete: (automation: AutomationConfig) => void;
|
||||
}
|
||||
|
||||
export default function AutomationSelection({
|
||||
savedAutomations,
|
||||
onCreateNew,
|
||||
onRun,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: AutomationSelectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const suggestedAutomations = useSuggestedAutomations();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title order={3} size="h4" fw={600} mb="md" style={{color: 'var(--mantine-color-dimmed)'}}>
|
||||
{t("automate.selection.saved.title", "Saved")}
|
||||
</Title>
|
||||
|
||||
<Stack gap="xs">
|
||||
<AutomationEntry
|
||||
title={t("automate.selection.createNew.title", "Create New Automation")}
|
||||
badgeIcon={AddCircleOutline}
|
||||
operations={[]}
|
||||
onClick={onCreateNew}
|
||||
keepIconColor={true}
|
||||
/>
|
||||
{/* Saved Automations */}
|
||||
{savedAutomations.map((automation) => (
|
||||
<AutomationEntry
|
||||
key={automation.id}
|
||||
title={automation.name}
|
||||
badgeIcon={SettingsIcon}
|
||||
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
|
||||
onClick={() => onRun(automation)}
|
||||
showMenu={true}
|
||||
onEdit={() => onEdit(automation)}
|
||||
onDelete={() => onDelete(automation)}
|
||||
/>
|
||||
))}
|
||||
<Divider pb='sm' />
|
||||
|
||||
{/* Suggested Automations */}
|
||||
<div>
|
||||
<Title order={3} size="h4" fw={600} mb="md"style={ {color: 'var(--mantine-color-dimmed)'}}>
|
||||
{t("automate.selection.suggested.title", "Suggested")}
|
||||
</Title>
|
||||
<Stack gap="xs">
|
||||
{suggestedAutomations.map((automation) => (
|
||||
<AutomationEntry
|
||||
key={automation.id}
|
||||
badgeIcon={automation.icon}
|
||||
operations={automation.operations.map(op => op.operation)}
|
||||
onClick={() => onRun(automation)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
Title,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Alert
|
||||
} from '@mantine/core';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import { ToolRegistry } from '../../../data/toolsTaxonomy';
|
||||
import { getAvailableToExtensions } from '../../../utils/convertUtils';
|
||||
interface ToolConfigurationModalProps {
|
||||
opened: boolean;
|
||||
tool: {
|
||||
id: string;
|
||||
operation: string;
|
||||
name: string;
|
||||
parameters?: any;
|
||||
};
|
||||
onSave: (parameters: any) => void;
|
||||
onCancel: () => void;
|
||||
toolRegistry: ToolRegistry;
|
||||
}
|
||||
|
||||
export default function ToolConfigurationModal({ opened, tool, onSave, onCancel, toolRegistry }: ToolConfigurationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [parameters, setParameters] = useState<any>({});
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
|
||||
// Get tool info from registry
|
||||
const toolInfo = toolRegistry[tool.operation];
|
||||
const SettingsComponent = toolInfo?.settingsComponent;
|
||||
|
||||
// Initialize parameters from tool (which should contain defaults from registry)
|
||||
useEffect(() => {
|
||||
if (tool.parameters) {
|
||||
setParameters(tool.parameters);
|
||||
} else {
|
||||
// Fallback to empty parameters if none provided
|
||||
setParameters({});
|
||||
}
|
||||
}, [tool.parameters, tool.operation]);
|
||||
|
||||
// Render the settings component
|
||||
const renderToolSettings = () => {
|
||||
if (!SettingsComponent) {
|
||||
return (
|
||||
<Alert icon={<WarningIcon />} color="orange">
|
||||
<Text size="sm">
|
||||
{t('automate.config.noSettings', 'This tool does not have configurable settings.')}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Special handling for ConvertSettings which needs additional props
|
||||
if (tool.operation === 'convert') {
|
||||
return (
|
||||
<SettingsComponent
|
||||
parameters={parameters}
|
||||
onParameterChange={(key: string, value: any) => {
|
||||
setParameters((prev: any) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
getAvailableToExtensions={getAvailableToExtensions}
|
||||
selectedFiles={[]}
|
||||
disabled={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsComponent
|
||||
parameters={parameters}
|
||||
onParameterChange={(key: string, value: any) => {
|
||||
setParameters((prev: any) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
disabled={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (isValid) {
|
||||
onSave(parameters);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCancel}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<SettingsIcon />
|
||||
<Title order={3}>
|
||||
{t('automate.config.title', 'Configure {{toolName}}', { toolName: tool.name })}
|
||||
</Title>
|
||||
</Group>
|
||||
}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('automate.config.description', 'Configure the settings for this tool. These settings will be applied when the automation runs.')}
|
||||
</Text>
|
||||
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{renderToolSettings()}
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<CloseIcon />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('automate.config.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<CheckIcon />}
|
||||
onClick={handleSave}
|
||||
disabled={!isValid}
|
||||
>
|
||||
{t('automate.config.save', 'Save Configuration')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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';
|
||||
|
||||
interface ToolListProps {
|
||||
tools: AutomationTool[];
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
onToolUpdate: (index: number, updates: Partial<AutomationTool>) => void;
|
||||
onToolRemove: (index: number) => void;
|
||||
onToolConfigure: (index: number) => void;
|
||||
onToolAdd: () => void;
|
||||
getToolName: (operation: string) => string;
|
||||
getToolDefaultParameters: (operation: string) => Record<string, any>;
|
||||
}
|
||||
|
||||
export default function ToolList({
|
||||
tools,
|
||||
toolRegistry,
|
||||
onToolUpdate,
|
||||
onToolRemove,
|
||||
onToolConfigure,
|
||||
onToolAdd,
|
||||
getToolName,
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs" style={{ color: 'var(--mantine-color-text)' }}>
|
||||
{t('automate.creation.tools.selected', 'Selected Tools')} ({tools.length})
|
||||
</Text>
|
||||
<Stack gap="0">
|
||||
{tools.map((tool, index) => (
|
||||
<React.Fragment key={tool.id}>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-gray-2)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
position: 'relative',
|
||||
padding: 'var(--mantine-spacing-xs)'
|
||||
}}
|
||||
>
|
||||
{/* Delete X in top right */}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => onToolRemove(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={{ 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) => handleToolSelect(index, newOperation)}
|
||||
excludeTools={['automate']}
|
||||
toolRegistry={toolRegistry}
|
||||
selectedValue={tool.operation}
|
||||
placeholder={tool.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings cog - only show if tool is selected, aligned right */}
|
||||
{tool.operation && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => onToolConfigure(index)}
|
||||
title={t('automate.creation.tools.configure', 'Configure tool')}
|
||||
style={{ color: 'var(--mantine-color-gray-6)' }}
|
||||
>
|
||||
<SettingsIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</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 < tools.length - 1 && (
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<Text size="xs" c="dimmed">↓</Text>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Arrow before Add Tool Button */}
|
||||
{tools.length > 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<Text size="xs" c="dimmed">↓</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Tool Button */}
|
||||
<div style={{
|
||||
border: '1px solid var(--mantine-color-gray-2)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<AutomationEntry
|
||||
title={t('automate.creation.tools.addTool', 'Add Tool')}
|
||||
badgeIcon={AddCircleOutline}
|
||||
operations={[]}
|
||||
onClick={onToolAdd}
|
||||
keepIconColor={true}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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';
|
||||
import { useToolSections } from '../../../hooks/useToolSections';
|
||||
import { renderToolButtons } from '../shared/renderToolButtons';
|
||||
import ToolSearch from '../toolPicker/ToolSearch';
|
||||
|
||||
interface ToolSelectorProps {
|
||||
onSelect: (toolKey: string) => void;
|
||||
excludeTools?: string[];
|
||||
toolRegistry: Record<string, ToolRegistryEntry>; // Pass registry as prop to break circular dependency
|
||||
selectedValue?: string; // For showing current selection when editing existing tool
|
||||
placeholder?: string; // Custom placeholder text
|
||||
}
|
||||
|
||||
export default function ToolSelector({
|
||||
onSelect,
|
||||
excludeTools = [],
|
||||
toolRegistry,
|
||||
selectedValue,
|
||||
placeholder
|
||||
}: ToolSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Filter out excluded tools (like 'automate' itself)
|
||||
const baseFilteredTools = useMemo(() => {
|
||||
return Object.entries(toolRegistry).filter(([key]) => !excludeTools.includes(key));
|
||||
}, [toolRegistry, excludeTools]);
|
||||
|
||||
// Apply search filter
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
return baseFilteredTools;
|
||||
}
|
||||
|
||||
const lowercaseSearch = searchTerm.toLowerCase();
|
||||
return baseFilteredTools.filter(([key, tool]) => {
|
||||
return (
|
||||
tool.name.toLowerCase().includes(lowercaseSearch) ||
|
||||
tool.description?.toLowerCase().includes(lowercaseSearch) ||
|
||||
key.toLowerCase().includes(lowercaseSearch)
|
||||
);
|
||||
});
|
||||
}, [baseFilteredTools, searchTerm]);
|
||||
|
||||
// Create filtered tool registry for ToolSearch
|
||||
const filteredToolRegistry = useMemo(() => {
|
||||
const registry: Record<string, ToolRegistryEntry> = {};
|
||||
baseFilteredTools.forEach(([key, tool]) => {
|
||||
registry[key] = tool;
|
||||
});
|
||||
return registry;
|
||||
}, [baseFilteredTools]);
|
||||
|
||||
// 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 = useCallback((toolKey: string) => {
|
||||
onSelect(toolKey);
|
||||
setOpened(false);
|
||||
setSearchTerm(''); // Clear search to show the selected tool display
|
||||
}, [onSelect]);
|
||||
|
||||
const renderedTools = useMemo(() =>
|
||||
displayGroups.map((subcategory) =>
|
||||
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching)
|
||||
), [displayGroups, handleToolSelect, isSearching, t]
|
||||
);
|
||||
|
||||
const handleSearchFocus = () => {
|
||||
setOpened(true);
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
if (!opened) {
|
||||
setOpened(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Get display value for selected tool
|
||||
const getDisplayValue = () => {
|
||||
if (selectedValue && toolRegistry[selectedValue]) {
|
||||
return toolRegistry[selectedValue].name;
|
||||
}
|
||||
return placeholder || t('automate.creation.tools.add', 'Add a tool...');
|
||||
};
|
||||
|
||||
return (
|
||||
<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} style={{ minWidth: '16rem' }}>
|
||||
<ScrollArea h={350}>
|
||||
<Stack gap="sm" p="sm">
|
||||
{displayGroups.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
||||
{isSearching
|
||||
? t('tools.noSearchResults', 'No tools found')
|
||||
: t('tools.noTools', 'No tools available')
|
||||
}
|
||||
</Text>
|
||||
) : (
|
||||
renderedTools
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Stack, Text, Divider, Card, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
|
||||
|
||||
export interface SuggestedToolsSectionProps {}
|
||||
|
||||
export function SuggestedToolsSection(): React.ReactElement {
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface FilesStepConfig {
|
||||
isCollapsed?: boolean;
|
||||
placeholder?: string;
|
||||
onCollapsedClick?: () => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
export interface MiddleStepConfig {
|
||||
@@ -63,7 +64,7 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
<Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}>
|
||||
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
|
||||
{/* Files Step */}
|
||||
{steps.createFilesStep({
|
||||
{config.files.isVisible !== false && steps.createFilesStep({
|
||||
selectedFiles: config.files.selectedFiles,
|
||||
isCollapsed: config.files.isCollapsed,
|
||||
placeholder: config.files.placeholder,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack } from '@mantine/core';
|
||||
import ToolButton from '../toolPicker/ToolButton';
|
||||
import SubcategoryHeader from './SubcategoryHeader';
|
||||
|
||||
import { getSubcategoryLabel } from "../../../data/toolsTaxonomy";
|
||||
import { TFunction } from 'i18next';
|
||||
import { SubcategoryGroup } from '../../../hooks/useToolSections';
|
||||
|
||||
// Helper function to render tool buttons for a subcategory
|
||||
export const renderToolButtons = (
|
||||
t: TFunction,
|
||||
subcategory: SubcategoryGroup,
|
||||
selectedToolKey: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
showSubcategoryHeader: boolean = true
|
||||
) => (
|
||||
<Box key={subcategory.subcategoryId} w="100%">
|
||||
{showSubcategoryHeader && (
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
|
||||
)}
|
||||
<Stack gap="xs">
|
||||
{subcategory.tools.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
@@ -12,19 +12,26 @@ interface ToolSearchProps {
|
||||
onToolSelect?: (toolId: string) => void;
|
||||
mode: 'filter' | 'dropdown';
|
||||
selectedToolKey?: string | null;
|
||||
placeholder?: string;
|
||||
hideIcon?: boolean;
|
||||
onFocus?: () => void;
|
||||
}
|
||||
|
||||
const ToolSearch = ({
|
||||
value,
|
||||
onChange,
|
||||
toolRegistry,
|
||||
onToolSelect,
|
||||
const ToolSearch = ({
|
||||
value,
|
||||
onChange,
|
||||
toolRegistry,
|
||||
onToolSelect,
|
||||
mode = 'filter',
|
||||
selectedToolKey
|
||||
selectedToolKey,
|
||||
placeholder,
|
||||
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 [];
|
||||
@@ -47,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);
|
||||
}
|
||||
};
|
||||
@@ -61,9 +73,10 @@ const ToolSearch = ({
|
||||
ref={searchRef}
|
||||
value={value}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
|
||||
icon={<span className="material-symbols-rounded">search</span>}
|
||||
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
|
||||
icon={hideIcon ? undefined : <span className="material-symbols-rounded">search</span>}
|
||||
autoComplete="off"
|
||||
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -77,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' }}>
|
||||
@@ -97,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}
|
||||
@@ -126,4 +142,4 @@ const ToolSearch = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolSearch;
|
||||
export default ToolSearch;
|
||||
|
||||
Reference in New Issue
Block a user