This commit is contained in:
Connor Yoh 2025-08-12 16:48:34 +01:00
parent 9d2ca3c8c8
commit 3e4afd166e
5 changed files with 220 additions and 157 deletions

View File

@ -0,0 +1,106 @@
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;
isCompleted?: boolean;
onCollapsedClick?: () => void;
completedMessage?: string;
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 ResultsStepConfig {
isVisible: boolean;
operation: ToolOperationHook<any>;
title: string;
onFileClick?: (file: File) => void;
testId?: string;
}
export interface ToolFlowConfig {
files: FilesStepConfig;
steps: MiddleStepConfig[];
executeButton?: ExecuteButtonConfig;
results: ResultsStepConfig;
}
/**
* 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 (
<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,
isCompleted: stepConfig.isCompleted,
onCollapsedClick: stepConfig.onCollapsedClick,
completedMessage: stepConfig.completedMessage,
tooltip: stepConfig.tooltip
}, stepConfig.content)
)}
{/* Execute Button */}
{config.executeButton && config.executeButton.isVisible !== false && (
<OperationButton
onClick={config.executeButton.onClick}
isLoading={config.results.operation.isLoading}
disabled={config.executeButton.disabled}
loadingText={config.executeButton.loadingText}
submitText={config.executeButton.text}
data-testid={config.executeButton.testId}
/>
)}
{/* Results Step */}
{steps.createResultsStep({
isVisible: config.results.isVisible,
operation: config.results.operation,
title: config.results.title,
onFileClick: config.results.onFileClick
})}
</ToolStepProvider>
);
}

View File

@ -1,12 +1,11 @@
import React, { useEffect, useMemo } from "react"; import React, { useEffect } from "react";
import { Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext"; import { useToolFileSelection } from "../contexts/FileSelectionContext";
import { createToolSteps, ToolStepProvider } from "../components/tools/shared/ToolStep"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import OperationButton from "../components/tools/shared/OperationButton";
import CompressSettings from "../components/tools/compress/CompressSettings"; import CompressSettings from "../components/tools/compress/CompressSettings";
@ -65,49 +64,41 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const filesCollapsed = hasFiles; const filesCollapsed = hasFiles;
const settingsCollapsed = !hasFiles || hasResults; const settingsCollapsed = !hasFiles || hasResults;
const steps = createToolSteps();
return ( return (
<Stack gap="md" h="100%" p="sm" style={{ overflow: 'auto' }}> <Stack gap="md" h="100%" p="sm" style={{ overflow: 'auto' }}>
<ToolStepProvider> {createToolFlow({
{/* Files Step */} files: {
{steps.createFilesStep({
selectedFiles, selectedFiles,
isCollapsed: filesCollapsed isCollapsed: filesCollapsed
})} },
steps: [{
{/* Settings Step */} title: "Settings",
{steps.create("Settings", {
isCollapsed: settingsCollapsed, isCollapsed: settingsCollapsed,
isCompleted: hasResults, isCompleted: hasResults,
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined, onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
completedMessage: t("compress.header", "Compression completed"), completedMessage: t("compress.header", "Compression completed"),
tooltip: compressTips tooltip: compressTips,
}, ( content: (
<Stack gap="md">
<CompressSettings <CompressSettings
parameters={compressParams.parameters} parameters={compressParams.parameters}
onParameterChange={compressParams.updateParameter} onParameterChange={compressParams.updateParameter}
disabled={endpointLoading} disabled={endpointLoading}
/> />
</Stack> )
))} }],
<OperationButton executeButton: {
onClick={handleCompress} text: t("compress.submit", "Compress"),
isLoading={compressOperation.isLoading} loadingText: t("loading"),
disabled={!compressParams.validateParameters() || !hasFiles || !endpointEnabled} onClick: handleCompress,
loadingText={t("loading")} disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled
submitText={t("compress.submit", "Compress")} },
/> results: {
{/* Results Step */}
{steps.createResultsStep({
isVisible: hasResults, isVisible: hasResults,
operation: compressOperation, operation: compressOperation,
title: t("compress.title", "Compression Results"), title: t("compress.title", "Compression Results"),
onFileClick: handleThumbnailClick onFileClick: handleThumbnailClick
}
})} })}
</ToolStepProvider>
</Stack> </Stack>
); );
} }

View File

