mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 09:29:24 +00:00
Add auto-redact to V2 (#4417)
# Description of Changes Adds auto-redact tool to V2, with manual-redact in the UI but explicitly disabled. Also creates a shared component for the large buttons we're using in a couple different tools and uses consistently.
This commit is contained in:
parent
494ef801a2
commit
9d723eae69
@ -498,13 +498,9 @@
|
|||||||
"title": "Show Javascript",
|
"title": "Show Javascript",
|
||||||
"desc": "Searches and displays any JS injected into a PDF"
|
"desc": "Searches and displays any JS injected into a PDF"
|
||||||
},
|
},
|
||||||
"autoRedact": {
|
|
||||||
"title": "Auto Redact",
|
|
||||||
"desc": "Auto Redacts(Blacks out) text in a PDF based on input text"
|
|
||||||
},
|
|
||||||
"redact": {
|
"redact": {
|
||||||
"title": "Manual Redaction",
|
"title": "Redact",
|
||||||
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
|
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
|
||||||
},
|
},
|
||||||
"overlay-pdfs": {
|
"overlay-pdfs": {
|
||||||
"title": "Overlay PDFs",
|
"title": "Overlay PDFs",
|
||||||
@ -1583,25 +1579,97 @@
|
|||||||
"downloadJS": "Download Javascript",
|
"downloadJS": "Download Javascript",
|
||||||
"submit": "Show"
|
"submit": "Show"
|
||||||
},
|
},
|
||||||
"autoRedact": {
|
"redact": {
|
||||||
"tags": "Redact,Hide,black out,black,marker,hidden",
|
"tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact",
|
||||||
"title": "Auto Redact",
|
"title": "Redact",
|
||||||
|
"submit": "Redact",
|
||||||
|
"error": {
|
||||||
|
"failed": "An error occurred while redacting the PDF."
|
||||||
|
},
|
||||||
|
"modeSelector": {
|
||||||
|
"title": "Redaction Method",
|
||||||
|
"mode": "Mode",
|
||||||
|
"automatic": "Automatic",
|
||||||
|
"automaticDesc": "Redact text based on search terms",
|
||||||
|
"manual": "Manual",
|
||||||
|
"manualDesc": "Click and drag to redact specific areas",
|
||||||
|
"manualComingSoon": "Manual redaction coming soon"
|
||||||
|
},
|
||||||
|
"auto": {
|
||||||
"header": "Auto Redact",
|
"header": "Auto Redact",
|
||||||
"colorLabel": "Colour",
|
"settings": {
|
||||||
"textsToRedactLabel": "Text to Redact (line-separated)",
|
"title": "Redaction Settings",
|
||||||
"textsToRedactPlaceholder": "e.g. \\nConfidential \\nTop-Secret",
|
"advancedTitle": "Advanced"
|
||||||
|
},
|
||||||
|
"colorLabel": "Box Colour",
|
||||||
|
"wordsToRedact": {
|
||||||
|
"title": "Words to Redact",
|
||||||
|
"placeholder": "Enter a word",
|
||||||
|
"add": "Add",
|
||||||
|
"examples": "Examples: Confidential, Top-Secret"
|
||||||
|
},
|
||||||
"useRegexLabel": "Use Regex",
|
"useRegexLabel": "Use Regex",
|
||||||
"wholeWordSearchLabel": "Whole Word Search",
|
"wholeWordSearchLabel": "Whole Word Search",
|
||||||
"customPaddingLabel": "Custom Extra Padding",
|
"customPaddingLabel": "Custom Extra Padding",
|
||||||
"convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)",
|
"convertPDFToImageLabel": "Convert PDF to PDF-Image"
|
||||||
"submitButton": "Submit"
|
|
||||||
},
|
},
|
||||||
"redact": {
|
"tooltip": {
|
||||||
"tags": "Redact,Hide,black out,black,marker,hidden,manual",
|
"mode": {
|
||||||
|
"header": {
|
||||||
|
"title": "Redaction Method"
|
||||||
|
},
|
||||||
|
"automatic": {
|
||||||
|
"title": "Automatic Redaction",
|
||||||
|
"text": "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, addresses, or confidential markers."
|
||||||
|
},
|
||||||
|
"manual": {
|
||||||
"title": "Manual Redaction",
|
"title": "Manual Redaction",
|
||||||
|
"text": "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"words": {
|
||||||
|
"header": {
|
||||||
|
"title": "Words to Redact"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"title": "Text Matching",
|
||||||
|
"text": "Enter words or phrases to find and redact in your document. Each word will be searched for separately."
|
||||||
|
},
|
||||||
|
"bullet1": "Add one word at a time",
|
||||||
|
"bullet2": "Press Enter or click 'Add Another' to add",
|
||||||
|
"bullet3": "Click × to remove words",
|
||||||
|
"examples": {
|
||||||
|
"title": "Common Examples",
|
||||||
|
"text": "Typical words to redact include: bank details, email addresses, or specific names."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"header": {
|
||||||
|
"title": "Advanced Redaction Settings"
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"title": "Box Colour & Padding",
|
||||||
|
"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": {
|
||||||
|
"title": "Use Regex",
|
||||||
|
"text": "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns.",
|
||||||
|
"bullet1": "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format",
|
||||||
|
"bullet2": "Use with caution - test thoroughly"
|
||||||
|
},
|
||||||
|
"wholeWord": {
|
||||||
|
"title": "Whole Word Search",
|
||||||
|
"text": "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled."
|
||||||
|
},
|
||||||
|
"convert": {
|
||||||
|
"title": "Convert to PDF-Image",
|
||||||
|
"text": "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"manual": {
|
||||||
"header": "Manual Redaction",
|
"header": "Manual Redaction",
|
||||||
"submit": "Redact",
|
"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": {
|
||||||
@ -1609,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",
|
||||||
@ -1622,11 +1690,12 @@
|
|||||||
"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",
|
||||||
"applyChanges": "Apply Changes"
|
"applyChanges": "Apply Changes"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"tableExtraxt": {
|
"tableExtraxt": {
|
||||||
"tags": "CSV,Table Extraction,extract,convert"
|
"tags": "CSV,Table Extraction,extract,convert"
|
||||||
@ -1837,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"
|
||||||
|
216
frontend/src/components/shared/ButtonSelector.test.tsx
Normal file
216
frontend/src/components/shared/ButtonSelector.test.tsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
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}
|
||||||
|
label="Test Label"
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Label')).toBeInTheDocument();
|
||||||
|
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}
|
||||||
|
label="Selection Label"
|
||||||
|
/>
|
||||||
|
</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');
|
||||||
|
expect(screen.getByText('Selection Label')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
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}
|
||||||
|
label="Layout Label"
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: 'Option 1' });
|
||||||
|
expect(button).not.toHaveStyle({ flex: '1' });
|
||||||
|
expect(screen.getByText('Layout Label')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not render label element when not provided', () => {
|
||||||
|
const options = [
|
||||||
|
{ value: 'option1', label: 'Option 1' },
|
||||||
|
{ value: 'option2', label: 'Option 2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<ButtonSelector
|
||||||
|
value="option1"
|
||||||
|
onChange={mockOnChange}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render buttons
|
||||||
|
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Stack should only contain the Group (buttons), no Text element for label
|
||||||
|
const stackElement = container.querySelector('[class*="mantine-Stack-root"]');
|
||||||
|
expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text
|
||||||
|
});
|
||||||
|
});
|
59
frontend/src/components/shared/ButtonSelector.tsx
Normal file
59
frontend/src/components/shared/ButtonSelector.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { Button, Group, Stack, Text } 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>[];
|
||||||
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonSelector = <T extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
label = undefined,
|
||||||
|
disabled = false,
|
||||||
|
fullWidth = true,
|
||||||
|
}: ButtonSelectorProps<T>) => {
|
||||||
|
return (
|
||||||
|
<Stack gap='var(--mantine-spacing-sm)'>
|
||||||
|
{/* Label (if it exists) */}
|
||||||
|
{label && <Text style={{
|
||||||
|
fontSize: "var(--mantine-font-size-sm)",
|
||||||
|
lineHeight: "var(--mantine-line-height-sm)",
|
||||||
|
fontWeight: "var(--font-weight-medium)",
|
||||||
|
}}>{label}</Text>}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<Group gap='4px'>
|
||||||
|
{options.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
variant={value === option.value ? 'filled' : 'outline'}
|
||||||
|
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
disabled={disabled || option.disabled}
|
||||||
|
style={{
|
||||||
|
flex: fullWidth ? 1 : undefined,
|
||||||
|
height: 'auto',
|
||||||
|
minHeight: '2.5rem',
|
||||||
|
fontSize: 'var(--mantine-font-size-sm)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ButtonSelector;
|
@ -1,5 +1,5 @@
|
|||||||
import { Button, 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';
|
||||||
@ -11,32 +11,21 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<ButtonSelector
|
||||||
<div style={{ display: 'flex', gap: '4px' }}>
|
value={watermarkType}
|
||||||
<Button
|
onChange={onWatermarkTypeChange}
|
||||||
variant={watermarkType === 'text' ? 'filled' : 'outline'}
|
options={[
|
||||||
color={watermarkType === 'text' ? 'blue' : 'var(--text-muted)'}
|
{
|
||||||
onClick={() => onWatermarkTypeChange('text')}
|
value: 'text',
|
||||||
|
label: t('watermark.watermarkType.text', 'Text'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'image',
|
||||||
|
label: t('watermark.watermarkType.image', 'Image'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
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;
|
||||||
@ -18,33 +19,16 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
|||||||
|
|
||||||
<Divider ml='-md'></Divider>
|
<Divider ml='-md'></Divider>
|
||||||
{/* Compression Method */}
|
{/* Compression Method */}
|
||||||
<Stack gap="sm">
|
<ButtonSelector
|
||||||
<Text size="sm" fw={500}>Compression Method</Text>
|
label={t('compress.method.title', 'Compression Method')}
|
||||||
<div style={{ display: 'flex', gap: '4px' }}>
|
value={parameters.compressionMethod}
|
||||||
<Button
|
onChange={(value) => onParameterChange('compressionMethod', value)}
|
||||||
variant={parameters.compressionMethod === 'quality' ? 'filled' : 'outline'}
|
options={[
|
||||||
color={parameters.compressionMethod === 'quality' ? 'blue' : 'var(--text-muted)'}
|
{ value: 'quality', label: t('compress.method.quality', 'Quality') },
|
||||||
onClick={() => onParameterChange('compressionMethod', '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>
|
|
||||||
|
|
||||||
{/* Quality Adjustment */}
|
{/* Quality Adjustment */}
|
||||||
{parameters.compressionMethod === 'quality' && (
|
{parameters.compressionMethod === 'quality' && (
|
||||||
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,69 @@
|
|||||||
|
import { Stack, NumberInput, ColorInput, Checkbox } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { RedactParameters } from "../../../hooks/tools/redact/useRedactParameters";
|
||||||
|
|
||||||
|
interface RedactAdvancedSettingsProps {
|
||||||
|
parameters: RedactParameters;
|
||||||
|
onParameterChange: <K extends keyof RedactParameters>(key: K, value: RedactParameters[K]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RedactAdvancedSettings = ({ parameters, onParameterChange, disabled = false }: RedactAdvancedSettingsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Box Color */}
|
||||||
|
<ColorInput
|
||||||
|
label={t('redact.auto.colorLabel', 'Box Colour')}
|
||||||
|
value={parameters.redactColor}
|
||||||
|
onChange={(value) => onParameterChange('redactColor', value)}
|
||||||
|
disabled={disabled}
|
||||||
|
size="sm"
|
||||||
|
format="hex"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Box Padding */}
|
||||||
|
<NumberInput
|
||||||
|
label={t('redact.auto.customPaddingLabel', 'Custom Extra Padding')}
|
||||||
|
value={parameters.customPadding}
|
||||||
|
onChange={(value) => onParameterChange('customPadding', typeof value === 'number' ? value : 0.1)}
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
step={0.1}
|
||||||
|
disabled={disabled}
|
||||||
|
size="sm"
|
||||||
|
placeholder="0.1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Use Regex */}
|
||||||
|
<Checkbox
|
||||||
|
label={t('redact.auto.useRegexLabel', 'Use Regex')}
|
||||||
|
checked={parameters.useRegex}
|
||||||
|
onChange={(e) => onParameterChange('useRegex', e.currentTarget.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Whole Word Search */}
|
||||||
|
<Checkbox
|
||||||
|
label={t('redact.auto.wholeWordSearchLabel', 'Whole Word Search')}
|
||||||
|
checked={parameters.wholeWordSearch}
|
||||||
|
onChange={(e) => onParameterChange('wholeWordSearch', e.currentTarget.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Convert PDF to PDF-Image */}
|
||||||
|
<Checkbox
|
||||||
|
label={t('redact.auto.convertPDFToImageLabel', 'Convert PDF to PDF-Image (Used to remove text behind the box)')}
|
||||||
|
checked={parameters.convertPDFToImage}
|
||||||
|
onChange={(e) => onParameterChange('convertPDFToImage', e.currentTarget.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RedactAdvancedSettings;
|
33
frontend/src/components/tools/redact/RedactModeSelector.tsx
Normal file
33
frontend/src/components/tools/redact/RedactModeSelector.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { RedactMode } from '../../../hooks/tools/redact/useRedactParameters';
|
||||||
|
import ButtonSelector from '../../shared/ButtonSelector';
|
||||||
|
|
||||||
|
interface RedactModeSelectorProps {
|
||||||
|
mode: RedactMode;
|
||||||
|
onModeChange: (mode: RedactMode) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RedactModeSelector({ mode, onModeChange, disabled }: RedactModeSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonSelector
|
||||||
|
label={t('redact.modeSelector.mode', 'Mode')}
|
||||||
|
value={mode}
|
||||||
|
onChange={onModeChange}
|
||||||
|
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
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,61 @@
|
|||||||
|
import { Stack, Divider } from "@mantine/core";
|
||||||
|
import { RedactParameters } from "../../../hooks/tools/redact/useRedactParameters";
|
||||||
|
import RedactModeSelector from "./RedactModeSelector";
|
||||||
|
import WordsToRedactInput from "./WordsToRedactInput";
|
||||||
|
import RedactAdvancedSettings from "./RedactAdvancedSettings";
|
||||||
|
|
||||||
|
interface RedactSingleStepSettingsProps {
|
||||||
|
parameters: RedactParameters;
|
||||||
|
onParameterChange: <K extends keyof RedactParameters>(key: K, value: RedactParameters[K]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RedactSingleStepSettings = ({ parameters, onParameterChange, disabled = false }: RedactSingleStepSettingsProps) => {
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Mode Selection */}
|
||||||
|
<RedactModeSelector
|
||||||
|
mode={parameters.mode}
|
||||||
|
onModeChange={(mode) => onParameterChange('mode', mode)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Automatic Mode Settings */}
|
||||||
|
{parameters.mode === 'automatic' && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Words to Redact */}
|
||||||
|
<WordsToRedactInput
|
||||||
|
wordsToRedact={parameters.wordsToRedact}
|
||||||
|
onWordsChange={(words) => onParameterChange('wordsToRedact', words)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Advanced Settings */}
|
||||||
|
<RedactAdvancedSettings
|
||||||
|
parameters={parameters}
|
||||||
|
onParameterChange={onParameterChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manual Mode Placeholder */}
|
||||||
|
{parameters.mode === 'manual' && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Stack gap="md">
|
||||||
|
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||||
|
Manual redaction interface will be available here when implemented.
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RedactSingleStepSettings;
|
191
frontend/src/components/tools/redact/WordsToRedactInput.test.tsx
Normal file
191
frontend/src/components/tools/redact/WordsToRedactInput.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
99
frontend/src/components/tools/redact/WordsToRedactInput.tsx
Normal file
99
frontend/src/components/tools/redact/WordsToRedactInput.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Stack, Text, TextInput, Button, Group, ActionIcon } from '@mantine/core';
|
||||||
|
|
||||||
|
interface WordsToRedactInputProps {
|
||||||
|
wordsToRedact: string[];
|
||||||
|
onWordsChange: (words: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WordsToRedactInput({ wordsToRedact, onWordsChange, disabled }: WordsToRedactInputProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [currentWord, setCurrentWord] = useState('');
|
||||||
|
|
||||||
|
const addWord = () => {
|
||||||
|
if (currentWord.trim() && !wordsToRedact.includes(currentWord.trim())) {
|
||||||
|
onWordsChange([...wordsToRedact, currentWord.trim()]);
|
||||||
|
setCurrentWord('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeWord = (index: number) => {
|
||||||
|
onWordsChange(wordsToRedact.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addWord();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t('redact.auto.wordsToRedact.title', 'Words to Redact')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Current words */}
|
||||||
|
{wordsToRedact.map((word, index) => (
|
||||||
|
<Group key={index} justify="space-between" p="sm" style={{
|
||||||
|
borderRadius: 'var(--mantine-radius-sm)',
|
||||||
|
border: `1px solid var(--mantine-color-gray-3)`,
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-0)'
|
||||||
|
}}>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
style={{
|
||||||
|
maxWidth: '80%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
title={word}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={() => removeWord(index)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add new word input */}
|
||||||
|
<Group gap="sm" align="end">
|
||||||
|
<TextInput
|
||||||
|
placeholder={t('redact.auto.wordsToRedact.placeholder', 'Enter a word')}
|
||||||
|
value={currentWord}
|
||||||
|
onChange={(e) => setCurrentWord(e.target.value)}
|
||||||
|
onKeyDown={handleKeyPress}
|
||||||
|
disabled={disabled}
|
||||||
|
size="sm"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onClick={addWord}
|
||||||
|
disabled={disabled || !currentWord.trim()}
|
||||||
|
>
|
||||||
|
+ {t('redact.auto.wordsToRedact.add', 'Add')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Examples */}
|
||||||
|
{wordsToRedact.length === 0 && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('redact.auto.wordsToRedact.examples', 'Examples: Confidential, Top-Secret')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
79
frontend/src/components/tooltips/useRedactTips.ts
Normal file
79
frontend/src/components/tooltips/useRedactTips.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { TooltipContent } from '../../types/tips';
|
||||||
|
|
||||||
|
export const useRedactModeTips = (): TooltipContent => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: {
|
||||||
|
title: t("redact.tooltip.mode.header.title", "Redaction Method")
|
||||||
|
},
|
||||||
|
tips: [
|
||||||
|
{
|
||||||
|
title: t("redact.tooltip.mode.automatic.title", "Automatic Redaction"),
|
||||||
|
description: t("redact.tooltip.mode.automatic.text", "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, SSNs, or confidential markers.")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("redact.tooltip.mode.manual.title", "Manual Redaction"),
|
||||||
|
description: t("redact.tooltip.mode.manual.text", "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRedactWordsTips = (): TooltipContent => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: {
|
||||||
|
title: t("redact.tooltip.words.header.title", "Words to Redact")
|
||||||
|
},
|
||||||
|
tips: [
|
||||||
|
{
|
||||||
|
title: t("redact.tooltip.words.description.title", "Text Matching"),
|
||||||
|
description: t("redact.tooltip.words.description.text", "Enter words or phrases to find and redact in your document. Each word will be searched for separately."),
|
||||||
|
bullets: [
|
||||||
|
t("redact.tooltip.words.bullet1", "Add one word at a time"),
|
||||||
|
t("redact.tooltip.words.bullet2", "Press Enter or click 'Add Another' to add"),
|
||||||
|
t("redact.tooltip.words.bullet3", "Click × to remove words")
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("redact.tooltip.words.examples.title", "Common Examples"),
|
||||||
|
description: t("redact.tooltip.words.examples.text", "Typical words to redact include: bank details, email addresses, or specific names.")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRedactAdvancedTips = (): TooltipContent => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: {
|
||||||
|
title: t("redact.tooltip.advanced.header.title", "Advanced Redaction Settings")
|
||||||
|
},
|
||||||
|
tips: [
|
||||||
|
{
|
||||||
|
title: t("redact.tooltip.advanced.color.title", "Box Colour & Padding"),
|
||||||
|
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"),
|
||||||
|
description: t("redact.tooltip.advanced.regex.text", "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns."),
|
||||||
|
bullets: [
|
||||||
|
t("redact.tooltip.advanced.regex.bullet1", "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format"),
|
||||||
|
t("redact.tooltip.advanced.regex.bullet2", "Use with caution - test thoroughly")
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("redact.tooltip.advanced.wholeWord.title", "Whole Word Search"),
|
||||||
|
description: t("redact.tooltip.advanced.wholeWord.text", "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled.")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("redact.tooltip.advanced.convert.title", "Convert to PDF-Image"),
|
||||||
|
description: t("redact.tooltip.advanced.convert.text", "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable.")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
@ -32,6 +32,7 @@ import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCerti
|
|||||||
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||||
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
|
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
|
||||||
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
||||||
|
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
||||||
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";
|
||||||
@ -44,6 +45,8 @@ 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 FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
||||||
|
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
|
||||||
|
import Redact from "../tools/Redact";
|
||||||
import { ToolId } from "../types/toolId";
|
import { ToolId } from "../types/toolId";
|
||||||
|
|
||||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||||
@ -701,10 +704,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
redact: {
|
redact: {
|
||||||
icon: <LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.redact.title", "Redact"),
|
name: t("home.redact.title", "Redact"),
|
||||||
component: null,
|
component: Redact,
|
||||||
description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"),
|
description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"),
|
||||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||||
subcategoryId: SubcategoryId.GENERAL,
|
subcategoryId: SubcategoryId.GENERAL,
|
||||||
|
maxFiles: -1,
|
||||||
|
endpoints: ["auto-redact"],
|
||||||
|
operationConfig: redactOperationConfig,
|
||||||
|
settingsComponent: RedactSingleStepSettings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
142
frontend/src/hooks/tools/redact/useRedactOperation.test.ts
Normal file
142
frontend/src/hooks/tools/redact/useRedactOperation.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
51
frontend/src/hooks/tools/redact/useRedactOperation.ts
Normal file
51
frontend/src/hooks/tools/redact/useRedactOperation.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||||
|
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
|
import { RedactParameters, defaultParameters } from './useRedactParameters';
|
||||||
|
|
||||||
|
// Static configuration that can be used by both the hook and automation executor
|
||||||
|
export const buildRedactFormData = (parameters: RedactParameters, file: File): FormData => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("fileInput", file);
|
||||||
|
|
||||||
|
if (parameters.mode === 'automatic') {
|
||||||
|
// Convert array to newline-separated string as expected by backend
|
||||||
|
formData.append("listOfText", parameters.wordsToRedact.join('\n'));
|
||||||
|
formData.append("useRegex", parameters.useRegex.toString());
|
||||||
|
formData.append("wholeWordSearch", parameters.wholeWordSearch.toString());
|
||||||
|
formData.append("redactColor", parameters.redactColor.replace('#', ''));
|
||||||
|
formData.append("customPadding", parameters.customPadding.toString());
|
||||||
|
formData.append("convertPDFToImage", parameters.convertPDFToImage.toString());
|
||||||
|
} else {
|
||||||
|
// Manual mode parameters would go here when implemented
|
||||||
|
throw new Error('Manual redaction not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static configuration object
|
||||||
|
export const redactOperationConfig = {
|
||||||
|
toolType: ToolType.singleFile,
|
||||||
|
buildFormData: buildRedactFormData,
|
||||||
|
operationType: 'redact',
|
||||||
|
endpoint: (parameters: RedactParameters) => {
|
||||||
|
if (parameters.mode === 'automatic') {
|
||||||
|
return '/api/v1/security/auto-redact';
|
||||||
|
} else {
|
||||||
|
// Manual redaction endpoint would go here when implemented
|
||||||
|
throw new Error('Manual redaction not yet implemented');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filePrefix: 'redacted_',
|
||||||
|
defaultParameters,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const useRedactOperation = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useToolOperation<RedactParameters>({
|
||||||
|
...redactOperationConfig,
|
||||||
|
getErrorMessage: createStandardErrorHandler(t('redact.error.failed', 'An error occurred while redacting the PDF.'))
|
||||||
|
});
|
||||||
|
};
|
134
frontend/src/hooks/tools/redact/useRedactParameters.test.ts
Normal file
134
frontend/src/hooks/tools/redact/useRedactParameters.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
48
frontend/src/hooks/tools/redact/useRedactParameters.ts
Normal file
48
frontend/src/hooks/tools/redact/useRedactParameters.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { BaseParameters } from '../../../types/parameters';
|
||||||
|
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||||
|
|
||||||
|
export type RedactMode = 'automatic' | 'manual';
|
||||||
|
|
||||||
|
export interface RedactParameters extends BaseParameters {
|
||||||
|
mode: RedactMode;
|
||||||
|
|
||||||
|
// Automatic redaction parameters
|
||||||
|
wordsToRedact: string[];
|
||||||
|
useRegex: boolean;
|
||||||
|
wholeWordSearch: boolean;
|
||||||
|
redactColor: string;
|
||||||
|
customPadding: number;
|
||||||
|
convertPDFToImage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultParameters: RedactParameters = {
|
||||||
|
mode: 'automatic',
|
||||||
|
wordsToRedact: [],
|
||||||
|
useRegex: false,
|
||||||
|
wholeWordSearch: false,
|
||||||
|
redactColor: '#000000',
|
||||||
|
customPadding: 0.1,
|
||||||
|
convertPDFToImage: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RedactParametersHook = BaseParametersHook<RedactParameters>;
|
||||||
|
|
||||||
|
export const useRedactParameters = (): RedactParametersHook => {
|
||||||
|
return useBaseParameters({
|
||||||
|
defaultParameters,
|
||||||
|
endpointName: (params) => {
|
||||||
|
if (params.mode === 'automatic') {
|
||||||
|
return '/api/v1/security/auto-redact';
|
||||||
|
}
|
||||||
|
// Manual redaction endpoint would go here when implemented
|
||||||
|
throw new Error('Manual redaction not yet implemented');
|
||||||
|
},
|
||||||
|
validateFn: (params) => {
|
||||||
|
if (params.mode === 'automatic') {
|
||||||
|
return params.wordsToRedact.length > 0 && params.wordsToRedact.some(word => word.trim().length > 0);
|
||||||
|
}
|
||||||
|
// Manual mode validation would go here when implemented
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
120
frontend/src/tools/Redact.tsx
Normal file
120
frontend/src/tools/Redact.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
import RedactModeSelector from "../components/tools/redact/RedactModeSelector";
|
||||||
|
import { useRedactParameters } from "../hooks/tools/redact/useRedactParameters";
|
||||||
|
import { useRedactOperation } from "../hooks/tools/redact/useRedactOperation";
|
||||||
|
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||||
|
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||||
|
import { useRedactModeTips, useRedactWordsTips, useRedactAdvancedTips } from "../components/tooltips/useRedactTips";
|
||||||
|
import RedactAdvancedSettings from "../components/tools/redact/RedactAdvancedSettings";
|
||||||
|
import WordsToRedactInput from "../components/tools/redact/WordsToRedactInput";
|
||||||
|
|
||||||
|
const Redact = (props: BaseToolProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// State for managing step collapse status
|
||||||
|
const [methodCollapsed, setMethodCollapsed] = useState(false);
|
||||||
|
const [wordsCollapsed, setWordsCollapsed] = useState(false);
|
||||||
|
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
|
||||||
|
|
||||||
|
const base = useBaseTool(
|
||||||
|
'redact',
|
||||||
|
useRedactParameters,
|
||||||
|
useRedactOperation,
|
||||||
|
props
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tooltips for each step
|
||||||
|
const modeTips = useRedactModeTips();
|
||||||
|
const wordsTips = useRedactWordsTips();
|
||||||
|
const advancedTips = useRedactAdvancedTips();
|
||||||
|
|
||||||
|
const isExecuteDisabled = () => {
|
||||||
|
if (base.params.parameters.mode === 'manual') {
|
||||||
|
return true; // Manual mode not implemented yet
|
||||||
|
}
|
||||||
|
return !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute actual collapsed state based on results and user state
|
||||||
|
const getActualCollapsedState = (userCollapsed: boolean) => {
|
||||||
|
return (!base.hasFiles || base.hasResults) ? true : userCollapsed; // Force collapse when results are shown
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build conditional steps based on redaction mode
|
||||||
|
const buildSteps = () => {
|
||||||
|
const steps = [
|
||||||
|
// Method selection step (always present)
|
||||||
|
{
|
||||||
|
title: t("redact.modeSelector.title", "Redaction Method"),
|
||||||
|
isCollapsed: getActualCollapsedState(methodCollapsed),
|
||||||
|
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setMethodCollapsed(!methodCollapsed),
|
||||||
|
tooltip: modeTips,
|
||||||
|
content: (
|
||||||
|
<RedactModeSelector
|
||||||
|
mode={base.params.parameters.mode}
|
||||||
|
onModeChange={(mode) => base.params.updateParameter('mode', mode)}
|
||||||
|
disabled={base.endpointLoading}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add mode-specific steps
|
||||||
|
if (base.params.parameters.mode === 'automatic') {
|
||||||
|
steps.push(
|
||||||
|
{
|
||||||
|
title: t("redact.auto.settings.title", "Redaction Settings"),
|
||||||
|
isCollapsed: getActualCollapsedState(wordsCollapsed),
|
||||||
|
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setWordsCollapsed(!wordsCollapsed),
|
||||||
|
tooltip: wordsTips,
|
||||||
|
content: <WordsToRedactInput
|
||||||
|
wordsToRedact={base.params.parameters.wordsToRedact}
|
||||||
|
onWordsChange={(words) => base.params.updateParameter('wordsToRedact', words)}
|
||||||
|
disabled={base.endpointLoading}
|
||||||
|
/>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("redact.auto.settings.advancedTitle", "Advanced Settings"),
|
||||||
|
isCollapsed: getActualCollapsedState(advancedCollapsed),
|
||||||
|
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setAdvancedCollapsed(!advancedCollapsed),
|
||||||
|
tooltip: advancedTips,
|
||||||
|
content: <RedactAdvancedSettings
|
||||||
|
parameters={base.params.parameters}
|
||||||
|
onParameterChange={base.params.updateParameter}
|
||||||
|
disabled={base.endpointLoading}
|
||||||
|
/>,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (base.params.parameters.mode === 'manual') {
|
||||||
|
// Manual mode steps would go here when implemented
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
};
|
||||||
|
|
||||||
|
return createToolFlow({
|
||||||
|
files: {
|
||||||
|
selectedFiles: base.selectedFiles,
|
||||||
|
isCollapsed: base.hasResults,
|
||||||
|
},
|
||||||
|
steps: buildSteps(),
|
||||||
|
executeButton: {
|
||||||
|
text: t("redact.submit", "Redact"),
|
||||||
|
isVisible: !base.hasResults,
|
||||||
|
loadingText: t("loading"),
|
||||||
|
onClick: base.handleExecute,
|
||||||
|
disabled: isExecuteDisabled(),
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
isVisible: base.hasResults,
|
||||||
|
operation: base.operation,
|
||||||
|
title: t("redact.title", "Redaction Results"),
|
||||||
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Redact as ToolComponent;
|
Loading…
x
Reference in New Issue
Block a user