Compare commits

..

10 Commits

Author SHA1 Message Date
James Brunton
7679ad3ec6 Improve styling on added words 2025-09-09 12:03:25 +01:00
James Brunton
db72bcb0f8 Fix linting 2025-09-09 11:46:56 +01:00
James Brunton
8503d8879c Add tests 2025-09-09 11:45:00 +01:00
James Brunton
a315a322bf Merge branch 'V2' into V2-auto-redact 2025-09-09 10:14:34 +01:00
James Brunton
e6526bdb44 Improve styling of Add button 2025-09-09 10:14:06 +01:00
James Brunton
e7cfb7045b Refactor buttons into shared component 2025-09-09 09:52:45 +01:00
James Brunton
e0b41adbdc Make translations more UK accurate 2025-09-09 09:35:07 +01:00
Ludy
e8af4f6b35
Set i18n to load only current language (#4359)
This pull request introduces a minor configuration change to the i18n
setup in the frontend. The change improves language loading behavior by
ensuring only the current language is loaded, which can help optimize
performance and prevent unnecessary resource usage.

* Added the `load: 'currentOnly'` option to the i18n initialization in
`frontend/src/i18n.ts`, so only the current language is loaded.

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2025-09-08 10:05:49 +01:00
stirlingbot[bot]
c25985e49e
Update Frontend 3rd Party Licenses (#4319)
Auto-generated by stirlingbot[bot]

This PR updates the frontend license report based on changes to
package.json dependencies.

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2025-09-08 08:58:22 +00:00
James Brunton
316be5eac5
Fix types of onParameterChange methods (#4415)
# Description of Changes
Fix types of onParameterChange methods
2025-09-08 09:55:30 +01:00
28 changed files with 1275 additions and 158 deletions

View File

@ -1605,7 +1605,7 @@
"wordsToRedact": { "wordsToRedact": {
"title": "Words to Redact", "title": "Words to Redact",
"placeholder": "Enter a word", "placeholder": "Enter a word",
"addAnother": "Add Another", "add": "Add",
"examples": "Examples: Confidential, Top-Secret" "examples": "Examples: Confidential, Top-Secret"
}, },
"useRegexLabel": "Use Regex", "useRegexLabel": "Use Regex",
@ -1620,7 +1620,7 @@
}, },
"automatic": { "automatic": {
"title": "Automatic Redaction", "title": "Automatic Redaction",
"text": "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, SSNs, or confidential markers." "text": "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, addresses, or confidential markers."
}, },
"manual": { "manual": {
"title": "Manual Redaction", "title": "Manual Redaction",
@ -1640,7 +1640,7 @@
"bullet3": "Click × to remove words", "bullet3": "Click × to remove words",
"examples": { "examples": {
"title": "Common Examples", "title": "Common Examples",
"text": "Typical words to redact include: 'Confidential', 'SSN:', phone numbers, email addresses, or specific names." "text": "Typical words to redact include: bank details, email addresses, or specific names."
} }
}, },
"advanced": { "advanced": {
@ -1648,8 +1648,8 @@
"title": "Advanced Redaction Settings" "title": "Advanced Redaction Settings"
}, },
"color": { "color": {
"title": "Box Color & Padding", "title": "Box Colour & Padding",
"text": "Customize the appearance of redaction boxes. Black is standard, but you can choose any color. Padding adds extra space around the found text." "text": "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text."
}, },
"regex": { "regex": {
"title": "Use Regex", "title": "Use Regex",
@ -1669,7 +1669,7 @@
}, },
"manual": { "manual": {
"header": "Manual Redaction", "header": "Manual Redaction",
"textBasedRedaction": "Text based Redaction", "textBasedRedaction": "Text-based Redaction",
"pageBasedRedaction": "Page-based Redaction", "pageBasedRedaction": "Page-based Redaction",
"convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)", "convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)",
"pageRedactionNumbers": { "pageRedactionNumbers": {
@ -1677,7 +1677,7 @@
"placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" "placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
}, },
"redactionColor": { "redactionColor": {
"title": "Redaction Color" "title": "Redaction Colour"
}, },
"export": "Export", "export": "Export",
"upload": "Upload", "upload": "Upload",
@ -1690,7 +1690,7 @@
"toggleSidebar": "Toggle Sidebar", "toggleSidebar": "Toggle Sidebar",
"showThumbnails": "Show Thumbnails", "showThumbnails": "Show Thumbnails",
"showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)", "showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)",
"showAttatchments": "Show Attachments", "showAttachments": "Show Attachments",
"showLayers": "Show Layers (double-click to reset all layers to the default state)", "showLayers": "Show Layers (double-click to reset all layers to the default state)",
"colourPicker": "Colour Picker", "colourPicker": "Colour Picker",
"findCurrentOutlineItem": "Find current outline item", "findCurrentOutlineItem": "Find current outline item",
@ -1906,6 +1906,11 @@
"title": "Compress", "title": "Compress",
"desc": "Compress PDFs to reduce their file size.", "desc": "Compress PDFs to reduce their file size.",
"header": "Compress PDF", "header": "Compress PDF",
"method": {
"title": "Compression Method",
"quality": "Quality",
"filesize": "File Size"
},
"credit": "This service uses qpdf for PDF Compress/Optimisation.", "credit": "This service uses qpdf for PDF Compress/Optimisation.",
"grayscale": { "grayscale": {
"label": "Apply Grayscale for Compression" "label": "Apply Grayscale for Compression"

View File

@ -385,6 +385,13 @@
"moduleLicense": "MIT", "moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT" "moduleLicenseUrl": "https://opensource.org/licenses/MIT"
}, },
{
"moduleName": "@posthog/core",
"moduleUrl": "https://github.com/PostHog/posthog-js",
"moduleVersion": "1.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{ {
"moduleName": "@tailwindcss/node", "moduleName": "@tailwindcss/node",
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss", "moduleUrl": "https://github.com/tailwindlabs/tailwindcss",
@ -742,6 +749,13 @@
"moduleLicense": "MIT", "moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT" "moduleLicenseUrl": "https://opensource.org/licenses/MIT"
}, },
{
"moduleName": "core-js",
"moduleUrl": "https://github.com/zloirock/core-js",
"moduleVersion": "3.45.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{ {
"moduleName": "core-util-is", "moduleName": "core-util-is",
"moduleUrl": "https://github.com/isaacs/core-util-is", "moduleUrl": "https://github.com/isaacs/core-util-is",
@ -924,6 +938,13 @@
"moduleLicense": "MIT", "moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT" "moduleLicenseUrl": "https://opensource.org/licenses/MIT"
}, },
{
"moduleName": "fflate",
"moduleUrl": "https://github.com/101arrowz/fflate",
"moduleVersion": "0.4.8",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{ {
"moduleName": "file-selector", "moduleName": "file-selector",
"moduleUrl": "https://github.com/react-dropzone/file-selector", "moduleUrl": "https://github.com/react-dropzone/file-selector",
@ -1533,6 +1554,20 @@
"moduleLicense": "MIT", "moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT" "moduleLicenseUrl": "https://opensource.org/licenses/MIT"
}, },
{
"moduleName": "posthog-js",
"moduleUrl": "https://github.com/PostHog/posthog-js",
"moduleVersion": "1.261.0",
"moduleLicense": "MIT*",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "preact",
"moduleUrl": "https://github.com/preactjs/preact",
"moduleVersion": "10.27.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{ {
"moduleName": "pretty-format", "moduleName": "pretty-format",
"moduleUrl": "https://github.com/facebook/jest", "moduleUrl": "https://github.com/facebook/jest",
@ -1928,7 +1963,7 @@
{ {
"moduleName": "typescript", "moduleName": "typescript",
"moduleUrl": "https://github.com/microsoft/TypeScript", "moduleUrl": "https://github.com/microsoft/TypeScript",
"moduleVersion": "5.8.3", "moduleVersion": "5.9.2",
"moduleLicense": "Apache-2.0", "moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
}, },
@ -1995,6 +2030,13 @@
"moduleLicense": "Apache-2.0", "moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
}, },
{
"moduleName": "web-vitals",
"moduleUrl": "https://github.com/GoogleChrome/web-vitals",
"moduleVersion": "4.2.4",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{ {
"moduleName": "webidl-conversions", "moduleName": "webidl-conversions",
"moduleUrl": "https://github.com/jsdom/webidl-conversions", "moduleUrl": "https://github.com/jsdom/webidl-conversions",

View File

@ -0,0 +1,185 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import ButtonSelector from './ButtonSelector';
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('ButtonSelector', () => {
const mockOnChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render all options as buttons', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
});
test('should highlight selected button with filled variant', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
const selectedButton = screen.getByRole('button', { name: 'Option 1' });
const unselectedButton = screen.getByRole('button', { name: 'Option 2' });
// Check data-variant attribute for filled/outline
expect(selectedButton).toHaveAttribute('data-variant', 'filled');
expect(unselectedButton).toHaveAttribute('data-variant', 'outline');
});
test('should call onChange when button is clicked', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
expect(mockOnChange).toHaveBeenCalledWith('option2');
});
test('should handle undefined value (no selection)', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value={undefined}
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
// Both buttons should be outlined when no value is selected
const button1 = screen.getByRole('button', { name: 'Option 1' });
const button2 = screen.getByRole('button', { name: 'Option 2' });
expect(button1).toHaveAttribute('data-variant', 'outline');
expect(button2).toHaveAttribute('data-variant', 'outline');
});
test.each([
{
description: 'disable buttons when disabled prop is true',
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
globalDisabled: true,
expectedStates: [true, true],
},
{
description: 'disable individual options when option.disabled is true',
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2', disabled: true },
],
globalDisabled: false,
expectedStates: [false, true],
},
])('should $description', ({ options, globalDisabled, expectedStates }) => {
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
disabled={globalDisabled}
/>
</TestWrapper>
);
options.forEach((option, index) => {
const button = screen.getByRole('button', { name: option.label });
expect(button).toHaveProperty('disabled', expectedStates[index]);
});
});
test('should not call onChange when disabled button is clicked', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2', disabled: true },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
expect(mockOnChange).not.toHaveBeenCalled();
});
test('should not apply fullWidth styling when fullWidth is false', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
fullWidth={false}
/>
</TestWrapper>
);
const button = screen.getByRole('button', { name: 'Option 1' });
expect(button).not.toHaveStyle({ flex: '1' });
});
});

