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 image 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. image image 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 }; };