csv parsing uses csv package

This commit is contained in:
pandeymangg
2026-02-23 16:02:40 +05:30
parent cd280d7a77
commit 8e2f8302e4
20 changed files with 110 additions and 18 deletions

View File

@@ -36,7 +36,7 @@ interface CreateConnectorModalProps {
surveyId?: string;
elementIds?: string[];
fieldMappings?: TFieldMapping[];
}) => void;
}) => Promise<void>;
surveys: TUnifySurvey[];
}
@@ -123,7 +123,7 @@ export const CreateConnectorModal = ({
}
};
const handleCreate = () => {
const handleCreate = async () => {
if (!selectedType || !connectorName.trim()) return;
if (selectedType !== "formbricks") {
@@ -135,7 +135,7 @@ export const CreateConnectorModal = ({
}
}
onCreateConnector({
await onCreateConnector({
name: connectorName.trim(),
type: selectedType,
surveyId: selectedType === "formbricks" ? (selectedSurveyId ?? undefined) : undefined,

View File

@@ -1,5 +1,6 @@
"use client";
import { parse } from "csv-parse/sync";
import {
ArrowUpFromLineIcon,
CloudIcon,
@@ -10,6 +11,7 @@ import {
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
@@ -21,7 +23,7 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { Switch } from "@/modules/ui/components/switch";
import { TFieldMapping, TSourceField } from "../types";
import { MAX_CSV_VALUES, TFieldMapping, TSourceField, ZFeedbackCSVData } from "../types";
import { MappingUI } from "./mapping-ui";
interface CsvConnectorUIProps {
@@ -43,6 +45,7 @@ export function CsvConnectorUI({
const [csvFile, setCsvFile] = useState<File | null>(null);
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
const [showMapping, setShowMapping] = useState(false);
const [csvError, setCsvError] = useState("");
const [s3AutoSync, setS3AutoSync] = useState(false);
const [s3Copied, setS3Copied] = useState(false);
@@ -63,29 +66,57 @@ export function CsvConnectorUI({
};
const processCSVFile = (file: File) => {
setCsvError("");
if (!file.name.endsWith(".csv")) {
setCsvError(t("environments.unify.csv_files_only"));
return;
}
setCsvFile(file);
if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) {
setCsvError(t("environments.unify.csv_files_only"));
return;
}
if (file.size > MAX_CSV_VALUES.FILE_SIZE) {
setCsvError(t("environments.unify.csv_file_too_large"));
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const csv = e.target?.result as string;
const lines = csv.split("\n").slice(0, 6);
const preview = lines.map((line) => line.split(",").map((cell) => cell.trim()));
setCsvPreview(preview);
if (preview.length > 0) {
const headers = preview[0];
try {
const records = parse(csv, { columns: true, skip_empty_lines: true });
const result = ZFeedbackCSVData.safeParse(records);
if (!result.success) {
setCsvError(result.error.errors[0].message);
return;
}
const validRecords = result.data;
const headers = Object.keys(validRecords[0]);
const preview: string[][] = [
headers,
...validRecords.slice(0, 5).map((row) => headers.map((h) => row[h] ?? "")),
];
setCsvFile(file);
setCsvPreview(preview);
const fields: TSourceField[] = headers.map((header) => ({
id: header,
name: header,
type: "string",
sampleValue: preview[1]?.[headers.indexOf(header)] || "",
sampleValue: validRecords[0][header] ?? "",
}));
onSourceFieldsChange(fields);
setShowMapping(true);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to parse CSV";
setCsvError(message);
}
};
reader.readAsText(file);
@@ -126,6 +157,7 @@ export function CsvConnectorUI({
onClick={() => {
setCsvFile(null);
setCsvPreview([]);
setCsvError("");
setShowMapping(false);
onSourceFieldsChange([]);
}}>
@@ -180,6 +212,11 @@ export function CsvConnectorUI({
return (
<div className="space-y-6">
{csvError && (
<Alert variant="error" size="small">
{csvError}
</Alert>
)}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">{t("environments.unify.upload_csv_file")}</h4>
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">

View File

@@ -30,8 +30,8 @@ interface EditConnectorModalProps {
surveyId?: string;
elementIds?: string[];
fieldMappings?: TFieldMapping[];
}) => void;
onDeleteConnector: (connectorId: string) => void;
}) => Promise<void>;
onDeleteConnector: (connectorId: string) => Promise<void>;
surveys: TUnifySurvey[];
}
@@ -140,10 +140,10 @@ export function EditConnectorModal({
setSelectedElementIds([]);
};
const handleUpdate = () => {
const handleUpdate = async () => {
if (!connector || !connectorName.trim()) return;
onUpdateConnector({
await onUpdateConnector({
connectorId: connector.id,
name: connectorName.trim(),
surveyId: connector.type === "formbricks" ? (selectedSurveyId ?? undefined) : undefined,
@@ -153,9 +153,9 @@ export function EditConnectorModal({
handleOpenChange(false);
};
const handleDelete = () => {
const handleDelete = async () => {
if (!connector) return;
onDeleteConnector(connector.id);
await onDeleteConnector(connector.id);
handleOpenChange(false);
};

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import { THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
@@ -170,4 +171,40 @@ export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
export const MAX_CSV_VALUES = {
FILE_SIZE: 2_097_152, // 2MB (2 * 1024 * 1024)
RECORDS: 1_000, // 1,000 records
} as const;
export const ZFeedbackCSVData = z
.array(z.record(z.string(), z.string()))
.min(1, { message: "CSV must contain at least one data row." })
.max(MAX_CSV_VALUES.RECORDS, {
message: `Maximum ${MAX_CSV_VALUES.RECORDS.toLocaleString()} records allowed.`,
})
.superRefine((rows, ctx) => {
const firstRowKeys = Object.keys(rows[0]).sort().join(",");
for (let i = 1; i < rows.length; i++) {
const rowKeys = Object.keys(rows[i]).sort().join(",");
if (rowKeys !== firstRowKeys) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Row ${(i + 1).toString()} has inconsistent columns. All rows must have the same headers.`,
});
return;
}
}
const emptyHeaders = Object.keys(rows[0]).filter((k) => k.trim() === "");
if (emptyHeaders.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "CSV contains empty column headers. All columns must have a name.",
});
}
});
export type TFeedbackCSVData = z.infer<typeof ZFeedbackCSVData>;
export type TCreateConnectorStep = "selectType" | "mapping";

View File

@@ -1950,6 +1950,7 @@ checksums:
environments/unify/copy: 627c00d2c850b9b45f7341a6ac01b6bb
environments/unify/create_mapping: cbe8c951e7819f574ca7d793920b2b60
environments/unify/csv_columns: 280c5ba0b19ae5fa6d42f4d05a1771cb
environments/unify/csv_file_too_large: e94c7a7c26096aae9eddb2db30c5cfc1
environments/unify/csv_files_only: 920612b537521b14c154f1ac9843e947
environments/unify/csv_import: ef4060fef24c4fec064987b9d2a9fa4b
environments/unify/delete_source: f1efd5e1c403192a063b761ddfeaf34a

View File

@@ -60,7 +60,10 @@ const convertValueToHubFields = (
return {};
case "date":
if (typeof value === "string") return { value_date: value };
if (typeof value === "string") {
const date = new Date(value);
if (!Number.isNaN(date.getTime())) return { value_date: date.toISOString() };
}
if (value instanceof Date) return { value_date: value.toISOString() };
return {};

View File

@@ -2058,6 +2058,7 @@
"copy": "Kopieren",
"create_mapping": "Zuordnung erstellen",
"csv_columns": "CSV-Spalten",
"csv_file_too_large": "Die CSV-Datei ist zu groß. Die maximale Größe beträgt 2 MB.",
"csv_files_only": "Nur CSV-Dateien",
"csv_import": "CSV-Import",
"delete_source": "Quelle löschen",

View File

@@ -2058,6 +2058,7 @@
"copy": "Copy",
"create_mapping": "Create mapping",
"csv_columns": "CSV Columns",
"csv_file_too_large": "CSV file is too large. Maximum size is 2MB.",
"csv_files_only": "CSV files only",
"csv_import": "CSV Import",
"delete_source": "Delete source",

View File

@@ -2058,6 +2058,7 @@
"copy": "Copiar",
"create_mapping": "Crear asignación",
"csv_columns": "Columnas CSV",
"csv_file_too_large": "El archivo CSV es demasiado grande. El tamaño máximo es de 2 MB.",
"csv_files_only": "Solo archivos CSV",
"csv_import": "Importación CSV",
"delete_source": "Eliminar fuente",

View File

@@ -2058,6 +2058,7 @@
"copy": "Copier",
"create_mapping": "Créer un mappage",
"csv_columns": "Colonnes CSV",
"csv_file_too_large": "Le fichier CSV est trop volumineux. La taille maximale est de 2 Mo.",
"csv_files_only": "Fichiers CSV uniquement",
"csv_import": "Importation CSV",
"delete_source": "Supprimer la source",

View File

@@ -2058,6 +2058,7 @@
"copy": "Másolás",
"create_mapping": "Leképezés létrehozása",
"csv_columns": "CSV oszlopok",
"csv_file_too_large": "A CSV fájl túl nagy. A maximális méret 2 MB.",
"csv_files_only": "Csak CSV fájlok",
"csv_import": "CSV importálás",
"delete_source": "Forrás törlése",

View File

@@ -2058,6 +2058,7 @@
"copy": "コピー",
"create_mapping": "マッピングを作成",
"csv_columns": "CSV列",
"csv_file_too_large": "CSVファイルが大きすぎます。最大サイズは2MBです。",
"csv_files_only": "CSVファイルのみ",
"csv_import": "CSVインポート",
"delete_source": "ソースを削除",

View File

@@ -2058,6 +2058,7 @@
"copy": "Kopiëren",
"create_mapping": "Koppeling aanmaken",
"csv_columns": "CSV kolommen",
"csv_file_too_large": "CSV-bestand is te groot. Maximale grootte is 2MB.",
"csv_files_only": "Alleen CSV bestanden",
"csv_import": "CSV import",
"delete_source": "Bron verwijderen",

View File

@@ -2058,6 +2058,7 @@
"copy": "Copiar",
"create_mapping": "Criar mapeamento",
"csv_columns": "Colunas CSV",
"csv_file_too_large": "O arquivo CSV é muito grande. O tamanho máximo é 2MB.",
"csv_files_only": "Apenas arquivos CSV",
"csv_import": "Importação CSV",
"delete_source": "Excluir fonte",

View File

@@ -2058,6 +2058,7 @@
"copy": "Copiar",
"create_mapping": "Criar mapeamento",
"csv_columns": "Colunas CSV",
"csv_file_too_large": "O ficheiro CSV é demasiado grande. O tamanho máximo é 2MB.",
"csv_files_only": "Apenas ficheiros CSV",
"csv_import": "Importação CSV",
"delete_source": "Eliminar fonte",

View File

@@ -2058,6 +2058,7 @@
"copy": "Copiază",
"create_mapping": "Creează mapare",
"csv_columns": "Coloane CSV",
"csv_file_too_large": "Fișierul CSV este prea mare. Dimensiunea maximă este de 2 MB.",
"csv_files_only": "Doar fișiere CSV",
"csv_import": "Import CSV",
"delete_source": "Șterge sursa",

View File

@@ -2058,6 +2058,7 @@
"copy": "Копировать",
"create_mapping": "Создать сопоставление",
"csv_columns": "Столбцы CSV",
"csv_file_too_large": "Файл CSV слишком большой. Максимальный размер — 2 МБ.",
"csv_files_only": "Только файлы CSV",
"csv_import": "Импорт CSV",
"delete_source": "Удалить источник",

View File

@@ -2058,6 +2058,7 @@
"copy": "Kopiera",
"create_mapping": "Skapa mappning",
"csv_columns": "CSV-kolumner",
"csv_file_too_large": "CSV-filen är för stor. Maxstorlek är 2 MB.",
"csv_files_only": "Endast CSV-filer",
"csv_import": "CSV-import",
"delete_source": "Ta bort källa",

View File

@@ -2058,6 +2058,7 @@
"copy": "复制",
"create_mapping": "创建映射",
"csv_columns": "CSV 列",
"csv_file_too_large": "CSV 文件过大,最大支持 2MB。",
"csv_files_only": "仅限 CSV 文件",
"csv_import": "CSV 导入",
"delete_source": "删除来源",

View File

@@ -2058,6 +2058,7 @@
"copy": "複製",
"create_mapping": "建立對應關係",
"csv_columns": "CSV 欄位",
"csv_file_too_large": "CSV 檔案過大,最大限制為 2MB。",
"csv_files_only": "僅限 CSV 檔案",
"csv_import": "CSV 匯入",
"delete_source": "刪除來源",