@ -5,8 +5,7 @@ import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext"; import { useToolFileSelection } from "../contexts/FileSelectionContext";
import { createToolSteps, ToolStepProvider } from "../components/tools/shared/ToolStep"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import OperationButton from "../components/tools/shared/OperationButton";
import ConvertSettings from "../components/tools/convert/ConvertSettings"; import ConvertSettings from "../components/tools/convert/ConvertSettings";
@ -101,26 +100,21 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
setCurrentMode('convert'); setCurrentMode('convert');
}; };
const steps = createToolSteps();
return ( return (
<div className="h-full max-h-screen overflow-y-auto" ref={scrollContainerRef}> <div className="h-full max-h-screen overflow-y-auto" ref={scrollContainerRef}>
<Stack gap="sm" p="sm"> <Stack gap="sm" p="sm">
<ToolStepProvider> {createToolFlow({
{/* Files Step */} files: {
{steps.createFilesStep({
selectedFiles, selectedFiles,
isCollapsed: filesCollapsed, isCollapsed: filesCollapsed,
placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started") placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started")
})} },
steps: [{
{/* Settings Step */} title: t("convert.settings", "Settings"),
{steps.create(t("convert.settings", "Settings"), {
isCollapsed: settingsCollapsed, isCollapsed: settingsCollapsed,
isCompleted: settingsCollapsed, isCompleted: settingsCollapsed,
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined, onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
}, ( content: (
<Stack gap="sm">
<ConvertSettings <ConvertSettings
parameters={convertParams.parameters} parameters={convertParams.parameters}
onParameterChange={convertParams.updateParameter} onParameterChange={convertParams.updateParameter}
@ -128,27 +122,24 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
selectedFiles={selectedFiles} selectedFiles={selectedFiles}
disabled={endpointLoading} disabled={endpointLoading}
/> />
</Stack> )
))} }],
{!hasResults && ( executeButton: {
<OperationButton text: t("convert.convertFiles", "Convert Files"),
onClick={handleConvert} loadingText: t("convert.converting", "Converting..."),
isLoading={convertOperation.isLoading} onClick: handleConvert,
disabled={!convertParams.validateParameters() || !hasFiles || !endpointEnabled} isVisible: !hasResults,
loadingText={t("convert.converting", "Converting...")} disabled: !convertParams.validateParameters() || !hasFiles || !endpointEnabled,
submitText={t("convert.convertFiles", "Convert Files")} testId: "convert-button"
data-testid="convert-button" },
/> results: {
)}
{/* Results Step */}
{steps.createResultsStep({
isVisible: hasResults, isVisible: hasResults,
operation: convertOperation, operation: convertOperation,
title: t("convert.conversionResults", "Conversion Results"), title: t("convert.conversionResults", "Conversion Results"),
onFileClick: handleThumbnailClick onFileClick: handleThumbnailClick,
testId: "conversion-results"
}
})} })}
</ToolStepProvider>
</Stack> </Stack>
</div> </div>
); );

View File

@ -1,12 +1,11 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useState } from "react";
import { Stack, Box } from "@mantine/core"; import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext"; import { useToolFileSelection } from "../contexts/FileSelectionContext";
import { createToolSteps, ToolStepProvider } from "../components/tools/shared/ToolStep"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import OperationButton from "../components/tools/shared/OperationButton";
import OCRSettings from "../components/tools/ocr/OCRSettings"; import OCRSettings from "../components/tools/ocr/OCRSettings";
import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings"; import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings";
@ -75,78 +74,66 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
}; };
const resultsVisible = hasResults;
const filesCollapsed = expandedStep !== 'files'; const filesCollapsed = expandedStep !== 'files';
const settingsCollapsed = expandedStep !== 'settings'; const settingsCollapsed = expandedStep !== 'settings';
const steps = createToolSteps();
return ( return (
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}> <Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
<ToolStepProvider> {createToolFlow({
{/* Files Step */} files: {
{steps.createFilesStep({
selectedFiles, selectedFiles,
isCollapsed: hasFiles && filesCollapsed, isCollapsed: hasFiles && filesCollapsed,
})} },
steps: [
{/* Settings Step */} {
{steps.create("Settings", { title: "Settings",
isCollapsed: !hasFiles || settingsCollapsed, isCollapsed: !hasFiles || settingsCollapsed,
isCompleted: hasFiles && hasValidSettings, isCompleted: hasFiles && hasValidSettings,
onCollapsedClick: () => { onCollapsedClick: () => {
if (!hasFiles) return; // Only allow if files are selected if (!hasFiles) return; // Only allow if files are selected
setExpandedStep(expandedStep === 'settings' ? null : 'settings'); setExpandedStep(expandedStep === 'settings' ? null : 'settings');
}, },
tooltip: ocrTips tooltip: ocrTips,
}, ( content: (
<Stack gap="sm">
<OCRSettings <OCRSettings
parameters={ocrParams.parameters} parameters={ocrParams.parameters}
onParameterChange={ocrParams.updateParameter} onParameterChange={ocrParams.updateParameter}
disabled={endpointLoading} disabled={endpointLoading}
/> />
</Stack> )
))} },
{
{/* Advanced Step */} title: "Advanced",
{steps.create("Advanced", {
isCollapsed: expandedStep !== 'advanced', isCollapsed: expandedStep !== 'advanced',
isCompleted: hasFiles && hasResults, isCompleted: hasFiles && hasResults,
onCollapsedClick: () => { onCollapsedClick: () => {
if (!hasFiles) return; // Only allow if files are selected if (!hasFiles) return; // Only allow if files are selected
setExpandedStep(expandedStep === 'advanced' ? null : 'advanced'); setExpandedStep(expandedStep === 'advanced' ? null : 'advanced');
}, },
}, ( content: (
<AdvancedOCRSettings <AdvancedOCRSettings
advancedOptions={ocrParams.parameters.additionalOptions} advancedOptions={ocrParams.parameters.additionalOptions}
ocrRenderType={ocrParams.parameters.ocrRenderType} ocrRenderType={ocrParams.parameters.ocrRenderType}
onParameterChange={ocrParams.updateParameter} onParameterChange={ocrParams.updateParameter}
disabled={endpointLoading} disabled={endpointLoading}
/> />
))} )
}
{/* Process Button - Available after all configuration */} ],
{hasValidSettings && !hasResults && ( executeButton: {
<OperationButton text: t("ocr.operation.submit", "Process OCR and Review"),
onClick={handleOCR} loadingText: t("loading"),
isLoading={ocrOperation.isLoading} onClick: handleOCR,
disabled={!ocrParams.validateParameters() || !hasFiles || !endpointEnabled} isVisible: hasValidSettings && !hasResults,
loadingText={t("loading")} disabled: !ocrParams.validateParameters() || !hasFiles || !endpointEnabled
submitText={t("ocr.operation.submit", "Process OCR and Review")} },
/> results: {
)} isVisible: hasResults,
{/* Results Step */}
{steps.createResultsStep({
isVisible: resultsVisible,
operation: ocrOperation, operation: ocrOperation,
title: t("ocr.results.title", "OCR Results"), title: t("ocr.results.title", "OCR Results"),
onFileClick: handleThumbnailClick onFileClick: handleThumbnailClick
}
})} })}
</ToolStepProvider>
</Stack> </Stack>
); );
} }