View File

@ -0,0 +1,49 @@
import { Button } from "@mantine/core";
export interface ButtonOption<T> {
value: T;
label: string;
disabled?: boolean;
}
interface ButtonSelectorProps<T> {
value: T | undefined;
onChange: (value: T) => void;
options: ButtonOption<T>[];
disabled?: boolean;
fullWidth?: boolean;
}
const ButtonSelector = <T extends string>({
value,
onChange,
options,
disabled = false,
fullWidth = true,
}: ButtonSelectorProps<T>) => {
return (
<div style={{ display: 'flex', gap: '4px' }}>
{options.map((option) => (
<Button
key={option.value}
variant={value === option.value ? 'filled' : 'outline'}
color={value === option.value ? 'blue' : 'var(--text-muted)'}
onClick={() => onChange(option.value)}
disabled={disabled || option.disabled}
style={{
flex: fullWidth ? 1 : undefined,
height: 'auto',
minHeight: '40px',
fontSize: '11px'
}}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{option.label}
</div>
</Button>
))}
</div>
);
};
export default ButtonSelector;

View File

@ -4,7 +4,7 @@ import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPa
interface AddPasswordSettingsProps { interface AddPasswordSettingsProps {
parameters: AddPasswordParameters; parameters: AddPasswordParameters;
onParameterChange: (key: keyof AddPasswordParameters, value: any) => void; onParameterChange: <K extends keyof AddPasswordParameters>(key: K, value: AddPasswordParameters[K]) => void;
disabled?: boolean; disabled?: boolean;
} }

