From 7074d9ce1bdb28f335ba5253c6f4fc229d807c24 Mon Sep 17 00:00:00 2001
From: BugIsGod <87571967+bugisthegod@users.noreply.github.com>
Date: Fri, 6 Feb 2026 13:35:47 +0000
Subject: [PATCH] Fix CSV preview duplicate key warning (#10920) (#17754)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fix issue #10920
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.
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>
---
.../files/components/DocumentViewer.tsx | 88 +++++++++++++++----
.../utils/__tests__/fetchCsvPreview.test.ts | 81 +++++++++++++++++
.../activities/files/utils/fetchCsvPreview.ts | 21 ++---
3 files changed, 163 insertions(+), 27 deletions(-)
create mode 100644 packages/twenty-front/src/modules/activities/files/utils/__tests__/fetchCsvPreview.test.ts
diff --git a/packages/twenty-front/src/modules/activities/files/components/DocumentViewer.tsx b/packages/twenty-front/src/modules/activities/files/components/DocumentViewer.tsx
index 53be3e97d6..3eb9f8c4bf 100644
--- a/packages/twenty-front/src/modules/activities/files/components/DocumentViewer.tsx
+++ b/packages/twenty-front/src/modules/activities/files/components/DocumentViewer.tsx
@@ -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(undefined);
+ const [csvPreview, setCsvPreview] = useState(
+ 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 (
+
+ Loading csv ...
+
+ );
+ }
return (
-
- Loading csv ...
+
+
+
+
+ {csvPreview.headers.map((header, columnIndex) => (
+ | {header} |
+ ))}
+
+
+
+ {csvPreview.rows.map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+ | {cell} |
+ ))}
+
+ ))}
+
+
);
+ }
if (isMsOfficeFile && isPrivateUrl(documentUrl)) {
return (
@@ -251,12 +310,7 @@ export const DocumentViewer = ({
{
+ 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);
+ });
+});
diff --git a/packages/twenty-front/src/modules/activities/files/utils/fetchCsvPreview.ts b/packages/twenty-front/src/modules/activities/files/utils/fetchCsvPreview.ts
index e07ef73698..3ad7412e30 100644
--- a/packages/twenty-front/src/modules/activities/files/utils/fetchCsvPreview.ts
+++ b/packages/twenty-front/src/modules/activities/files/utils/fetchCsvPreview.ts
@@ -2,21 +2,22 @@ import Papa from 'papaparse';
const DEFAULT_PREVIEW_ROWS = 50;
-export const fetchCsvPreview = async (url: string): Promise => {
+export type CsvPreviewData = {
+ headers: string[];
+ rows: string[][];
+};
+
+export const fetchCsvPreview = async (url: string): Promise => {
const response = await fetch(url);
const text = await response.text();
- const result = Papa.parse(text, {
- preview: DEFAULT_PREVIEW_ROWS,
+ const result = Papa.parse(text, {
+ preview: DEFAULT_PREVIEW_ROWS + 1, // +1 for header row
skipEmptyLines: true,
- header: true,
+ header: false,
});
- const data = result.data as Record[];
+ const [headers = [], ...rows] = result.data;
- const csvContent = Papa.unparse(data, {
- header: true,
- });
-
- return csvContent;
+ return { headers, rows };
};