View File

@ -1,12 +1,11 @@
import React, { useEffect, useMemo } from "react"; import React, { useEffect } from "react";
import { Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext"; import { useToolFileSelection } from "../contexts/FileSelectionContext";
import { createToolSteps, ToolStepProvider } from "../components/tools/shared/ToolStep"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import OperationButton from "../components/tools/shared/OperationButton";
import SplitSettings from "../components/tools/split/SplitSettings"; import SplitSettings from "../components/tools/split/SplitSettings";
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters"; import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
@ -64,52 +63,41 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const filesCollapsed = hasFiles; const filesCollapsed = hasFiles;
const settingsCollapsed = !hasFiles || hasResults; const settingsCollapsed = !hasFiles || hasResults;
const steps = createToolSteps();
return ( return (
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}> <Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
<ToolStepProvider> {createToolFlow({
{/* Files Step */} files: {
{steps.createFilesStep({
selectedFiles, selectedFiles,
isCollapsed: filesCollapsed, isCollapsed: filesCollapsed,
placeholder: "Select a PDF file in the main view to get started" placeholder: "Select a PDF file in the main view to get started"
})} },
steps: [{
{/* Settings Step */} title: "Settings",
{steps.create("Settings", {
isCollapsed: settingsCollapsed, isCollapsed: settingsCollapsed,
isCompleted: hasResults, isCompleted: hasResults,
onCollapsedClick: hasResults ? handleSettingsReset : undefined, onCollapsedClick: hasResults ? handleSettingsReset : undefined,
}, ( content: (
<Stack gap="sm">
<SplitSettings <SplitSettings
parameters={splitParams.parameters} parameters={splitParams.parameters}
onParameterChange={splitParams.updateParameter} onParameterChange={splitParams.updateParameter}
disabled={endpointLoading} disabled={endpointLoading}
/> />
)
</Stack> }],
))} executeButton: {
text: t("split.submit", "Split PDF"),
{!hasResults && ( loadingText: t("loading"),
<OperationButton onClick: handleSplit,
onClick={handleSplit} isVisible: !hasResults,
isLoading={splitOperation.isLoading} disabled: !splitParams.validateParameters() || !hasFiles || !endpointEnabled
disabled={!splitParams.validateParameters() || !hasFiles || !endpointEnabled} },
loadingText={t("loading")} results: {
submitText={t("split.submit", "Split PDF")}
/>
)}
{/* Results Step */}
{steps.createResultsStep({
isVisible: hasResults, isVisible: hasResults,
operation: splitOperation, operation: splitOperation,
title: "Split Results", title: "Split Results",
onFileClick: handleThumbnailClick onFileClick: handleThumbnailClick
}
})} })}
</ToolStepProvider>
</Stack> </Stack>
); );
} }