View File

@ -1,5 +1,6 @@
import { Button, Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import ButtonSelector from "../../shared/ButtonSelector";
interface WatermarkTypeSettingsProps { interface WatermarkTypeSettingsProps {
watermarkType?: 'text' | 'image'; watermarkType?: 'text' | 'image';
@ -10,32 +11,25 @@ interface WatermarkTypeSettingsProps {
const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled = false }: WatermarkTypeSettingsProps) => { const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled = false }: WatermarkTypeSettingsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const options = [
{
value: 'text' as const,
label: t('watermark.watermarkType.text', 'Text')
},
{
value: 'image' as const,
label: t('watermark.watermarkType.image', 'Image')
}
];
return ( return (
<Stack gap="sm"> <Stack gap="sm">
<div style={{ display: 'flex', gap: '4px' }}> <ButtonSelector
<Button value={watermarkType}
variant={watermarkType === 'text' ? 'filled' : 'outline'} onChange={onWatermarkTypeChange}
color={watermarkType === 'text' ? 'blue' : 'var(--text-muted)'} options={options}
onClick={() => onWatermarkTypeChange('text')}
disabled={disabled} disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }} />
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{t('watermark.watermarkType.text', 'Text')}
</div>
</Button>
<Button
variant={watermarkType === 'image' ? 'filled' : 'outline'}
color={watermarkType === 'image' ? 'blue' : 'var(--text-muted)'}
onClick={() => onWatermarkTypeChange('image')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{t('watermark.watermarkType.image', 'Image')}
</div>
</Button>
</div>
</Stack> </Stack>
); );
}; };

View File

@ -4,7 +4,7 @@ import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissi
interface ChangePermissionsSettingsProps { interface ChangePermissionsSettingsProps {
parameters: ChangePermissionsParameters; parameters: ChangePermissionsParameters;
onParameterChange: (key: keyof ChangePermissionsParameters, value: boolean) => void; onParameterChange: <K extends keyof ChangePermissionsParameters>(key: K, value: ChangePermissionsParameters[K]) => void;
disabled?: boolean; disabled?: boolean;
} }

View File

