V2 results flow (#4196)

Better tool flow for reusability
Pinning 
Styling of tool flow
consumption of files after tooling

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
ConnorYoh
2025-08-15 14:43:30 +01:00
committed by GitHub
parent 1468df3e21
commit 4c17c520d7
40 changed files with 1474 additions and 1274 deletions

View File

@@ -39,7 +39,7 @@ export default function ToolPanel() {
}`}
style={{
width: isPanelVisible ? '20rem' : '0',
padding: isPanelVisible ? '0.5rem' : '0'
padding: '0'
}}
>
<div

View File

@@ -1,14 +1,7 @@
import React, { useState } from "react";
import { Button, Stack, Text, NumberInput, Select } from "@mantine/core";
import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface CompressParameters {
compressionMethod: 'quality' | 'filesize';
compressionLevel: number;
fileSizeValue: string;
fileSizeUnit: 'KB' | 'MB';
grayscale: boolean;
}
import { CompressParameters } from "../../../hooks/tools/compress/useCompressOperation";
interface CompressSettingsProps {
parameters: CompressParameters;
@@ -22,6 +15,8 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
return (
<Stack gap="md">
<Divider ml='-md'></Divider>
{/* Compression Method */}
<Stack gap="sm">
<Text size="sm" fw={500}>Compression Method</Text>
@@ -54,6 +49,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
{/* Quality Adjustment */}
{parameters.compressionMethod === 'quality' && (
<Stack gap="sm">
<Divider />
<Text size="sm" fw={500}>Compression Level</Text>
<div style={{ position: 'relative' }}>
<input
@@ -68,7 +64,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
onTouchStart={() => setIsSliding(true)}
onTouchEnd={() => setIsSliding(false)}
disabled={disabled}
style={{
style={{
width: '100%',
height: '6px',
borderRadius: '3px',
@@ -107,6 +103,8 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
</Stack>
)}
<Divider/>
{/* File Size Input */}
{parameters.compressionMethod === 'filesize' && (
<Stack gap="sm">
@@ -141,7 +139,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
{/* Compression Options */}
<Stack gap="sm">
<label
<label
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
title="Converts all images in the PDF to grayscale, which can significantly reduce file size while maintaining readability"
>
@@ -158,4 +156,4 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
);
};
export default CompressSettings;
export default CompressSettings;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { Stack, Text } from '@mantine/core';
import { formatFileSize, getFileDate } from '../../../utils/fileUtils';
export interface FileMetadataProps {
file: File;
}
const FileMetadata = ({ file }: FileMetadataProps) => {
return (
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Stack gap="0.125rem">
<Text size="xs" c="dimmed">
{formatFileSize(file.size)}
</Text>
<Text size="xs" c="dimmed">
{file.type || 'Unknown'}
</Text>
<Text size="xs" c="dimmed">
{getFileDate(file)}
</Text>
</Stack>
</Stack>
);
};
export default FileMetadata;

View File

@@ -3,17 +3,15 @@ import { Text } from '@mantine/core';
export interface FileStatusIndicatorProps {
selectedFiles?: File[];
isCompleted?: boolean;
placeholder?: string;
showFileName?: boolean;
}
const FileStatusIndicator = ({
selectedFiles = [],
isCompleted = false,
placeholder = "Select a PDF file in the main view to get started",
showFileName = true
placeholder = "Select a PDF file in the main view to get started"
}: FileStatusIndicatorProps) => {
// Only show content when no files are selected
if (selectedFiles.length === 0) {
return (
<Text size="sm" c="dimmed">
@@ -22,19 +20,8 @@ const FileStatusIndicator = ({
);
}
if (isCompleted) {
return (
<Text size="sm" c="green">
Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
</Text>
);
}
return (
<Text size="sm" c="blue">
Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
</Text>
);
// Return nothing when files are selected
return null;
}
export default FileStatusIndicator;

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FileStatusIndicator from './FileStatusIndicator';
export interface FilesToolStepProps {
selectedFiles: File[];
isCollapsed?: boolean;
onCollapsedClick?: () => void;
placeholder?: string;
}
export function createFilesToolStep(
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
props: FilesToolStepProps
): React.ReactElement {
const { t } = useTranslation();
return createStep(t("files.title", "Files"), {
isVisible: true,
isCollapsed: props.isCollapsed,
onCollapsedClick: props.onCollapsedClick
}, (
<FileStatusIndicator
selectedFiles={props.selectedFiles}
placeholder={props.placeholder || t("files.placeholder", "Select a PDF file in the main view to get started")}
/>
));
}

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Stack, Group, ActionIcon, Text } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
export interface NavigationControlsProps {
currentIndex: number;
totalFiles: number;
onPrevious: () => void;
onNext: () => void;
}
const NavigationControls = ({
currentIndex,
totalFiles,
onPrevious,
onNext,
}: NavigationControlsProps) => {
if (totalFiles <= 1) return null;
return (
<Stack align="center" gap="xs" mt="xs">
<Group justify="center" gap="xs">
<ActionIcon
variant="light"
size="sm"
onClick={onPrevious}
disabled={totalFiles <= 1}
data-testid="review-panel-prev"
>
<ChevronLeftIcon style={{ fontSize: '1rem' }} />
</ActionIcon>
<Text size="xs" c="dimmed">
{currentIndex + 1} of {totalFiles}
</Text>
<ActionIcon
variant="light"
size="sm"
onClick={onNext}
disabled={totalFiles <= 1}
data-testid="review-panel-next"
>
<ChevronRightIcon style={{ fontSize: '1rem' }} />
</ActionIcon>
</Group>
</Stack>
);
};
export default NavigationControls;

View File

@@ -24,7 +24,7 @@ const OperationButton = ({
submitText,
variant = 'filled',
color = 'blue',
fullWidth = true,
fullWidth = false,
mt = 'md',
type = 'button',
'data-testid': dataTestId
@@ -36,14 +36,17 @@ const OperationButton = ({
type={type}
onClick={onClick}
fullWidth={fullWidth}
mr='md'
ml='md'
mt={mt}
loading={isLoading}
disabled={disabled}
variant={variant}
color={color}
data-testid={dataTestId}
style={{ minHeight: '2.5rem' }}
>
{isLoading
{isLoading
? (loadingText || t("loading", "Loading..."))
: (submitText || t("submit", "Submit"))
}
@@ -51,4 +54,4 @@ const OperationButton = ({
);
}
export default OperationButton;
export default OperationButton;

View File

@@ -1,15 +1,18 @@
import { Grid, Paper, Box, Image, Text, Loader, Stack, Center } from '@mantine/core';
import React, { useState } from 'react';
import { Box, Text, Loader, Stack, Center, Flex } from '@mantine/core';
import FilePreview from '../../shared/FilePreview';
import FileMetadata from './FileMetadata';
import NavigationControls from './NavigationControls';
export interface ResultFile {
export interface ReviewFile {
file: File;
thumbnail?: string;
}
export interface ResultsPreviewProps {
files: ResultFile[];
files: ReviewFile[];
isGeneratingThumbnails?: boolean;
onFileClick?: (file: File) => void;
title?: string;
emptyMessage?: string;
loadingMessage?: string;
}
@@ -18,14 +21,17 @@ const ResultsPreview = ({
files,
isGeneratingThumbnails = false,
onFileClick,
title,
emptyMessage = "No files to preview",
loadingMessage = "Generating previews..."
}: ResultsPreviewProps) => {
const formatSize = (size: number) => {
if (size > 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
if (size > 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${size} B`;
const [currentIndex, setCurrentIndex] = useState(0);
const handlePrevious = () => {
setCurrentIndex((prev) => (prev === 0 ? files.length - 1 : prev - 1));
};
const handleNext = () => {
setCurrentIndex((prev) => (prev === files.length - 1 ? 0 : prev + 1));
};
if (files.length === 0 && !isGeneratingThumbnails) {
@@ -36,78 +42,59 @@ const ResultsPreview = ({
);
}
return (
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }} data-testid="results-preview-container">
{title && (
<Text fw={500} size="md" mb="sm" data-testid="results-preview-title">
{title} ({files.length} files)
</Text>
)}
if (isGeneratingThumbnails) {
return (
<Center p="lg" data-testid="review-panel-loading">
<Stack align="center" gap="sm">
<Loader size="sm" />
<Text size="sm" c="dimmed">{loadingMessage}</Text>
</Stack>
</Center>
);
}
{isGeneratingThumbnails ? (
<Center p="lg" data-testid="results-preview-loading">
<Stack align="center" gap="sm">
<Loader size="sm" />
<Text size="sm" c="dimmed">{loadingMessage}</Text>
</Stack>
</Center>
) : (
<Grid data-testid="results-preview-grid">
{files.map((result, index) => (
<Grid.Col span={{ base: 6, sm: 4, md: 3 }} key={index}>
<Paper
p="xs"
withBorder
onClick={() => onFileClick?.(result.file)}
data-testid={`results-preview-thumbnail-${index}`}
style={{
textAlign: 'center',
height: '10rem',
width:'5rem',
display: 'flex',
flexDirection: 'column',
cursor: onFileClick ? 'pointer' : 'default',
transition: 'all 0.2s ease'
}}
>
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{result.thumbnail ? (
<Image
src={result.thumbnail}
alt={`Preview of ${result.file.name}`}
style={{
maxWidth: '100%',
maxHeight: '9rem',
objectFit: 'contain'
}}
/>
) : (
<Text size="xs" c="dimmed">No preview</Text>
)}
</Box>
<Text
size="xs"
c="dimmed"
mt="xs"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
title={result.file.name}
>
{result.file.name}
</Text>
<Text size="xs" c="dimmed">
{formatSize(result.file.size)}
</Text>
</Paper>
</Grid.Col>
))}
</Grid>
)}
const currentFile = files[currentIndex];
if (!currentFile) return null;
return (
<Box p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.5rem', maxWidth: '100%' }} data-testid="review-panel-container">
{/* File name at the top */}
<Box mb="sm" style={{ minHeight: '3rem', display: 'flex', alignItems: 'flex-start' }}>
<Text
size="sm"
fw={500}
style={{
wordBreak: 'break-word',
lineHeight: 1.4
}}
title={currentFile.file.name}
>
{currentFile.file.name}
</Text>
</Box>
<Flex gap="md" align="flex-start" style={{ minHeight: '7.5rem', maxWidth: '100%' }} data-testid={`review-panel-item-${currentIndex}`}>
<Box style={{ width: '6.25rem', height: '7.5rem', flexShrink: 0 }}>
<FilePreview
file={currentFile.file}
thumbnail={currentFile.thumbnail}
showHoverOverlay={true}
onFileClick={onFileClick ? (file) => file && onFileClick(file as File) : undefined}
/>
</Box>
<FileMetadata file={currentFile.file} />
</Flex>
{/* Navigation controls */}
<NavigationControls
currentIndex={currentIndex}
totalFiles={files.length}
onPrevious={handlePrevious}
onNext={handleNext}
/>
</Box>
);
}
};
export default ResultsPreview;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { Button, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DownloadIcon from '@mui/icons-material/Download';
import ErrorNotification from './ErrorNotification';
import ResultsPreview from './ResultsPreview';
import { SuggestedToolsSection } from './SuggestedToolsSection';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
export interface ReviewToolStepProps<TParams = unknown> {
isVisible: boolean;
operation: ToolOperationHook<TParams>;
title?: string;
onFileClick?: (file: File) => void;
}
export function createReviewToolStep<TParams = unknown>(
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
props: ReviewToolStepProps<TParams>
): React.ReactElement {
const { t } = useTranslation();
const { operation } = props;
const previewFiles = operation.files?.map((file, index) => ({
file,
thumbnail: operation.thumbnails[index]
})) || [];
return createStep(t("review", "Review"), {
isVisible: props.isVisible,
_excludeFromCount: true,
_noPadding: true
}, (
<Stack gap="sm" >
<ErrorNotification
error={operation.errorMessage}
onClose={operation.clearError}
/>
{previewFiles.length > 0 && (
<ResultsPreview
files={previewFiles}
onFileClick={props.onFileClick}
isGeneratingThumbnails={operation.isGeneratingThumbnails}
/>
)}
{operation.downloadUrl && (
<Button
component="a"
href={operation.downloadUrl}
download={operation.downloadFilename}
leftSection={<DownloadIcon />}
color="blue"
fullWidth
mb="md"
>
{t("download", "Download")}
</Button>
)}
<SuggestedToolsSection />
</Stack>
));
}

View File

@@ -0,0 +1,42 @@
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 {
const { t } = useTranslation();
const suggestedTools = useSuggestedTools();
return (
<Stack gap="md">
<Divider />
<Text size="lg" fw={600}>
{t('editYourNewFiles', 'Edit your new File(s)')}
</Text>
<Stack gap="xs">
{suggestedTools.map((tool) => {
const IconComponent = tool.icon;
return (
<Card
key={tool.name}
p="sm"
withBorder
style={{ cursor: 'pointer' }}
onClick={tool.navigate}
>
<Group gap="xs">
<IconComponent fontSize="small" />
<Text size="sm" fw={500}>
{tool.title}
</Text>
</Group>
</Card>
);
})}
</Stack>
</Stack>
);
}

View File

@@ -1,13 +1,14 @@
import React, { createContext, useContext, useMemo, useRef } from 'react';
import { Paper, Text, Stack, Box, Flex } from '@mantine/core';
import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { Tooltip } from '../../shared/Tooltip';
import { TooltipTip } from '../../shared/tooltip/TooltipContent';
import { createFilesToolStep, FilesToolStepProps } from './FilesToolStep';
import { createReviewToolStep, ReviewToolStepProps } from './ReviewToolStep';
interface ToolStepContextType {
visibleStepCount: number;
getStepNumber: () => number;
}
const ToolStepContext = createContext<ToolStepContextType | null>(null);
@@ -16,12 +17,13 @@ export interface ToolStepProps {
title: string;
isVisible?: boolean;
isCollapsed?: boolean;
isCompleted?: boolean;
onCollapsedClick?: () => void;
children?: React.ReactNode;
completedMessage?: string;
helpText?: string;
showNumber?: boolean;
_stepNumber?: number; // Internal prop set by ToolStepContainer
_excludeFromCount?: boolean; // Internal prop to exclude from visible count calculation
_noPadding?: boolean; // Internal prop to exclude from default left padding
tooltip?: {
content?: React.ReactNode;
tips?: TooltipTip[];
@@ -56,7 +58,7 @@ const renderTooltipTitle = (
</Tooltip>
);
}
return (
<Text fw={500} size="lg">
{title}
@@ -68,39 +70,40 @@ const ToolStep = ({
title,
isVisible = true,
isCollapsed = false,
isCompleted = false,
onCollapsedClick,
children,
completedMessage,
helpText,
showNumber,
_stepNumber,
_noPadding,
tooltip
}: ToolStepProps) => {
if (!isVisible) return null;
const parent = useContext(ToolStepContext);
// Auto-detect if we should show numbers based on sibling count
const shouldShowNumber = useMemo(() => {
if (showNumber !== undefined) return showNumber;
return parent ? parent.visibleStepCount >= 3 : false;
}, [showNumber, parent]);
const stepNumber = parent?.getStepNumber?.() || 1;
const stepNumber = _stepNumber;
return (
<Paper
p="md"
withBorder
style={{
opacity: isCollapsed ? 0.8 : 1,
transition: 'opacity 0.2s ease'
}}
>
<div>
<div
style={{
padding: '1rem',
opacity: isCollapsed ? 0.8 : 1,
color: isCollapsed ? 'var(--mantine-color-dimmed)' : 'inherit',
transition: 'opacity 0.2s ease, color 0.2s ease'
}}
>
{/* Chevron icon to collapse/expand the step */}
<Flex
align="center"
justify="space-between"
<Flex
align="center"
justify="space-between"
mb="sm"
style={{
cursor: onCollapsedClick ? 'pointer' : 'default'
@@ -115,37 +118,24 @@ const ToolStep = ({
)}
{renderTooltipTitle(title, tooltip, isCollapsed)}
</Flex>
{isCollapsed ? (
<ChevronRightIcon style={{
fontSize: '1.2rem',
<ChevronRightIcon style={{
fontSize: '1.2rem',
color: 'var(--mantine-color-dimmed)',
opacity: onCollapsedClick ? 1 : 0.5
}} />
) : (
<ExpandMoreIcon style={{
fontSize: '1.2rem',
<ExpandMoreIcon style={{
fontSize: '1.2rem',
color: 'var(--mantine-color-dimmed)',
opacity: onCollapsedClick ? 1 : 0.5
}} />
)}
</Flex>
{isCollapsed ? (
<Box>
{isCompleted && completedMessage && (
<Text size="sm" c="green">
{completedMessage}
{onCollapsedClick && (
<Text span c="dimmed" size="xs" ml="sm">
(click to change)
</Text>
)}
</Text>
)}
</Box>
) : (
<Stack gap="md">
{!isCollapsed && (
<Stack gap="md" pl={_noPadding ? 0 : "md"}>
{helpText && (
<Text size="sm" c="dimmed">
{helpText}
@@ -154,36 +144,77 @@ const ToolStep = ({
{children}
</Stack>
)}
</Paper>
</div>
<Divider style={{ marginLeft: '1rem', marginRight: '-0.5rem' }} />
</div>
);
}
export interface ToolStepContainerProps {
children: React.ReactNode;
// ToolStepFactory for creating numbered steps
export function createToolSteps() {
let stepNumber = 1;
const steps: React.ReactElement[] = [];
const create = (
title: string,
props: Omit<ToolStepProps, 'title' | '_stepNumber'> = {},
children?: React.ReactNode
): React.ReactElement => {
const isVisible = props.isVisible !== false;
const currentStepNumber = isVisible ? stepNumber++ : undefined;
const step = React.createElement(ToolStep, {
...props,
title,
_stepNumber: currentStepNumber,
children,
key: `step-${title.toLowerCase().replace(/\s+/g, '-')}`
});
steps.push(step);
return step;
};
const createFilesStep = (props: FilesToolStepProps): React.ReactElement => {
return createFilesToolStep(create, props);
};
const createReviewStep = <TParams = unknown>(props: ReviewToolStepProps<TParams>): React.ReactElement => {
return createReviewToolStep(create, props);
};
const getVisibleCount = () => {
return steps.filter(step => {
const props = step.props as ToolStepProps;
const isVisible = props.isVisible !== false;
const excludeFromCount = props._excludeFromCount === true;
return isVisible && !excludeFromCount;
}).length;
};
return { create, createFilesStep, createReviewStep, getVisibleCount, steps };
}
export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
const stepCounterRef = useRef(0);
// Count visible ToolStep children
// Context provider wrapper for tools using the factory
export function ToolStepProvider({ children }: { children: React.ReactNode }) {
// Count visible steps from children that are ToolStep elements
const visibleStepCount = useMemo(() => {
let count = 0;
React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type === ToolStep) {
const isVisible = (child.props as ToolStepProps).isVisible !== false;
if (isVisible) count++;
const props = child.props as ToolStepProps;
const isVisible = props.isVisible !== false;
const excludeFromCount = props._excludeFromCount === true;
if (isVisible && !excludeFromCount) count++;
}
});
return count;
}, [children]);
const contextValue = useMemo(() => ({
visibleStepCount,
getStepNumber: () => ++stepCounterRef.current
visibleStepCount
}), [visibleStepCount]);
stepCounterRef.current = 0;
return (
<ToolStepContext.Provider value={contextValue}>
{children}
@@ -191,4 +222,6 @@ export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
);
}
export type { FilesToolStepProps } from './FilesToolStep';
export type { ReviewToolStepProps } from './ReviewToolStep';
export default ToolStep;

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { Stack } from '@mantine/core';
import { createToolSteps, ToolStepProvider } from './ToolStep';
import OperationButton from './OperationButton';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
export interface FilesStepConfig {
selectedFiles: File[];
isCollapsed?: boolean;
placeholder?: string;
onCollapsedClick?: () => void;
}
export interface MiddleStepConfig {
title: string;
isVisible?: boolean;
isCollapsed?: boolean;
onCollapsedClick?: () => void;
content: React.ReactNode;
tooltip?: {
content?: React.ReactNode;
tips?: any[];
header?: {
title: string;
logo?: React.ReactNode;
};
};
}
export interface ExecuteButtonConfig {
text: string;
loadingText: string;
onClick: () => Promise<void>;
isVisible?: boolean;
disabled?: boolean;
testId?: string;
}
export interface ReviewStepConfig {
isVisible: boolean;
operation: ToolOperationHook<any>;
title: string;
onFileClick?: (file: File) => void;
testId?: string;
}
export interface ToolFlowConfig {
files: FilesStepConfig;
steps: MiddleStepConfig[];
executeButton?: ExecuteButtonConfig;
review: ReviewStepConfig;
}
/**
* Creates a flexible tool flow with configurable steps and state management left to the tool.
* Reduces boilerplate while allowing tools to manage their own collapse/expansion logic.
*/
export function createToolFlow(config: ToolFlowConfig) {
const steps = createToolSteps();
return (
<Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}>
<ToolStepProvider>
{/* Files Step */}
{steps.createFilesStep({
selectedFiles: config.files.selectedFiles,
isCollapsed: config.files.isCollapsed,
placeholder: config.files.placeholder,
onCollapsedClick: config.files.onCollapsedClick
})}
{/* Middle Steps */}
{config.steps.map((stepConfig, index) =>
steps.create(stepConfig.title, {
isVisible: stepConfig.isVisible,
isCollapsed: stepConfig.isCollapsed,
onCollapsedClick: stepConfig.onCollapsedClick,
tooltip: stepConfig.tooltip
}, stepConfig.content)
)}
{/* Execute Button */}
{config.executeButton && config.executeButton.isVisible !== false && (
<OperationButton
onClick={config.executeButton.onClick}
isLoading={config.review.operation.isLoading}
disabled={config.executeButton.disabled}
loadingText={config.executeButton.loadingText}
submitText={config.executeButton.text}
data-testid={config.executeButton.testId}
/>
)}
{/* Review Step */}
{steps.createReviewStep({
isVisible: config.review.isVisible,
operation: config.review.operation,
title: config.review.title,
onFileClick: config.review.onFileClick
})}
</ToolStepProvider>
</Stack>
);
}