Redesign Merge around new major merges into V2

This commit is contained in:
James Brunton 2025-08-22 16:59:01 +01:00
parent dd20a3c0a3
commit 5ed6cc3cfc
6 changed files with 103 additions and 89 deletions

View File

@ -2,10 +2,10 @@
* FileContext reducer - Pure state management for file operations * FileContext reducer - Pure state management for file operations
*/ */
import { import {
FileContextState, FileContextState,
FileContextAction, FileContextAction,
FileId, FileId,
FileRecord FileRecord
} from '../../types/fileContext'; } from '../../types/fileContext';
@ -32,7 +32,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
const { fileRecords } = action.payload; const { fileRecords } = action.payload;
const newIds: FileId[] = []; const newIds: FileId[] = [];
const newById: Record<FileId, FileRecord> = { ...state.files.byId }; const newById: Record<FileId, FileRecord> = { ...state.files.byId };
fileRecords.forEach(record => { fileRecords.forEach(record => {
// Only add if not already present (dedupe by stable ID) // Only add if not already present (dedupe by stable ID)
if (!newById[record.id]) { if (!newById[record.id]) {
@ -40,7 +40,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
newById[record.id] = record; newById[record.id] = record;
} }
}); });
return { return {
...state, ...state,
files: { files: {
@ -49,20 +49,20 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'REMOVE_FILES': { case 'REMOVE_FILES': {
const { fileIds } = action.payload; const { fileIds } = action.payload;
const remainingIds = state.files.ids.filter(id => !fileIds.includes(id)); const remainingIds = state.files.ids.filter(id => !fileIds.includes(id));
const newById = { ...state.files.byId }; const newById = { ...state.files.byId };
// Remove files from state (resource cleanup handled by lifecycle manager) // Remove files from state (resource cleanup handled by lifecycle manager)
fileIds.forEach(id => { fileIds.forEach(id => {
delete newById[id]; delete newById[id];
}); });
// Clear selections that reference removed files // Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id)); const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id));
return { return {
...state, ...state,
files: { files: {
@ -75,15 +75,15 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'UPDATE_FILE_RECORD': { case 'UPDATE_FILE_RECORD': {
const { id, updates } = action.payload; const { id, updates } = action.payload;
const existingRecord = state.files.byId[id]; const existingRecord = state.files.byId[id];
if (!existingRecord) { if (!existingRecord) {
return state; // File doesn't exist, no-op return state; // File doesn't exist, no-op
} }
return { return {
...state, ...state,
files: { files: {
@ -98,22 +98,28 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'REORDER_FILES': { case 'REORDER_FILES': {
const { orderedFileIds } = action.payload; const { orderedFileIds } = action.payload;
// Validate that all IDs exist in current state // Validate that all IDs exist in current state
const validIds = orderedFileIds.filter(id => state.files.byId[id]); 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 { return {
...state, ...state,
files: { files: {
...state.files, ...state.files,
ids: validIds ids: validIds
},
ui: {
...state.ui,
selectedFileIds,
} }
}; };
} }
case 'SET_SELECTED_FILES': { case 'SET_SELECTED_FILES': {
const { fileIds } = action.payload; const { fileIds } = action.payload;
return { return {
@ -124,7 +130,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'SET_SELECTED_PAGES': { case 'SET_SELECTED_PAGES': {
const { pageNumbers } = action.payload; const { pageNumbers } = action.payload;
return { return {
@ -135,7 +141,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'CLEAR_SELECTIONS': { case 'CLEAR_SELECTIONS': {
return { return {
...state, ...state,
@ -146,7 +152,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'SET_PROCESSING': { case 'SET_PROCESSING': {
const { isProcessing, progress } = action.payload; const { isProcessing, progress } = action.payload;
return { return {
@ -158,7 +164,7 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'SET_UNSAVED_CHANGES': { case 'SET_UNSAVED_CHANGES': {
return { return {
...state, ...state,
@ -168,42 +174,42 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'PIN_FILE': { case 'PIN_FILE': {
const { fileId } = action.payload; const { fileId } = action.payload;
const newPinnedFiles = new Set(state.pinnedFiles); const newPinnedFiles = new Set(state.pinnedFiles);
newPinnedFiles.add(fileId); newPinnedFiles.add(fileId);
return { return {
...state, ...state,
pinnedFiles: newPinnedFiles pinnedFiles: newPinnedFiles
}; };
} }
case 'UNPIN_FILE': { case 'UNPIN_FILE': {
const { fileId } = action.payload; const { fileId } = action.payload;
const newPinnedFiles = new Set(state.pinnedFiles); const newPinnedFiles = new Set(state.pinnedFiles);
newPinnedFiles.delete(fileId); newPinnedFiles.delete(fileId);
return { return {
...state, ...state,
pinnedFiles: newPinnedFiles pinnedFiles: newPinnedFiles
}; };
} }
case 'CONSUME_FILES': { case 'CONSUME_FILES': {
const { inputFileIds, outputFileRecords } = action.payload; const { inputFileIds, outputFileRecords } = action.payload;
// Only remove unpinned input files // Only remove unpinned input files
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id)); const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id)); const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
// Remove unpinned files from state // Remove unpinned files from state
const newById = { ...state.files.byId }; const newById = { ...state.files.byId };
unpinnedInputIds.forEach(id => { unpinnedInputIds.forEach(id => {
delete newById[id]; delete newById[id];
}); });
// Add output files // Add output files
const outputIds: FileId[] = []; const outputIds: FileId[] = [];
outputFileRecords.forEach(record => { outputFileRecords.forEach(record => {
@ -212,10 +218,10 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
newById[record.id] = record; newById[record.id] = record;
} }
}); });
// Clear selections that reference removed files // Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id)); const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
return { return {
...state, ...state,
files: { files: {
@ -228,13 +234,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
}; };
} }
case 'RESET_CONTEXT': { case 'RESET_CONTEXT': {
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager) // Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
return { ...initialFileContextState }; return { ...initialFileContextState };
} }
default: default:
return state; return state;
} }
} }

View File

@ -3,11 +3,11 @@
*/ */
import { useContext, useMemo } from 'react'; import { useContext, useMemo } from 'react';
import { import {
FileStateContext, FileStateContext,
FileActionsContext, FileActionsContext,
FileContextStateValue, FileContextStateValue,
FileContextActionsValue FileContextActionsValue
} from './contexts'; } from './contexts';
import { FileId, FileRecord } from '../../types/fileContext'; import { FileId, FileRecord } from '../../types/fileContext';
@ -39,7 +39,7 @@ export function useFileActions(): FileContextActionsValue {
*/ */
export function useCurrentFile(): { file?: File; record?: FileRecord } { export function useCurrentFile(): { file?: File; record?: FileRecord } {
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
const primaryFileId = state.files.ids[0]; const primaryFileId = state.files.ids[0];
return useMemo(() => ({ return useMemo(() => ({
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined, file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
@ -81,7 +81,7 @@ export function useFileSelection() {
*/ */
export function useFileManagement() { export function useFileManagement() {
const { actions } = useFileActions(); const { actions } = useFileActions();
return useMemo(() => ({ return useMemo(() => ({
addFiles: actions.addFiles, addFiles: actions.addFiles,
removeFiles: actions.removeFiles, removeFiles: actions.removeFiles,
@ -112,7 +112,7 @@ export function useFileUI() {
*/ */
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } { export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
const { selectors } = useFileState(); const { selectors } = useFileState();
return useMemo(() => ({ return useMemo(() => ({
file: selectors.getFile(fileId), file: selectors.getFile(fileId),
record: selectors.getFileRecord(fileId) record: selectors.getFileRecord(fileId)
@ -124,7 +124,7 @@ export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecor
*/ */
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } { export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
return useMemo(() => ({ return useMemo(() => ({
files: selectors.getFiles(), files: selectors.getFiles(),
records: selectors.getFileRecords(), records: selectors.getFileRecords(),
@ -135,13 +135,13 @@ export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds:
/** /**
* Hook for selected files (optimized for selection-based UI) * Hook for selected files (optimized for selection-based UI)
*/ */
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } { export function useSelectedFiles(): { selectedFiles: File[]; selectedRecords: FileRecord[]; selectedFileIds: FileId[] } {
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
return useMemo(() => ({ return useMemo(() => ({
files: selectors.getSelectedFiles(), selectedFiles: selectors.getSelectedFiles(),
records: selectors.getSelectedFileRecords(), selectedRecords: selectors.getSelectedFileRecords(),
fileIds: state.ui.selectedFileIds selectedFileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]); }), [state.ui.selectedFileIds, selectors]);
} }
@ -160,31 +160,31 @@ export function useFileContext() {
trackBlobUrl: actions.trackBlobUrl, trackBlobUrl: actions.trackBlobUrl,
scheduleCleanup: actions.scheduleCleanup, scheduleCleanup: actions.scheduleCleanup,
setUnsavedChanges: actions.setHasUnsavedChanges, setUnsavedChanges: actions.setHasUnsavedChanges,
// File management // File management
addFiles: actions.addFiles, addFiles: actions.addFiles,
consumeFiles: actions.consumeFiles, consumeFiles: actions.consumeFiles,
recordOperation: (fileId: string, operation: any) => {}, // Operation tracking not implemented recordOperation: (fileId: string, operation: any) => {}, // Operation tracking not implemented
markOperationApplied: (fileId: string, operationId: string) => {}, // Operation tracking not implemented markOperationApplied: (fileId: string, operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // Operation tracking not implemented markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // Operation tracking not implemented
// File ID lookup // File ID lookup
findFileId: (file: File) => { findFileId: (file: File) => {
return state.files.ids.find(id => { return state.files.ids.find(id => {
const record = state.files.byId[id]; const record = state.files.byId[id];
return record && return record &&
record.name === file.name && record.name === file.name &&
record.size === file.size && record.size === file.size &&
record.lastModified === file.lastModified; record.lastModified === file.lastModified;
}); });
}, },
// Pinned files // Pinned files
pinnedFiles: state.pinnedFiles, pinnedFiles: state.pinnedFiles,
pinFile: actions.pinFile, pinFile: actions.pinFile,
unpinFile: actions.unpinFile, unpinFile: actions.unpinFile,
isFilePinned: selectors.isFilePinned, isFilePinned: selectors.isFilePinned,
// Active files // Active files
activeFiles: selectors.getFiles() activeFiles: selectors.getFiles()
}), [state, selectors, actions]); }), [state, selectors, actions]);

View File

@ -28,6 +28,7 @@ import { ocrOperationConfig } from '../hooks/tools/ocr/useOCROperation';
import { convertOperationConfig } from '../hooks/tools/convert/useConvertOperation'; import { convertOperationConfig } from '../hooks/tools/convert/useConvertOperation';
import { removeCertificateSignOperationConfig } from '../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation'; import { removeCertificateSignOperationConfig } from '../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation';
import { changePermissionsOperationConfig } from '../hooks/tools/changePermissions/useChangePermissionsOperation'; import { changePermissionsOperationConfig } from '../hooks/tools/changePermissions/useChangePermissionsOperation';
import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation';
import CompressSettings from '../components/tools/compress/CompressSettings'; import CompressSettings from '../components/tools/compress/CompressSettings';
import SplitSettings from '../components/tools/split/SplitSettings'; import SplitSettings from '../components/tools/split/SplitSettings';
import AddPasswordSettings from '../components/tools/addPassword/AddPasswordSettings'; import AddPasswordSettings from '../components/tools/addPassword/AddPasswordSettings';
@ -39,6 +40,7 @@ import AddWatermarkSingleStepSettings from '../components/tools/addWatermark/Add
import OCRSettings from '../components/tools/ocr/OCRSettings'; import OCRSettings from '../components/tools/ocr/OCRSettings';
import ConvertSettings from '../components/tools/convert/ConvertSettings'; import ConvertSettings from '../components/tools/convert/ConvertSettings';
import ChangePermissionsSettings from '../components/tools/changePermissions/ChangePermissionsSettings'; import ChangePermissionsSettings from '../components/tools/changePermissions/ChangePermissionsSettings';
import MergeSettings from '../components/tools/merge/MergeSettings';
const showPlaceholderTools = false; // For development purposes. Allows seeing the full list of tools, even if they're unimplemented const showPlaceholderTools = false; // For development purposes. Allows seeing the full list of tools, even if they're unimplemented
@ -636,6 +638,8 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.GENERAL, subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1, maxFiles: -1,
endpoints: ["merge-pdfs"], endpoints: ["merge-pdfs"],
operationConfig: mergeOperationConfig,
settingsComponent: MergeSettings
}, },
"multi-tool": { "multi-tool": {
icon: <span className="material-symbols-rounded">dashboard_customize</span>, icon: <span className="material-symbols-rounded">dashboard_customize</span>,

View File

@ -20,12 +20,12 @@ vi.mock('../../../utils/toolErrorHandler', () => ({
})); }));
// Import the mocked function // Import the mocked function
import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation'; import { ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
describe('useMergeOperation', () => { describe('useMergeOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation); const mockUseToolOperation = vi.mocked(useToolOperation<MergeParameters>);
const getToolConfig = (): ToolOperationConfig<MergeParameters> => mockUseToolOperation.mock.calls[0][0]; const getToolConfig = () => mockUseToolOperation.mock.calls[0][0];
const mockToolOperationReturn: ToolOperationHook<unknown> = { const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [], files: [],

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation, ResponseHandler } from '../shared/useToolOperation'; import { useToolOperation, ResponseHandler, ToolOperationConfig } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { MergeParameters } from './useMergeParameters'; import { MergeParameters } from './useMergeParameters';
@ -21,16 +21,21 @@ const mergeResponseHandler: ResponseHandler = (blob: Blob, originalFiles: File[]
return [new File([blob], filename, { type: 'application/pdf' })]; return [new File([blob], filename, { type: 'application/pdf' })];
}; };
// Operation configuration for automation
export const mergeOperationConfig: ToolOperationConfig<MergeParameters> = {
operationType: 'merge',
endpoint: '/api/v1/general/merge-pdfs',
buildFormData,
filePrefix: 'merged_',
multiFileEndpoint: true,
responseHandler: mergeResponseHandler,
};
export const useMergeOperation = () => { export const useMergeOperation = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return useToolOperation<MergeParameters>({ return useToolOperation<MergeParameters>({
operationType: 'merge', ...mergeOperationConfig,
endpoint: '/api/v1/general/merge-pdfs',
buildFormData,
filePrefix: 'merged_',
multiFileEndpoint: true, // Single API call with all files
responseHandler: mergeResponseHandler, // Handle single PDF response
getErrorMessage: createStandardErrorHandler(t('merge.error.failed', 'An error occurred while merging the PDFs.')) getErrorMessage: createStandardErrorHandler(t('merge.error.failed', 'An error occurred while merging the PDFs.'))
}); });
}; };

View File

@ -1,8 +1,7 @@
import React, { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
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 { useFileSelection, useFileManagement, useSelectedFiles, useAllFiles } from "../contexts/FileContext";
import { useToolFileSelection, useFileSelectionActions } from "../contexts/FileSelectionContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createToolFlow } from "../components/tools/shared/createToolFlow";
import MergeSettings from "../components/tools/merge/MergeSettings"; import MergeSettings from "../components/tools/merge/MergeSettings";
@ -14,9 +13,10 @@ import { BaseToolProps } from "../types/tool";
const Merge = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const Merge = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setCurrentMode } = useFileContext(); const { selectedFiles, selectedFileIds } = useFileSelection();
const { selectedFiles } = useToolFileSelection(); const { fileIds } = useAllFiles()
const { setSelectedFiles } = useFileSelectionActions(); const { selectedRecords } = useSelectedFiles()
const { reorderFiles } = useFileManagement();
const mergeParams = useMergeParameters(); const mergeParams = useMergeParameters();
const mergeOperation = useMergeOperation(); const mergeOperation = useMergeOperation();
@ -45,36 +45,35 @@ const Merge = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const handleThumbnailClick = (file: File) => { const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file); onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "merge"); sessionStorage.setItem("previousMode", "merge");
setCurrentMode("viewer");
}; };
const handleSettingsReset = () => { const handleSettingsReset = () => {
mergeOperation.resetResults(); mergeOperation.resetResults();
onPreviewFile?.(null); onPreviewFile?.(null);
setCurrentMode("merge");
}; };
// TODO: Move to more general place so other tools can use it // TODO: Move to more general place so other tools can use it
const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => { const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => {
setSelectedFiles(((prevFiles: File[]) => { // Sort the FileIds based on their corresponding File properties
const sortedFiles = [...prevFiles].sort((a, b) => { const sortedRecords = [...selectedRecords].sort((recordA, recordB) => {
let comparison = 0; let comparison = 0;
switch (sortType) {
case 'filename':
comparison = recordA.name.localeCompare(recordB.name);
break;
case 'dateModified':
comparison = recordA.lastModified - recordB.lastModified;
break;
}
switch (sortType) { return ascending ? comparison : -comparison;
case 'filename': });
comparison = a.name.localeCompare(b.name);
break;
case 'dateModified':
comparison = a.lastModified - b.lastModified;
break;
}
return ascending ? comparison : -comparison; const selectedIds = sortedRecords.map(record => record.id);
}); const deselectedIds = fileIds.filter(id => !selectedIds.includes(id));
return sortedFiles; reorderFiles([...selectedIds, ...deselectedIds]); // Move all sorted IDs to the front of the workbench
}) as any /* FIX ME: Parameter type is wrong on setSelectedFiles */); }, [selectedFiles, selectedFileIds, reorderFiles]);
}, []);
const minFiles = 2; // Merging one file doesn't make sense const minFiles = 2; // Merging one file doesn't make sense
const hasFiles = selectedFiles.length >= minFiles; const hasFiles = selectedFiles.length >= minFiles;
@ -83,7 +82,7 @@ const Merge = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
return createToolFlow({ return createToolFlow({
files: { files: {
selectedFiles, selectedFiles: selectedFiles,
isCollapsed: hasFiles && !hasResults, isCollapsed: hasFiles && !hasResults,
placeholder: "Select multiple PDF files to merge", placeholder: "Select multiple PDF files to merge",
minFiles: minFiles, minFiles: minFiles,