@ -1,11 +1,12 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core"; import { Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CompressParameters } from "../../../hooks/tools/compress/useCompressParameters"; import { CompressParameters } from "../../../hooks/tools/compress/useCompressParameters";
import ButtonSelector from "../../shared/ButtonSelector";
interface CompressSettingsProps { interface CompressSettingsProps {
parameters: CompressParameters; parameters: CompressParameters;
onParameterChange: (key: keyof CompressParameters, value: any) => void; onParameterChange: <K extends keyof CompressParameters>(key: K, value: CompressParameters[K]) => void;
disabled?: boolean; disabled?: boolean;
} }
@ -19,31 +20,16 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
<Divider ml='-md'></Divider> <Divider ml='-md'></Divider>
{/* Compression Method */} {/* Compression Method */}
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" fw={500}>Compression Method</Text> <Text size="sm" fw={500}>{t('compress.method.title', 'Compression Method')}</Text>
<div style={{ display: 'flex', gap: '4px' }}> <ButtonSelector
<Button value={parameters.compressionMethod}
variant={parameters.compressionMethod === 'quality' ? 'filled' : 'outline'} onChange={(value) => onParameterChange('compressionMethod', value)}
color={parameters.compressionMethod === 'quality' ? 'blue' : 'var(--text-muted)'} options={[
onClick={() => onParameterChange('compressionMethod', 'quality')} { value: 'quality', label: t('compress.method.quality', 'Quality') },
{ value: 'filesize', label: t('compress.method.filesize', 'File Size') },
]}
disabled={disabled} disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }} />
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
Quality
</div>
</Button>
<Button
variant={parameters.compressionMethod === 'filesize' ? 'filled' : 'outline'}
color={parameters.compressionMethod === 'filesize' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('compressionMethod', 'filesize')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
File Size
</div>
</Button>
</div>
</Stack> </Stack>
{/* Quality Adjustment */} {/* Quality Adjustment */}

View File

@ -5,7 +5,7 @@ import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParame
interface ConvertFromEmailSettingsProps { interface ConvertFromEmailSettingsProps {
parameters: ConvertParameters; parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void; onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean; disabled?: boolean;
} }

View File

@ -6,7 +6,7 @@ import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParame
interface ConvertFromImageSettingsProps { interface ConvertFromImageSettingsProps {
parameters: ConvertParameters; parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void; onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean; disabled?: boolean;
} }

View File

@ -5,7 +5,7 @@ import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParame
interface ConvertFromWebSettingsProps { interface ConvertFromWebSettingsProps {
parameters: ConvertParameters; parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void; onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean; disabled?: boolean;
} }

View File

@ -26,7 +26,7 @@ import { StirlingFile } from "../../../types/fileContext";
interface ConvertSettingsProps { interface ConvertSettingsProps {
parameters: ConvertParameters; parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void; onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>; getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
selectedFiles: StirlingFile[]; selectedFiles: StirlingFile[];
disabled?: boolean; disabled?: boolean;

View File

@ -6,7 +6,7 @@ import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParame
interface ConvertToImageSettingsProps { interface ConvertToImageSettingsProps {
parameters: ConvertParameters; parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void; onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean; disabled?: boolean;
} }

View File

@ -7,7 +7,7 @@ import { StirlingFile } from '../../../types/fileContext';
interface ConvertToPdfaSettingsProps { interface ConvertToPdfaSettingsProps {
parameters: ConvertParameters; parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void; onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
selectedFiles: StirlingFile[]; selectedFiles: StirlingFile[];
disabled?: boolean; disabled?: boolean;
} }

View File

@ -16,7 +16,7 @@ interface AdvancedOption {
interface AdvancedOCRSettingsProps { interface AdvancedOCRSettingsProps {
advancedOptions: string[]; advancedOptions: string[];
ocrRenderType?: string; ocrRenderType?: string;
onParameterChange: (key: keyof OCRParameters, value: any) => void; onParameterChange: <K extends keyof OCRParameters>(key: K, value: OCRParameters[K]) => void;
disabled?: boolean; disabled?: boolean;
} }

View File

@ -6,7 +6,7 @@ import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
interface OCRSettingsProps { interface OCRSettingsProps {
parameters: OCRParameters; parameters: OCRParameters;
onParameterChange: (key: keyof OCRParameters, value: any) => void; onParameterChange: <K extends keyof OCRParameters>(key: K, value: OCRParameters[K]) => void;
disabled?: boolean; disabled?: boolean;
} }

View File

@ -0,0 +1,211 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import RedactAdvancedSettings from './RedactAdvancedSettings';
import { defaultParameters } from '../../../hooks/tools/redact/useRedactParameters';
// Mock useTranslation
const mockT = vi.fn((_key: string, fallback: string) => fallback);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('RedactAdvancedSettings', () => {
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render all advanced settings controls', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
expect(screen.getByText('Box Colour')).toBeInTheDocument();
expect(screen.getByText('Custom Extra Padding')).toBeInTheDocument();
expect(screen.getByText('Use Regex')).toBeInTheDocument();
expect(screen.getByText('Whole Word Search')).toBeInTheDocument();
expect(screen.getByText('Convert PDF to PDF-Image (Used to remove text behind the box)')).toBeInTheDocument();
});
test('should display current parameter values', () => {
const customParameters = {
...defaultParameters,
redactColor: '#FF0000',
customPadding: 0.5,
useRegex: true,
wholeWordSearch: true,
convertPDFToImage: false,
};
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={customParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Check color input value
const colorInput = screen.getByDisplayValue('#FF0000');
expect(colorInput).toBeInTheDocument();
// Check number input value
const paddingInput = screen.getByDisplayValue('0.5');
expect(paddingInput).toBeInTheDocument();
// Check checkbox states
const useRegexCheckbox = screen.getByLabelText('Use Regex');
const wholeWordCheckbox = screen.getByLabelText('Whole Word Search');
const convertCheckbox = screen.getByLabelText('Convert PDF to PDF-Image (Used to remove text behind the box)');
expect(useRegexCheckbox).toBeChecked();
expect(wholeWordCheckbox).toBeChecked();
expect(convertCheckbox).not.toBeChecked();
});
test('should call onParameterChange when color is changed', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const colorInput = screen.getByDisplayValue('#000000');
fireEvent.change(colorInput, { target: { value: '#FF0000' } });
expect(mockOnParameterChange).toHaveBeenCalledWith('redactColor', '#FF0000');
});
test('should call onParameterChange when padding is changed', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const paddingInput = screen.getByDisplayValue('0.1');
fireEvent.change(paddingInput, { target: { value: '0.5' } });
expect(mockOnParameterChange).toHaveBeenCalledWith('customPadding', 0.5);
});
test('should handle invalid padding values', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const paddingInput = screen.getByDisplayValue('0.1');
// Simulate NumberInput onChange with invalid value (empty string)
const numberInput = paddingInput.closest('.mantine-NumberInput-root');
if (numberInput) {
// Find the input and trigger change with empty value
fireEvent.change(paddingInput, { target: { value: '' } });
// The component should default to 0.1 for invalid values
expect(mockOnParameterChange).toHaveBeenCalledWith('customPadding', 0.1);
}
});
test.each([
{
paramName: 'useRegex' as const,
label: 'Use Regex',
initialValue: false,
expectedValue: true,
},
{
paramName: 'wholeWordSearch' as const,
label: 'Whole Word Search',
initialValue: false,
expectedValue: true,
},
{
paramName: 'convertPDFToImage' as const,
label: 'Convert PDF to PDF-Image (Used to remove text behind the box)',
initialValue: true,
expectedValue: false,
},
])('should call onParameterChange when $paramName checkbox is toggled', ({ paramName, label, initialValue, expectedValue }) => {
const customParameters = {
...defaultParameters,
[paramName]: initialValue,
};
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={customParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const checkbox = screen.getByLabelText(label);
fireEvent.click(checkbox);
expect(mockOnParameterChange).toHaveBeenCalledWith(paramName, expectedValue);
});
test.each([
{ controlType: 'color input', getValue: () => screen.getByDisplayValue('#000000') },
{ controlType: 'padding input', getValue: () => screen.getByDisplayValue('0.1') },
{ controlType: 'useRegex checkbox', getValue: () => screen.getByLabelText('Use Regex') },
{ controlType: 'wholeWordSearch checkbox', getValue: () => screen.getByLabelText('Whole Word Search') },
{ controlType: 'convertPDFToImage checkbox', getValue: () => screen.getByLabelText('Convert PDF to PDF-Image (Used to remove text behind the box)') },
])('should disable $controlType when disabled prop is true', ({ getValue }) => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
disabled={true}
/>
</TestWrapper>
);
const control = getValue();
expect(control).toBeDisabled();
});
test('should have correct padding input constraints', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// NumberInput in Mantine might not expose these attributes directly on the input element
// Instead, check that the NumberInput component is rendered with correct placeholder
const paddingInput = screen.getByPlaceholderText('0.1');
expect(paddingInput).toBeInTheDocument();
expect(paddingInput).toHaveDisplayValue('0.1');
});
});

