mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-05 00:48:03 -06:00
csv parsing uses csv package
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ソースを削除",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Удалить источник",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "删除来源",
|
||||
|
||||
@@ -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": "刪除來源",
|
||||
|
||||
Reference in New Issue
Block a user