Fix CSV preview duplicate key warning (#10920) (#17754)

Fix issue #10920
<img width="1264" height="630" alt="image"
src="https://github.com/user-attachments/assets/d7a00e4f-85cb-49e1-aa38-4c807f1e1b69"
/>


The root cause is that `@cyntler/react-doc-viewer`'s CSV renderer uses
**cell values as React keys** instead of indices.

### There are two approaches:
1. **patch-package** — Directly fix the key usage in the library's
source code and pull a request to the author of
`@cyntler/react-doc-viewer` to fix it.

<img width="880" height="469" alt="image"
src="https://github.com/user-attachments/assets/819e5b6a-21ae-4333-87f5-3b7f6d7e2738"
/>

<img width="1753" height="568" alt="image"
src="https://github.com/user-attachments/assets/c8a0ee49-9bf4-4002-aad2-65914ac10254"
/>



2. Custom CSV renderer (My current code commit)

### Changes
- `fetchCsvPreview.ts` — Fetches CSV and parses it into headers and rows
- `DocumentViewer.tsx` — Renders CSV with a custom table instead of
passing it to DocViewer

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
BugIsGod
2026-02-06 13:35:47 +00:00
committed by GitHub
parent b3c95744ef
commit 7074d9ce1b
3 changed files with 163 additions and 27 deletions
@@ -1,6 +1,9 @@
import { PREVIEWABLE_EXTENSIONS } from '@/activities/files/const/previewable-extensions.const';
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { fetchCsvPreview } from '@/activities/files/utils/fetchCsvPreview';
import {
type CsvPreviewData,
fetchCsvPreview,
} from '@/activities/files/utils/fetchCsvPreview';
import { getFileType } from '@/activities/files/utils/getFileType';
import DocViewer, { DocViewerRenderers } from '@cyntler/react-doc-viewer';
import '@cyntler/react-doc-viewer/dist/index.css';
@@ -79,6 +82,39 @@ const StyledTitle = styled.div`
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledCsvTable = styled.table`
border-collapse: collapse;
font-size: ${({ theme }) => theme.font.size.sm};
text-align: left;
width: 100%;
th {
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.tertiary};
font-weight: ${({ theme }) => theme.font.weight.medium};
height: ${({ theme }) => theme.spacing(8)};
padding: 0 ${({ theme }) => theme.spacing(2)};
}
td {
color: ${({ theme }) => theme.font.color.secondary};
height: ${({ theme }) => theme.spacing(8)};
max-width: 200px;
overflow: hidden;
padding: 0 ${({ theme }) => theme.spacing(2)};
text-overflow: ellipsis;
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
}
tbody tr:hover {
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
type DocumentViewerProps = {
documentName: string;
documentUrl: string;
@@ -165,7 +201,9 @@ export const DocumentViewer = ({
}: DocumentViewerProps) => {
const { t } = useLingui();
const theme = useTheme();
const [csvPreview, setCsvPreview] = useState<string | undefined>(undefined);
const [csvPreview, setCsvPreview] = useState<CsvPreviewData | undefined>(
undefined,
);
const { extension } = getFileNameAndExtension(documentName);
const fileExtension = isDefined(documentExtension)
@@ -175,15 +213,11 @@ export const DocumentViewer = ({
const isPreviewable = PREVIEWABLE_EXTENSIONS.includes(fileExtension);
const isMsOfficeFile = MS_OFFICE_EXTENSIONS.includes(fileExtension);
const mimeType = PREVIEWABLE_EXTENSIONS.includes(fileExtension)
? MIME_TYPE_MAPPING[fileExtension]
: undefined;
const mimeType = isPreviewable ? MIME_TYPE_MAPPING[fileExtension] : undefined;
useEffect(() => {
if (fileExtension === 'csv') {
fetchCsvPreview(documentUrl).then((content) => {
setCsvPreview(content);
});
fetchCsvPreview(documentUrl).then(setCsvPreview);
}
}, [documentUrl, fileExtension]);
@@ -218,12 +252,37 @@ export const DocumentViewer = ({
);
}
if (fileExtension === 'csv' && !isDefined(csvPreview))
if (fileExtension === 'csv') {
if (!isDefined(csvPreview)) {
return (
<StyledDocumentViewerContainer>
<Trans>Loading csv ... </Trans>
</StyledDocumentViewerContainer>
);
}
return (
<StyledDocumentViewerContainer>
<Trans>Loading csv ... </Trans>
<StyledDocumentViewerContainer style={{ background: 'transparent' }}>
<StyledCsvTable>
<thead>
<tr>
{csvPreview.headers.map((header, columnIndex) => (
<th key={columnIndex}>{header}</th>
))}
</tr>
</thead>
<tbody>
{csvPreview.rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td key={cellIndex}>{cell}</td>
))}
</tr>
))}
</tbody>
</StyledCsvTable>
</StyledDocumentViewerContainer>
);
}
if (isMsOfficeFile && isPrivateUrl(documentUrl)) {
return (
@@ -251,12 +310,7 @@ export const DocumentViewer = ({
<DocViewer
documents={[
{
uri:
fileExtension === 'csv' && isDefined(csvPreview)
? window.URL.createObjectURL(
new Blob([csvPreview], { type: 'text/csv' }),
)
: documentUrl,
uri: documentUrl,
fileName: documentName,
fileType: mimeType,
},
@@ -0,0 +1,81 @@
import { fetchCsvPreview } from '@/activities/files/utils/fetchCsvPreview';
const mockFetch = (text: string) => {
global.fetch = jest.fn(() =>
Promise.resolve({
text: () => Promise.resolve(text),
} as unknown as Response),
);
};
describe('fetchCsvPreview', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should parse headers and rows from CSV', async () => {
mockFetch('Name,Age,City\nAlice,30,Paris\nBob,25,London\n');
const result = await fetchCsvPreview('https://example.com/file.csv');
expect(result.headers).toEqual(['Name', 'Age', 'City']);
expect(result.rows).toEqual([
['Alice', '30', 'Paris'],
['Bob', '25', 'London'],
]);
});
it('should return empty headers and rows for empty CSV', async () => {
mockFetch('');
const result = await fetchCsvPreview('https://example.com/empty.csv');
expect(result.headers).toEqual([]);
expect(result.rows).toEqual([]);
});
it('should return headers with no rows when CSV has only a header line', async () => {
mockFetch('Name,Age,City\n');
const result = await fetchCsvPreview('https://example.com/header-only.csv');
expect(result.headers).toEqual(['Name', 'Age', 'City']);
expect(result.rows).toEqual([]);
});
it('should skip empty lines', async () => {
mockFetch('Name,Age\n\nAlice,30\n\nBob,25\n');
const result = await fetchCsvPreview('https://example.com/file.csv');
expect(result.rows).toEqual([
['Alice', '30'],
['Bob', '25'],
]);
});
it('should handle rows with inconsistent column counts', async () => {
mockFetch('Name,Age,City\nAlice,30\nBob,25,London,Extra\n');
const result = await fetchCsvPreview('https://example.com/malformed.csv');
expect(result.headers).toEqual(['Name', 'Age', 'City']);
expect(result.rows).toEqual([
['Alice', '30'],
['Bob', '25', 'London', 'Extra'],
]);
});
it('should limit rows to the preview amount', async () => {
const lines = ['Name'];
for (let i = 0; i < 100; i++) {
lines.push(`Person${i}`);
}
mockFetch(lines.join('\n'));
const result = await fetchCsvPreview('https://example.com/large.csv');
expect(result.headers).toEqual(['Name']);
expect(result.rows).toHaveLength(50);
});
});
@@ -2,21 +2,22 @@ import Papa from 'papaparse';
const DEFAULT_PREVIEW_ROWS = 50;
export const fetchCsvPreview = async (url: string): Promise<string> => {
export type CsvPreviewData = {
headers: string[];
rows: string[][];
};
export const fetchCsvPreview = async (url: string): Promise<CsvPreviewData> => {
const response = await fetch(url);
const text = await response.text();
const result = Papa.parse(text, {
preview: DEFAULT_PREVIEW_ROWS,
const result = Papa.parse<string[]>(text, {
preview: DEFAULT_PREVIEW_ROWS + 1, // +1 for header row
skipEmptyLines: true,
header: true,
header: false,
});
const data = result.data as Record<string, string>[];
const [headers = [], ...rows] = result.data;
const csvContent = Papa.unparse(data, {
header: true,
});
return csvContent;
return { headers, rows };
};