View File

@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Stack, Text } from '@mantine/core'; import { Stack, Text } from '@mantine/core';
import { RedactMode } from '../../../hooks/tools/redact/useRedactParameters'; import { RedactMode } from '../../../hooks/tools/redact/useRedactParameters';
import ButtonSelector from '../../shared/ButtonSelector';
interface RedactModeSelectorProps { interface RedactModeSelectorProps {
mode: RedactMode; mode: RedactMode;
@ -11,36 +12,30 @@ interface RedactModeSelectorProps {
export default function RedactModeSelector({ mode, onModeChange, disabled }: RedactModeSelectorProps) { export default function RedactModeSelector({ mode, onModeChange, disabled }: RedactModeSelectorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const options = [
{
value: 'automatic' as const,
label: t('redact.modeSelector.automatic', 'Automatic')
},
{
value: 'manual' as const,
label: t('redact.modeSelector.manual', 'Manual'),
disabled: true // Keep manual mode disabled until implemented
}
];
return ( return (
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{t('redact.modeSelector.mode', 'Mode')} {t('redact.modeSelector.mode', 'Mode')}
</Text> </Text>
<div style={{ display: 'flex', gap: '4px' }}> <ButtonSelector
<Button value={mode}
variant={mode === 'automatic' ? 'filled' : 'outline'} onChange={onModeChange}
color={mode === 'automatic' ? 'blue' : 'var(--text-muted)'} options={options}
onClick={() => onModeChange('automatic')}
disabled={disabled} disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }} />
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{t('redact.modeSelector.automatic', 'Automatic')}
</div>
</Button>
<Button
variant={mode === 'manual' ? 'filled' : 'outline'}
color={mode === 'manual' ? 'blue' : 'var(--text-muted)'}
onClick={() => onModeChange('manual')}
disabled={disabled || true} // Keep manual disabled until implemented
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{t('redact.modeSelector.manual', 'Manual')}
</div>
</Button>
</div>
</Stack> </Stack>
); );
} }

View File

@ -0,0 +1,183 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import RedactSingleStepSettings from './RedactSingleStepSettings';
import { defaultParameters } from '../../../hooks/tools/redact/useRedactParameters';
// Mock useTranslation
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('RedactSingleStepSettings', () => {
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render mode selector', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
expect(screen.getByText('Mode')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Manual' })).toBeInTheDocument();
});
test('should render automatic mode settings when mode is automatic', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Default mode is automatic, so these should be visible
expect(screen.getByText('Words to Redact')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter a word')).toBeInTheDocument();
expect(screen.getByText('Box Colour')).toBeInTheDocument();
expect(screen.getByText('Use Regex')).toBeInTheDocument();
});
test('should render manual mode settings when mode is manual', () => {
const manualParameters = {
...defaultParameters,
mode: 'manual' as const,
};
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={manualParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Manual mode should show placeholder text
expect(screen.getByText('Manual redaction interface will be available here when implemented.')).toBeInTheDocument();
// Automatic mode settings should not be visible
expect(screen.queryByText('Words to Redact')).not.toBeInTheDocument();
});
test('should pass through parameter changes from automatic settings', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Test adding a word
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: 'TestWord' } });
fireEvent.click(addButton);
expect(mockOnParameterChange).toHaveBeenCalledWith('wordsToRedact', ['TestWord']);
});
test('should pass through parameter changes from advanced settings', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Test changing color
const colorInput = screen.getByDisplayValue('#000000');
fireEvent.change(colorInput, { target: { value: '#FF0000' } });
expect(mockOnParameterChange).toHaveBeenCalledWith('redactColor', '#FF0000');
});
test('should disable all controls when disabled prop is true', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
disabled={true}
/>
</TestWrapper>
);
// Mode selector buttons should be disabled
expect(screen.getByRole('button', { name: 'Automatic' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Manual' })).toBeDisabled();
// Automatic settings controls should be disabled
expect(screen.getByPlaceholderText('Enter a word')).toBeDisabled();
expect(screen.getByRole('button', { name: '+ Add' })).toBeDisabled();
expect(screen.getByDisplayValue('#000000')).toBeDisabled();
});
test('should show current parameter values in automatic mode', () => {
const customParameters = {
...defaultParameters,
wordsToRedact: ['Word1', 'Word2'],
redactColor: '#FF0000',
useRegex: true,
customPadding: 0.5,
};
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={customParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Check that word tags are displayed
expect(screen.getByText('Word1')).toBeInTheDocument();
expect(screen.getByText('Word2')).toBeInTheDocument();
// Check that color is displayed
expect(screen.getByDisplayValue('#FF0000')).toBeInTheDocument();
// Check that regex checkbox is checked
const useRegexCheckbox = screen.getByLabelText('Use Regex');
expect(useRegexCheckbox).toBeChecked();
// Check that padding value is displayed
expect(screen.getByDisplayValue('0.5')).toBeInTheDocument();
});
test('should maintain consistent spacing and layout', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Check that the Stack container exists
const container = screen.getByText('Mode').closest('.mantine-Stack-root');
expect(container).toBeInTheDocument();
});
});

