feat(form-fill): add CSV and XLSX extraction for form fields, improve file ID handling (#5776)

This commit is contained in:
Balázs Szücs
2026-02-23 21:17:58 +01:00
committed by GitHub
parent 340224b40b
commit 549f796e47
9 changed files with 321 additions and 64 deletions

View File

@@ -16,7 +16,7 @@ import { useRedaction } from '@app/contexts/RedactionContext';
import type { RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { isStirlingFile } from '@app/types/fileContext';
import { isStirlingFile, getFormFillFileId } from '@app/types/fileContext';
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
import { RulerOverlay, type PageMeasureScales, type PageScaleInfo, type ViewportScale } from '@app/components/viewer/RulerOverlay';
@@ -788,19 +788,7 @@ const EmbedPdfViewerContent = ({
// Generate a unique identifier for the current file to detect file changes
const currentFileId = React.useMemo(() => {
if (!currentFile) return null;
if (isStirlingFile(currentFile)) {
return `stirling-${currentFile.fileId}`;
}
// File is also a Blob, but has more specific properties
if (currentFile instanceof File) {
return `file-${currentFile.name}-${currentFile.size}-${currentFile.lastModified}`;
}
// Fallback for any other object (shouldn't happen in practice)
return `unknown-${(currentFile as any).size || 0}`;
return getFormFillFileId(currentFile);
}, [currentFile]);
useEffect(() => {

View File

@@ -79,15 +79,33 @@
}
.actionBar {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.primaryActions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.actionBar > *:first-child {
.primaryActions > *:first-child {
flex: 1;
}
.secondaryActions {
display: flex;
gap: 0.375rem;
align-items: center;
}
.secondaryActions > button {
flex: 1;
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.fieldList {
flex: 1;
overflow: hidden;

View File

@@ -26,7 +26,7 @@ import { useNavigation } from '@app/contexts/NavigationContext';
import { useViewer } from '@app/contexts/ViewerContext';
import { useFileState } from '@app/contexts/FileContext';
import { Skeleton } from '@mantine/core';
import { isStirlingFile } from '@app/types/fileContext';
import { isStirlingFile, getFormFillFileId } from '@app/types/fileContext';
import type { BaseToolProps } from '@app/types/tool';
import type { FormField } from '@app/tools/formFill/types';
import { FieldInput } from '@app/tools/formFill/FieldInput';
@@ -40,6 +40,7 @@ import FileCopyIcon from '@mui/icons-material/FileCopy';
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
import DescriptionIcon from '@mui/icons-material/Description';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { extractFormFieldsCsv, extractFormFieldsXlsx } from '@app/tools/formFill/formApi';
import styles from '@app/tools/formFill/FormFill.module.css';
// ---------------------------------------------------------------------------
@@ -149,6 +150,44 @@ const FormFill = (_props: BaseToolProps) => {
return activeFiles[0];
}, [activeFiles, selectedFileIds]);
const handleExtractCsv = useCallback(async () => {
if (!currentFile) return;
setExtracting(true);
try {
const blob = await extractFormFieldsCsv(currentFile, allValues);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `form-data-${new Date().getTime()}.csv`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 250);
} catch (err) {
console.error('[FormFill] CSV extraction failed:', err);
setSaveError('Failed to extract CSV');
} finally {
setExtracting(false);
}
}, [currentFile, allValues]);
const handleExtractXlsx = useCallback(async () => {
if (!currentFile) return;
setExtracting(true);
try {
const blob = await extractFormFieldsXlsx(currentFile, allValues);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `form-data-${new Date().getTime()}.xlsx`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 250);
} catch (err) {
console.error('[FormFill] XLSX extraction failed:', err);
setSaveError('Failed to extract XLSX');
} finally {
setExtracting(false);
}
}, [currentFile, allValues]);
const isActive = selectedTool === 'formFill';
useEffect(() => {
@@ -228,10 +267,8 @@ const FormFill = (_props: BaseToolProps) => {
}, [formState.isDirty]);
const handleRefresh = useCallback(() => {
if (currentFile && isStirlingFile(currentFile)) {
fetchFields(currentFile, currentFile.fileId);
} else if (currentFile) {
fetchFields(currentFile);
if (currentFile) {
fetchFields(currentFile, getFormFillFileId(currentFile) ?? undefined);
}
}, [currentFile, fetchFields]);
@@ -406,38 +443,63 @@ const FormFill = (_props: BaseToolProps) => {
{/* Action buttons */}
<div className={styles.actionBar}>
<Button
leftSection={<SaveIcon sx={{ fontSize: 14 }} />}
size="xs"
onClick={handleSave}
loading={saving}
disabled={!formState.isDirty && !flattenChanged}
flex={1}
>
Save
</Button>
<Button
variant="light"
color="blue"
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
loading={extracting}
onClick={handleExtractJson}
size="xs"
>
Extract JSON
</Button>
<Tooltip label="Re-scan fields" withArrow position="bottom">
<ActionIcon
variant="light"
size="md"
onClick={handleRefresh}
aria-label="Re-scan form fields"
<div className={styles.primaryActions}>
<Button
leftSection={<SaveIcon sx={{ fontSize: 14 }} />}
size="xs"
onClick={handleSave}
loading={saving}
disabled={!formState.isDirty && !flattenChanged}
>
<RefreshIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
Save
</Button>
<Tooltip label="Re-scan fields" withArrow position="bottom">
<ActionIcon
variant="light"
size="md"
onClick={handleRefresh}
aria-label="Re-scan form fields"
>
<RefreshIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
</div>
<div className={styles.secondaryActions}>
<Button
variant="light"
color="blue"
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
loading={extracting}
onClick={handleExtractJson}
size="xs"
>
JSON
</Button>
<Button
variant="light"
color="blue"
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
loading={extracting}
onClick={handleExtractCsv}
size="xs"
>
CSV
</Button>
<Button
variant="light"
color="blue"
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
loading={extracting}
onClick={handleExtractXlsx}
size="xs"
>
XLSX
</Button>
</div>
</div>
{/* Error message */}

View File

@@ -44,3 +44,49 @@ export async function fillFormFields(
return response.data;
}
/**
* Extract form fields as CSV.
* Calls POST /api/v1/form/extract-csv
*/
export async function extractFormFieldsCsv(
file: File | Blob,
values?: Record<string, string>
): Promise<Blob> {
const formData = new FormData();
formData.append('file', file);
if (values) {
formData.append(
'data',
new Blob([JSON.stringify(values)], { type: 'application/json' })
);
}
const response = await apiClient.post('/api/v1/form/extract-csv', formData, {
responseType: 'blob',
});
return response.data;
}
/**
* Extract form fields as XLSX.
* Calls POST /api/v1/form/extract-xlsx
*/
export async function extractFormFieldsXlsx(
file: File | Blob,
values?: Record<string, string>
): Promise<Blob> {
const formData = new FormData();
formData.append('file', file);
if (values) {
formData.append(
'data',
new Blob([JSON.stringify(values)], { type: 'application/json' })
);
}
const response = await apiClient.post('/api/v1/form/extract-xlsx', formData, {
responseType: 'blob',
});
return response.data;
}

View File

@@ -65,7 +65,7 @@ export function createFileId(): FileId {
return window.crypto.randomUUID() as FileId;
}
// Fallback for environments without randomUUID
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
@@ -85,9 +85,29 @@ export interface StirlingFile extends File {
}
// Type guard to check if a File object has an embedded fileId
export function isStirlingFile(file: File): file is StirlingFile {
return 'fileId' in file && typeof (file as any).fileId === 'string' &&
'quickKey' in file && typeof (file as any).quickKey === 'string';
export function isStirlingFile(file: File | Blob): file is StirlingFile {
return file instanceof File && 'fileId' in file && typeof (file as any).fileId === 'string' &&
'quickKey' in file && typeof (file as any).quickKey === 'string';
}
/**
* Generate a unique identifier for form fill state tracking.
* This ensures that form widgets/values are correctly isolated between files
* even if they have the same name or are re-scanned.
*/
export function getFormFillFileId(file: File | Blob | null | undefined): string | null {
if (!file) return null;
if (isStirlingFile(file)) {
return `stirling-${file.fileId}`;
}
if (file instanceof File) {
return `file-${file.name}-${file.size}-${file.lastModified}`;
}
// Fallback for Blobs or other objects
return `blob-${(file as any).size || 0}`;
}
// Create a StirlingFile from a regular File object
@@ -141,11 +161,11 @@ export function extractFiles(files: StirlingFile[]): File[] {
// Check if an object is a File or StirlingFile (replaces instanceof File checks)
export function isFileObject(obj: any): obj is File | StirlingFile {
return obj &&
typeof obj.name === 'string' &&
typeof obj.size === 'number' &&
typeof obj.type === 'string' &&
typeof obj.lastModified === 'number' &&
typeof obj.arrayBuffer === 'function';
typeof obj.name === 'string' &&
typeof obj.size === 'number' &&
typeof obj.type === 'string' &&
typeof obj.lastModified === 'number' &&
typeof obj.arrayBuffer === 'function';
}