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:
@@ -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,
|
||||
},
|
||||
|
||||
+81
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user