View File

@ -0,0 +1,191 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import WordsToRedactInput from './WordsToRedactInput';
// Mock useTranslation
const mockT = vi.fn((_key: string, fallback: string) => fallback);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('WordsToRedactInput', () => {
const mockOnWordsChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render with title and input field', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
expect(screen.getByText('Words to Redact')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter a word')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '+ Add' })).toBeInTheDocument();
});
test.each([
{ trigger: 'Add button click', action: (_input: HTMLElement, addButton: HTMLElement) => fireEvent.click(addButton) },
{ trigger: 'Enter key press', action: (input: HTMLElement) => fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }) },
])('should add word when $trigger', ({ action }) => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: 'TestWord' } });
action(input, addButton);
expect(mockOnWordsChange).toHaveBeenCalledWith(['TestWord']);
});
test('should not add empty word', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.click(addButton);
expect(mockOnWordsChange).not.toHaveBeenCalled();
});
test('should not add duplicate word', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={['Existing']}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: 'Existing' } });
fireEvent.click(addButton);
expect(mockOnWordsChange).not.toHaveBeenCalled();
});
test('should trim whitespace when adding word', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: ' TestWord ' } });
fireEvent.click(addButton);
expect(mockOnWordsChange).toHaveBeenCalledWith(['TestWord']);
});
test('should remove word when x button is clicked', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={['Word1', 'Word2']}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const removeButtons = screen.getAllByText('×');
fireEvent.click(removeButtons[0]);
expect(mockOnWordsChange).toHaveBeenCalledWith(['Word2']);
});
test('should clear input after adding word', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word') as HTMLInputElement;
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: 'TestWord' } });
fireEvent.click(addButton);
expect(input.value).toBe('');
});
test.each([
{ description: 'disable Add button when input is empty', inputValue: '', expectedDisabled: true },
{ description: 'enable Add button when input has text', inputValue: 'TestWord', expectedDisabled: false },
])('should $description', ({ inputValue, expectedDisabled }) => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: inputValue } });
expect(addButton).toHaveProperty('disabled', expectedDisabled);
});
test('should disable all controls when disabled prop is true', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={['Word1']}
onWordsChange={mockOnWordsChange}
disabled={true}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
const removeButton = screen.getByText('×');
expect(input).toBeDisabled();
expect(addButton).toBeDisabled();
expect(removeButton.closest('button')).toBeDisabled();
});
});

View File

