mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Add Merge UI to V2 (#4235)
# Description of Changes Add UI for Merge into V2.
This commit is contained in:
@@ -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
|
||||
})}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user