Add Merge UI to V2 (#4235)

# Description of Changes
Add UI for Merge into V2.
This commit is contained in:
James Brunton
2025-09-10 13:06:23 +00:00
committed by GitHub
parent 9d723eae69
commit f3fd85d777
30 changed files with 1146 additions and 49 deletions
@@ -0,0 +1,182 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import MergeFileSorter from './MergeFileSorter';
// Mock useTranslation with predictable return values
const mockT = vi.fn((key: string) => `mock-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('MergeFileSorter', () => {
const mockOnSortFiles = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render sort options dropdown, direction toggle, and sort button', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
// Should have a select dropdown (Mantine Select uses textbox role)
expect(screen.getByRole('textbox')).toBeInTheDocument();
// Should have direction toggle button
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2); // ActionIcon + Sort Button
// Should have sort button with text
expect(screen.getByText('mock-merge.sortBy.sort')).toBeInTheDocument();
});
test('should render description text', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
expect(screen.getByText('mock-merge.sortBy.description')).toBeInTheDocument();
});
test('should have filename selected by default', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
const select = screen.getByRole('textbox');
expect(select).toHaveValue('mock-merge.sortBy.filename');
});
test('should show ascending direction by default', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
// Should show ascending arrow icon
const directionButton = screen.getAllByRole('button')[0];
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
});
test('should toggle direction when direction button is clicked', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
const directionButton = screen.getAllByRole('button')[0];
// Initially ascending
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
// Click to toggle to descending
fireEvent.click(directionButton);
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.descending');
// Click again to toggle back to ascending
fireEvent.click(directionButton);
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
});
test('should call onSortFiles with correct parameters when sort button is clicked', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
const sortButton = screen.getByText('mock-merge.sortBy.sort');
fireEvent.click(sortButton);
// Should be called with default values (filename, ascending)
expect(mockOnSortFiles).toHaveBeenCalledWith('filename', true);
});
test('should call onSortFiles with dateModified when dropdown is changed', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
// Open the dropdown by clicking on the current selected value
const currentSelection = screen.getByText('mock-merge.sortBy.filename');
fireEvent.mouseDown(currentSelection);
// Click on the dateModified option
const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified');
fireEvent.click(dateModifiedOption);
const sortButton = screen.getByText('mock-merge.sortBy.sort');
fireEvent.click(sortButton);
expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true);
});
test('should call onSortFiles with descending direction when toggled', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
const directionButton = screen.getAllByRole('button')[0];
const sortButton = screen.getByText('mock-merge.sortBy.sort');
// Toggle to descending
fireEvent.click(directionButton);
// Click sort
fireEvent.click(sortButton);
expect(mockOnSortFiles).toHaveBeenCalledWith('filename', false);
});
test('should handle complex user interaction sequence', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
const directionButton = screen.getAllByRole('button')[0];
const sortButton = screen.getByText('mock-merge.sortBy.sort');
// 1. Change to dateModified
const currentSelection = screen.getByText('mock-merge.sortBy.filename');
fireEvent.mouseDown(currentSelection);
const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified');
fireEvent.click(dateModifiedOption);
// 2. Toggle to descending
fireEvent.click(directionButton);
// 3. Click sort
fireEvent.click(sortButton);
expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', false);
// 4. Toggle back to ascending
fireEvent.click(directionButton);
// 5. Sort again
fireEvent.click(sortButton);
expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true);
});
});
@@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { Group, Button, Text, ActionIcon, Stack, Select } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import SortIcon from '@mui/icons-material/Sort';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
interface MergeFileSorterProps {
onSortFiles: (sortType: 'filename' | 'dateModified', ascending: boolean) => void;
disabled?: boolean;
}
const MergeFileSorter: React.FC<MergeFileSorterProps> = ({
onSortFiles,
disabled = false,
}) => {
const { t } = useTranslation();
const [sortType, setSortType] = useState<'filename' | 'dateModified'>('filename');
const [ascending, setAscending] = useState(true);
const sortOptions = [
{ value: 'filename', label: t('merge.sortBy.filename', 'File Name') },
{ value: 'dateModified', label: t('merge.sortBy.dateModified', 'Date Modified') },
];
const handleSort = () => {
onSortFiles(sortType, ascending);
};
const handleDirectionToggle = () => {
setAscending(!ascending);
};
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('merge.sortBy.description', "Files will be merged in the order they're selected. Drag to reorder or sort below.")}
</Text>
<Stack gap="xs">
<Group gap="xs" align="end" justify="space-between">
<Select
data={sortOptions}
value={sortType}
onChange={(value) => setSortType(value as 'filename' | 'dateModified')}
disabled={disabled}
label={t('merge.sortBy.label', 'Sort By')}
size='xs'
style={{ flex: 1 }}
/>
<ActionIcon
variant="light"
size="md"
onClick={handleDirectionToggle}
disabled={disabled}
title={ascending ? t('merge.sortBy.ascending', 'Ascending') : t('merge.sortBy.descending', 'Descending')}
>
{ascending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />}
</ActionIcon>
</Group>
<Button
variant="light"
size="xs"
leftSection={<SortIcon />}
onClick={handleSort}
disabled={disabled}
fullWidth
>
{t('merge.sortBy.sort', 'Sort')}
</Button>
</Stack>
</Stack>
);
};
export default MergeFileSorter;
@@ -0,0 +1,100 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import MergeSettings from './MergeSettings';
import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
// Mock useTranslation with predictable return values
const mockT = vi.fn((key: string) => `mock-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('MergeSettings', () => {
const defaultParameters: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false,
};
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render both merge option checkboxes', () => {
render(
<TestWrapper>
<MergeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Should render one checkbox for each parameter
const expectedCheckboxCount = Object.keys(defaultParameters).length;
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(expectedCheckboxCount);
});
test('should show correct initial checkbox states based on parameters', () => {
render(
<TestWrapper>
<MergeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const checkboxes = screen.getAllByRole('checkbox');
// Both checkboxes should be unchecked initially
checkboxes.forEach(checkbox => {
expect(checkbox).not.toBeChecked();
});
});
test('should call onParameterChange with correct parameters when checkboxes are clicked', () => {
render(
<TestWrapper>
<MergeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const checkboxes = screen.getAllByRole('checkbox');
// Click the first checkbox (removeDigitalSignature - should toggle from false to true)
fireEvent.click(checkboxes[0]);
expect(mockOnParameterChange).toHaveBeenCalledWith('removeDigitalSignature', true);
// Click the second checkbox (generateTableOfContents - should toggle from false to true)
fireEvent.click(checkboxes[1]);
expect(mockOnParameterChange).toHaveBeenCalledWith('generateTableOfContents', true);
});
test('should call translation function with correct keys', () => {
render(
<TestWrapper>
<MergeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Verify that translation keys are being called
expect(mockT).toHaveBeenCalledWith('merge.removeDigitalSignature', 'Remove digital signature in the merged file?');
expect(mockT).toHaveBeenCalledWith('merge.generateTableOfContents', 'Generate table of contents in the merged file?');
});
});
@@ -0,0 +1,38 @@
import React from 'react';
import { Stack, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
interface MergeSettingsProps {
parameters: MergeParameters;
onParameterChange: <K extends keyof MergeParameters>(key: K, value: MergeParameters[K]) => void;
disabled?: boolean;
}
const MergeSettings: React.FC<MergeSettingsProps> = ({
parameters,
onParameterChange,
disabled = false,
}) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Checkbox
label={t('merge.removeDigitalSignature', 'Remove digital signature in the merged file?')}
checked={parameters.removeDigitalSignature}
onChange={(event) => onParameterChange('removeDigitalSignature', event.currentTarget.checked)}
disabled={disabled}
/>
<Checkbox
label={t('merge.generateTableOfContents', 'Generate table of contents in the merged file?')}
checked={parameters.generateTableOfContents}
onChange={(event) => onParameterChange('generateTableOfContents', event.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
};
export default MergeSettings;
@@ -10,11 +10,12 @@ import { StirlingFile } from "../../../types/fileContext";
export interface FileStatusIndicatorProps {
selectedFiles?: StirlingFile[];
placeholder?: string;
minFiles?: number;
}
const FileStatusIndicator = ({
selectedFiles = [],
minFiles = 1,
}: FileStatusIndicatorProps) => {
const { t } = useTranslation();
const { openFilesModal, onFilesSelect } = useFilesModalContext();
@@ -55,6 +56,14 @@ const FileStatusIndicator = ({
return null;
}
const getPlaceholder = () => {
if (minFiles === undefined || minFiles === 1) {
return t("files.selectFromWorkbench", "Select files from the workbench or ");
} else {
return t("files.selectMultipleFromWorkbench", "Select at least {{count}} files from the workbench or ", { count: minFiles });
}
};
// Check if there are no files in the workbench
if (stirlingFileStubs.length === 0) {
// If no recent files, show upload button
@@ -89,12 +98,12 @@ const FileStatusIndicator = ({
}
// Show selection status when there are files in workbench
if (selectedFiles.length === 0) {
if (selectedFiles.length < minFiles) {
// If no recent files, show upload option
if (!hasRecentFiles) {
return (
<Text size="sm" c="dimmed">
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
{getPlaceholder() + " "}
<Anchor
size="sm"
onClick={handleNativeUpload}
@@ -109,7 +118,7 @@ const FileStatusIndicator = ({
// If there are recent files, show add files option
return (
<Text size="sm" c="dimmed">
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
{getPlaceholder() + " "}
<Anchor
size="sm"
onClick={() => openFilesModal()}
@@ -125,7 +134,7 @@ const FileStatusIndicator = ({
return (
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
{selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
</Text>
);
};
@@ -7,7 +7,7 @@ export interface FilesToolStepProps {
selectedFiles: StirlingFile[];
isCollapsed?: boolean;
onCollapsedClick?: () => void;
placeholder?: string;
minFiles?: number;
}
export function createFilesToolStep(
@@ -23,7 +23,7 @@ export function createFilesToolStep(
}, (
<FileStatusIndicator
selectedFiles={props.selectedFiles}
placeholder={props.placeholder || t("files.placeholder", "Select a PDF file in the main view to get started")}
minFiles={props.minFiles}
/>
));
}
@@ -9,7 +9,7 @@ import { StirlingFile } from '../../../types/fileContext';
export interface FilesStepConfig {
selectedFiles: StirlingFile[];
isCollapsed?: boolean;
placeholder?: string;
minFiles?: number;
onCollapsedClick?: () => void;
isVisible?: boolean;
}
@@ -76,7 +76,7 @@ export function createToolFlow(config: ToolFlowConfig) {
{config.files.isVisible !== false && steps.createFilesStep({
selectedFiles: config.files.selectedFiles,
isCollapsed: config.files.isCollapsed,
placeholder: config.files.placeholder,
minFiles: config.files.minFiles,
onCollapsedClick: config.files.onCollapsedClick
})}
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useMergeTips = (): TooltipContent => {
const { t } = useTranslation();
return {
tips: [
{
title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'),
description: t('merge.removeDigitalSignature.tooltip.description', 'Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF.')
},
{
title: t('merge.generateTableOfContents.tooltip.title', 'Generate Table of Contents'),
description: t('merge.generateTableOfContents.tooltip.description', 'Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers.')
}
]
};
};
+9 -1
View File
@@ -145,12 +145,17 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
// Validate that all IDs exist in current state
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
// Reorder selected files by passed order
const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id));
return {
...state,
files: {
...state.files,
ids: validIds
},
ui: {
...state.ui,
selectedFileIds,
}
};
}
@@ -234,11 +239,14 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
case 'CONSUME_FILES': {
const { inputFileIds, outputStirlingFileStubs } = action.payload;
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
}
case 'UNDO_CONSUME_FILES': {
const { inputStirlingFileStubs, outputFileIds } = action.payload;
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
}
+4 -5
View File
@@ -136,13 +136,13 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu
/**
* Hook for selected files (optimized for selection-based UI)
*/
export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedRecords: StirlingFileStub[]; selectedFileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
files: selectors.getSelectedFiles(),
records: selectors.getSelectedStirlingFileStubs(),
fileIds: state.ui.selectedFileIds
selectedFiles: selectors.getSelectedFiles(),
selectedRecords: selectors.getSelectedStirlingFileStubs(),
selectedFileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]);
}
@@ -169,7 +169,6 @@ export function useFileContext() {
recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (_fileId: FileId, _operationId: string, _error: string) => {}, // Operation tracking not implemented
// File ID lookup
findFileId: (file: File) => {
return state.files.ids.find(id => {
@@ -11,6 +11,7 @@ import ChangePermissions from "../tools/ChangePermissions";
import RemovePassword from "../tools/RemovePassword";
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
import AddWatermark from "../tools/AddWatermark";
import Merge from '../tools/Merge';
import Repair from "../tools/Repair";
import AutoRename from "../tools/AutoRename";
import SingleLargePage from "../tools/SingleLargePage";
@@ -30,6 +31,7 @@ import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation";
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation';
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
@@ -48,6 +50,7 @@ import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
import Redact from "../tools/Redact";
import { ToolId } from "../types/toolId";
import MergeSettings from '../components/tools/merge/MergeSettings';
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@@ -672,12 +675,14 @@ export function useFlatToolRegistry(): ToolRegistry {
mergePdfs: {
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.merge.title", "Merge"),
component: null,
component: Merge,
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["merge-pdfs"],
operationConfig: mergeOperationConfig,
settingsComponent: MergeSettings
},
"multi-tool": {
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
@@ -0,0 +1,138 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useMergeOperation } from './useMergeOperation';
import type { MergeParameters } from './useMergeParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => {
const actual = await vi.importActual('../shared/useToolOperation');
return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook
const mockT = vi.fn((key: string) => `translated-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Mock the error handler
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
}));
// Import the mocked function
import { MultiFileToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
describe('useMergeOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation<MergeParameters>);
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as MultiFileToolOperationConfig<MergeParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
thumbnails: [],
downloadUrl: null,
downloadFilename: '',
isLoading: false,
errorMessage: null,
status: '',
isGeneratingThumbnails: false,
progress: null,
executeOperation: vi.fn(),
resetResults: vi.fn(),
clearError: vi.fn(),
cancelOperation: vi.fn(),
undoOperation: function (): Promise<void> {
throw new Error('Function not implemented.');
}
};
beforeEach(() => {
vi.clearAllMocks();
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
});
test('should build FormData correctly', () => {
renderHook(() => useMergeOperation());
const config = getToolConfig();
const mockFiles = [
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
];
const parameters: MergeParameters = {
removeDigitalSignature: true,
generateTableOfContents: false
};
const formData = config.buildFormData(parameters, mockFiles);
// Verify files are appended
expect(formData.getAll('fileInput')).toHaveLength(2);
expect(formData.getAll('fileInput')[0]).toBe(mockFiles[0]);
expect(formData.getAll('fileInput')[1]).toBe(mockFiles[1]);
// Verify parameters are appended correctly
expect(formData.get('sortType')).toBe('orderProvided');
expect(formData.get('removeCertSign')).toBe('true');
expect(formData.get('generateToc')).toBe('false');
});
test('should handle response correctly', () => {
renderHook(() => useMergeOperation());
const config = getToolConfig();
const mockBlob = new Blob(['merged content'], { type: 'application/pdf' });
const mockFiles = [
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
];
const result = config.responseHandler!(mockBlob, mockFiles) as File[];
expect(result).toHaveLength(1);
expect(result[0].name).toBe('merged_file1.pdf');
expect(result[0].type).toBe('application/pdf');
expect(result[0].size).toBe(mockBlob.size);
});
test('should return the hook result from useToolOperation', () => {
const { result } = renderHook(() => useMergeOperation());
expect(result.current).toBe(mockToolOperationReturn);
});
test('should use correct translation keys for error handling', () => {
renderHook(() => useMergeOperation());
expect(mockT).toHaveBeenCalledWith('merge.error.failed', 'An error occurred while merging the PDFs.');
});
test('should build FormData with different parameter combinations', () => {
renderHook(() => useMergeOperation());
const config = getToolConfig();
const mockFiles = [new File(['test'], 'test.pdf', { type: 'application/pdf' })];
// Test case 1: All options disabled
const params1: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false
};
const formData1 = config.buildFormData(params1, mockFiles);
expect(formData1.get('removeCertSign')).toBe('false');
expect(formData1.get('generateToc')).toBe('false');
// Test case 2: All options enabled
const params2: MergeParameters = {
removeDigitalSignature: true,
generateTableOfContents: true
};
const formData2 = config.buildFormData(params2, mockFiles);
expect(formData2.get('removeCertSign')).toBe('true');
expect(formData2.get('generateToc')).toBe('true');
});
});
@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ResponseHandler, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { MergeParameters } from './useMergeParameters';
const buildFormData = (parameters: MergeParameters, files: File[]): FormData => {
const formData = new FormData();
files.forEach((file) => {
formData.append("fileInput", file);
});
formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
formData.append("removeCertSign", parameters.removeDigitalSignature.toString());
formData.append("generateToc", parameters.generateTableOfContents.toString());
return formData;
};
const mergeResponseHandler: ResponseHandler = (blob: Blob, originalFiles: File[]): File[] => {
const filename = `merged_${originalFiles[0].name}`
return [new File([blob], filename, { type: 'application/pdf' })];
};
// Operation configuration for automation
export const mergeOperationConfig: ToolOperationConfig<MergeParameters> = {
toolType: ToolType.multiFile,
buildFormData,
operationType: 'merge',
endpoint: '/api/v1/general/merge-pdfs',
filePrefix: 'merged_',
responseHandler: mergeResponseHandler,
};
export const useMergeOperation = () => {
const { t } = useTranslation();
return useToolOperation<MergeParameters>({
...mergeOperationConfig,
getErrorMessage: createStandardErrorHandler(t('merge.error.failed', 'An error occurred while merging the PDFs.'))
});
};
@@ -0,0 +1,68 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMergeParameters, defaultParameters } from './useMergeParameters';
describe('useMergeParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useMergeParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: 'removeDigitalSignature' as const, value: true },
{ paramName: 'removeDigitalSignature' as const, value: false },
{ paramName: 'generateTableOfContents' as const, value: true },
{ paramName: 'generateTableOfContents' as const, value: false }
])('should update parameter $paramName to $value', ({ paramName, value }) => {
const { result } = renderHook(() => useMergeParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useMergeParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('removeDigitalSignature', true);
result.current.updateParameter('generateTableOfContents', true);
});
expect(result.current.parameters.removeDigitalSignature).toBe(true);
expect(result.current.parameters.generateTableOfContents).toBe(true);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should validate parameters correctly - always returns true', () => {
const { result } = renderHook(() => useMergeParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
// Change parameters and validate again
act(() => {
result.current.updateParameter('removeDigitalSignature', true);
result.current.updateParameter('generateTableOfContents', true);
});
expect(result.current.validateParameters()).toBe(true);
// Reset and validate again
act(() => {
result.current.resetParameters();
});
expect(result.current.validateParameters()).toBe(true);
});
});
@@ -0,0 +1,21 @@
import { BaseParameters } from '../../../types/parameters';
import { BaseParametersHook, useBaseParameters } from '../shared/useBaseParameters';
export interface MergeParameters extends BaseParameters {
removeDigitalSignature: boolean;
generateTableOfContents: boolean;
};
export const defaultParameters: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false,
};
export type MergeParametersHook = BaseParametersHook<MergeParameters>;
export const useMergeParameters = (): MergeParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "merge-pdfs",
});
};
@@ -38,7 +38,9 @@ export function useBaseTool<TParams>(
useParams: () => BaseParametersHook<TParams>,
useOperation: () => ToolOperationHook<TParams>,
props: BaseToolProps,
options?: { minFiles?: number }
): BaseToolReturn<TParams> {
const minFiles = options?.minFiles ?? 1;
const { onPreviewFile, onComplete, onError } = props;
// File selection
@@ -96,7 +98,7 @@ export function useBaseTool<TParams>(
}, [operation, onPreviewFile]);
// Standard computed state
const hasFiles = selectedFiles.length > 0;
const hasFiles = selectedFiles.length >= minFiles;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults;
+1 -16
View File
@@ -1,4 +1,4 @@
import React, { useState, useMemo } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
@@ -161,25 +161,10 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
content
});
// Dynamic file placeholder based on supported types
const filesPlaceholder = useMemo(() => {
if (currentStep === AUTOMATION_STEPS.RUN && stepData.automation?.operations?.length) {
const firstOperation = stepData.automation.operations[0];
const toolConfig = toolRegistry[firstOperation.operation as keyof typeof toolRegistry];
// Check if the tool has supportedFormats that include non-PDF formats
if (toolConfig?.supportedFormats && toolConfig.supportedFormats.length > 1) {
return t('automate.files.placeholder.multiFormat', 'Select files to process (supports various formats)');
}
}
return t('automate.files.placeholder', 'Select PDF files to process with this automation');
}, [currentStep, stepData.automation, toolRegistry, t]);
// Always create files step to avoid conditional hook calls
const filesStep = createFilesToolStep(createStep, {
selectedFiles,
isCollapsed: hasResults,
placeholder: filesPlaceholder
});
const automationSteps = [
-1
View File
@@ -100,7 +100,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
files: {
selectedFiles,
isCollapsed: hasResults,
placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started"),
},
steps: [
{
+1 -2
View File
@@ -22,7 +22,6 @@ const Flatten = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("flatten.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [
{
@@ -59,4 +58,4 @@ const Flatten = (props: BaseToolProps) => {
// Static method to get the operation hook for automation
Flatten.tool = () => useFlattenOperation;
export default Flatten as ToolComponent;
export default Flatten as ToolComponent;
+98
View File
@@ -0,0 +1,98 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import MergeSettings from "../components/tools/merge/MergeSettings";
import MergeFileSorter from "../components/tools/merge/MergeFileSorter";
import { useMergeParameters } from "../hooks/tools/merge/useMergeParameters";
import { useMergeOperation } from "../hooks/tools/merge/useMergeOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useMergeTips } from "../components/tooltips/useMergeTips";
import { useFileManagement, useSelectedFiles, useAllFiles } from "../contexts/FileContext";
const Merge = (props: BaseToolProps) => {
const { t } = useTranslation();
const mergeTips = useMergeTips();
// File selection hooks for custom sorting
const { fileIds } = useAllFiles();
const { selectedRecords } = useSelectedFiles();
const { reorderFiles } = useFileManagement();
const base = useBaseTool(
'merge',
useMergeParameters,
useMergeOperation,
props,
{ minFiles: 2 }
);
// Custom file sorting logic for merge tool
const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => {
const sortedRecords = [...selectedRecords].sort((recordA, recordB) => {
let comparison = 0;
switch (sortType) {
case 'filename':
comparison = recordA.name.localeCompare(recordB.name);
break;
case 'dateModified':
comparison = recordA.lastModified - recordB.lastModified;
break;
}
return ascending ? comparison : -comparison;
});
const selectedIds = sortedRecords.map(record => record.id);
const deselectedIds = fileIds.filter(id => !selectedIds.includes(id));
reorderFiles([...selectedIds, ...deselectedIds]);
}, [selectedRecords, fileIds, reorderFiles]);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
minFiles: 2,
},
steps: [
{
title: "Sort Files",
isCollapsed: base.settingsCollapsed,
content: (
<MergeFileSorter
onSortFiles={sortFiles}
disabled={!base.hasFiles || base.endpointLoading}
/>
),
},
{
title: "Settings",
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: mergeTips,
content: (
<MergeSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t("merge.submit", "Merge PDFs"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("merge.title", "Merge Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default Merge as ToolComponent;
@@ -19,7 +19,6 @@ const RemoveCertificateSign = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("removeCertSign.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [],
executeButton: {
-1
View File
@@ -19,7 +19,6 @@ const Repair = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("repair.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [],
executeButton: {
-1
View File
@@ -20,7 +20,6 @@ const Sanitize = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [
{
-1
View File
@@ -19,7 +19,6 @@ const SingleLargePage = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("pdfToSinglePage.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [],
executeButton: {
-1
View File
@@ -19,7 +19,6 @@ const UnlockPdfForms = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasFiles || base.hasResults,
placeholder: t("unlockPDFForms.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [],
executeButton: {