@ -38,11 +38,10 @@ export default function WordsToRedactInput({ wordsToRedact, onWordsChange, disab
{/* Current words */} {/* Current words */}
{wordsToRedact.map((word, index) => ( {wordsToRedact.map((word, index) => (
<Group key={index} justify="space-between" style={{ <Group key={index} justify="space-between" p="sm" style={{
padding: '8px 12px', borderRadius: 'var(--mantine-radius-sm)',
backgroundColor: '#f8f9fa', border: `1px solid var(--mantine-color-gray-3)`,
borderRadius: '4px', backgroundColor: 'var(--mantine-color-gray-0)'
border: '1px solid #e9ecef'
}}> }}>
<Text size="sm">{word}</Text> <Text size="sm">{word}</Text>
<ActionIcon <ActionIcon
@ -58,7 +57,7 @@ export default function WordsToRedactInput({ wordsToRedact, onWordsChange, disab
))} ))}
{/* Add new word input */} {/* Add new word input */}
<Stack gap="sm"> <Group gap="sm" align="end">
<TextInput <TextInput
placeholder={t('redact.auto.wordsToRedact.placeholder', 'Enter a word')} placeholder={t('redact.auto.wordsToRedact.placeholder', 'Enter a word')}
value={currentWord} value={currentWord}
@ -66,17 +65,17 @@ export default function WordsToRedactInput({ wordsToRedact, onWordsChange, disab
onKeyDown={handleKeyPress} onKeyDown={handleKeyPress}
disabled={disabled} disabled={disabled}
size="sm" size="sm"
style={{ flex: 1 }}
/> />
<Button <Button
size="sm" size="sm"
variant="light" variant="light"
onClick={addWord} onClick={addWord}
disabled={disabled || !currentWord.trim()} disabled={disabled || !currentWord.trim()}
style={{ alignSelf: 'flex-start' }}
> >
+ {t('redact.auto.wordsToRedact.addAnother', 'Add Another')} + {t('redact.auto.wordsToRedact.add', 'Add')}
</Button> </Button>
</Stack> </Group>
{/* Examples */} {/* Examples */}
{wordsToRedact.length === 0 && ( {wordsToRedact.length === 0 && (

View File

@ -4,7 +4,7 @@ import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/us
interface RemovePasswordSettingsProps { interface RemovePasswordSettingsProps {
parameters: RemovePasswordParameters; parameters: RemovePasswordParameters;
onParameterChange: (key: keyof RemovePasswordParameters, value: string) => void; onParameterChange: <K extends keyof RemovePasswordParameters>(key: K, value: RemovePasswordParameters[K]) => void;
disabled?: boolean; disabled?: boolean;
} }

View File

@ -4,7 +4,7 @@ import { SanitizeParameters, defaultParameters } from "../../../hooks/tools/sani
interface SanitizeSettingsProps { interface SanitizeSettingsProps {
parameters: SanitizeParameters; parameters: SanitizeParameters;
onParameterChange: (key: keyof SanitizeParameters, value: boolean) => void; onParameterChange: <K extends keyof SanitizeParameters>(key: K, value: SanitizeParameters[K]) => void;
disabled?: boolean; disabled?: boolean;
} }

View File

@ -1,11 +1,11 @@
import { Stack, TextInput, Select, Checkbox } from '@mantine/core'; import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { isSplitMode, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants'; import { isSplitMode, isSplitType, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants';
import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters'; import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters';
export interface SplitSettingsProps { export interface SplitSettingsProps {
parameters: SplitParameters; parameters: SplitParameters;
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void; onParameterChange: <K extends keyof SplitParameters>(key: K, value: SplitParameters[K]) => void;
disabled?: boolean; disabled?: boolean;
} }
@ -62,7 +62,7 @@ const SplitSettings = ({
<Select <Select
label={t("split-by-size-or-count.type.label", "Split Type")} label={t("split-by-size-or-count.type.label", "Split Type")}
value={parameters.splitType} value={parameters.splitType}
onChange={(v) => v && onParameterChange('splitType', v)} onChange={(v) => isSplitType(v) && onParameterChange('splitType', v)}
disabled={disabled} disabled={disabled}
data={[ data={[
{ value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") }, { value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") },

View File

@ -40,7 +40,7 @@ export const useRedactWordsTips = (): TooltipContent => {
}, },
{ {
title: t("redact.tooltip.words.examples.title", "Common Examples"), title: t("redact.tooltip.words.examples.title", "Common Examples"),
description: t("redact.tooltip.words.examples.text", "Typical words to redact include: 'Confidential', 'SSN:', phone numbers, email addresses, or specific names.") description: t("redact.tooltip.words.examples.text", "Typical words to redact include: bank details, email addresses, or specific names.")
} }
] ]
}; };
@ -55,8 +55,8 @@ export const useRedactAdvancedTips = (): TooltipContent => {
}, },
tips: [ tips: [
{ {
title: t("redact.tooltip.advanced.color.title", "Box Color & Padding"), title: t("redact.tooltip.advanced.color.title", "Box Colour & Padding"),
description: t("redact.tooltip.advanced.color.text", "Customize the appearance of redaction boxes. Black is standard, but you can choose any color. Padding adds extra space around the found text."), description: t("redact.tooltip.advanced.color.text", "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text."),
}, },
{ {
title: t("redact.tooltip.advanced.regex.title", "Use Regex"), title: t("redact.tooltip.advanced.regex.title", "Use Regex"),

View File

@ -0,0 +1,142 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { buildRedactFormData, redactOperationConfig, useRedactOperation } from './useRedactOperation';
import { defaultParameters, RedactParameters } from './useRedactParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => {
const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) })
}));
// Mock the error handler utility
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => vi.fn())
}));
describe('buildRedactFormData', () => {
const mockFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
test('should build form data for automatic mode', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
wordsToRedact: ['Confidential', 'Secret'],
useRegex: true,
wholeWordSearch: true,
redactColor: '#FF0000',
customPadding: 0.5,
convertPDFToImage: false,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('fileInput')).toBe(mockFile);
expect(formData.get('listOfText')).toBe('Confidential\nSecret');
expect(formData.get('useRegex')).toBe('true');
expect(formData.get('wholeWordSearch')).toBe('true');
expect(formData.get('redactColor')).toBe('FF0000'); // Hash should be removed
expect(formData.get('customPadding')).toBe('0.5');
expect(formData.get('convertPDFToImage')).toBe('false');
});
test('should handle empty words array', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
wordsToRedact: [],
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('listOfText')).toBe('');
});
test('should join multiple words with newlines', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
wordsToRedact: ['Word1', 'Word2', 'Word3'],
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('listOfText')).toBe('Word1\nWord2\nWord3');
});
test.each([
{ description: 'remove hash from redact color', redactColor: '#123456', expected: '123456' },
{ description: 'handle redact color without hash', redactColor: 'ABCDEF', expected: 'ABCDEF' },
])('should $description', ({ redactColor, expected }) => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
redactColor,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('redactColor')).toBe(expected);
});
test('should convert boolean parameters to strings', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
useRegex: false,
wholeWordSearch: true,
convertPDFToImage: false,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('useRegex')).toBe('false');
expect(formData.get('wholeWordSearch')).toBe('true');
expect(formData.get('convertPDFToImage')).toBe('false');
});
test('should throw error for manual mode (not implemented)', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'manual',
};
expect(() => buildRedactFormData(parameters, mockFile)).toThrow('Manual redaction not yet implemented');
});
});
describe('useRedactOperation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('should call useToolOperation with correct configuration', async () => {
const { useToolOperation } = await import('../shared/useToolOperation');
const mockUseToolOperation = vi.mocked(useToolOperation);
renderHook(() => useRedactOperation());
expect(mockUseToolOperation).toHaveBeenCalledWith({
...redactOperationConfig,
getErrorMessage: expect.any(Function),
});
});
test('should provide error handler to useToolOperation', async () => {
const { useToolOperation } = await import('../shared/useToolOperation');
const mockUseToolOperation = vi.mocked(useToolOperation);
renderHook(() => useRedactOperation());
const callArgs = mockUseToolOperation.mock.calls[0][0];
expect(typeof callArgs.getErrorMessage).toBe('function');
});
});

View File

@ -0,0 +1,134 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRedactParameters, defaultParameters } from './useRedactParameters';
describe('useRedactParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRedactParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: 'mode' as const, value: 'manual' as const },
{ paramName: 'wordsToRedact' as const, value: ['word1', 'word2'] },
{ paramName: 'useRegex' as const, value: true },
{ paramName: 'wholeWordSearch' as const, value: true },
{ paramName: 'redactColor' as const, value: '#FF0000' },
{ paramName: 'customPadding' as const, value: 0.5 },
{ paramName: 'convertPDFToImage' as const, value: false }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toStrictEqual(value);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useRedactParameters());
// Modify some parameters
act(() => {
result.current.updateParameter('mode', 'manual');
result.current.updateParameter('wordsToRedact', ['test']);
result.current.updateParameter('useRegex', true);
});
// Reset parameters
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
describe('validation', () => {
test.each([
{ description: 'validate when wordsToRedact has non-empty words in automatic mode', wordsToRedact: ['word1', 'word2'], expected: true },
{ description: 'not validate when wordsToRedact is empty in automatic mode', wordsToRedact: [], expected: false },
{ description: 'not validate when wordsToRedact contains only empty strings in automatic mode', wordsToRedact: ['', ' ', ''], expected: false },
{ description: 'validate when wordsToRedact contains at least one non-empty word in automatic mode', wordsToRedact: ['', 'valid', ' '], expected: true },
])('should $description', ({ wordsToRedact, expected }) => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'automatic');
result.current.updateParameter('wordsToRedact', wordsToRedact);
});
expect(result.current.validateParameters()).toBe(expected);
});
test('should not validate in manual mode (not implemented)', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'manual');
});
expect(result.current.validateParameters()).toBe(false);
});
});
describe('endpoint handling', () => {
test('should return correct endpoint for automatic mode', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'automatic');
});
expect(result.current.getEndpointName()).toBe('/api/v1/security/auto-redact');
});
test('should throw error for manual mode (not implemented)', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'manual');
});
expect(() => result.current.getEndpointName()).toThrow('Manual redaction not yet implemented');
});
});
test('should maintain parameter state across updates', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('redactColor', '#FF0000');
result.current.updateParameter('customPadding', 0.5);
result.current.updateParameter('wordsToRedact', ['word1']);
});
// All parameters should be updated
expect(result.current.parameters.redactColor).toBe('#FF0000');
expect(result.current.parameters.customPadding).toBe(0.5);
expect(result.current.parameters.wordsToRedact).toEqual(['word1']);
// Other parameters should remain at defaults
expect(result.current.parameters.mode).toBe('automatic');
expect(result.current.parameters.useRegex).toBe(false);
expect(result.current.parameters.wholeWordSearch).toBe(false);
expect(result.current.parameters.convertPDFToImage).toBe(true);
});
test('should handle array parameter updates correctly', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('wordsToRedact', ['initial']);
});
expect(result.current.parameters.wordsToRedact).toEqual(['initial']);
act(() => {
result.current.updateParameter('wordsToRedact', ['updated', 'multiple']);
});
expect(result.current.parameters.wordsToRedact).toEqual(['updated', 'multiple']);
});
});

View File

@ -59,6 +59,7 @@ i18n
.init({ .init({
fallbackLng: 'en-GB', fallbackLng: 'en-GB',
supportedLngs: Object.keys(supportedLanguages), supportedLngs: Object.keys(supportedLanguages),
load: 'currentOnly',
nonExplicitSupportedLngs: false, nonExplicitSupportedLngs: false,
debug: process.env.NODE_ENV === 'development', debug: process.env.NODE_ENV === 'development',