chore: merge with polish PR

This commit is contained in:
pandeymangg
2026-02-26 16:04:08 +05:30
36 changed files with 980 additions and 788 deletions

View File

@@ -39,8 +39,7 @@ export function ConnectorsSection({
const handleCreateConnector = async (data: {
name: string;
type: TConnectorType;
surveyId?: string;
elementIds?: string[];
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}): Promise<string | undefined> => {
const result = await createConnectorWithMappingsAction({
@@ -50,9 +49,7 @@ export function ConnectorsSection({
type: data.type,
},
formbricksMappings:
data.type === "formbricks" && data.surveyId && data.elementIds?.length
? { surveyId: data.surveyId, elementIds: data.elementIds }
: undefined,
data.type === "formbricks" && data.surveyMappings?.length ? data.surveyMappings : undefined,
fieldMappings:
data.type !== "formbricks" && data.fieldMappings?.length
? data.fieldMappings.map((m) => ({
@@ -77,8 +74,7 @@ export function ConnectorsSection({
connectorId: string;
environmentId: string;
name: string;
surveyId?: string;
elementIds?: string[];
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => {
const result = await updateConnectorWithMappingsAction({
@@ -87,10 +83,7 @@ export function ConnectorsSection({
connectorInput: {
name: data.name,
},
formbricksMappings:
data.surveyId && data.elementIds?.length
? { surveyId: data.surveyId, elementIds: data.elementIds }
: undefined,
formbricksMappings: data.surveyMappings?.length ? data.surveyMappings : undefined,
fieldMappings: data.fieldMappings?.length
? data.fieldMappings.map((m) => ({
sourceFieldId: m.sourceFieldId || "",
@@ -185,7 +178,6 @@ export function ConnectorsSection({
open={editingConnector !== null}
onOpenChange={(open) => !open && setEditingConnector(null)}
onUpdateConnector={handleUpdateConnector}
onDeleteConnector={handleDeleteConnector}
surveys={initialSurveys}
/>
</PageContentWrapper>

View File

@@ -41,8 +41,7 @@ interface CreateConnectorModalProps {
onCreateConnector: (data: {
name: string;
type: TConnectorType;
surveyId?: string;
elementIds?: string[];
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<string | undefined>;
surveys: TUnifySurvey[];
@@ -72,7 +71,7 @@ const getDialogDescription = (
};
const getNextStepButtonLabel = (type: TConnectorType | null, t: (key: string) => string): string => {
if (type === "formbricks") return t("environments.unify.select_elements");
if (type === "formbricks") return t("environments.unify.select_questions");
if (type === "csv") return t("environments.unify.configure_import");
return t("environments.unify.create_mapping");
};
@@ -88,44 +87,64 @@ const getCreateDisabled = (
return !allRequiredMapped;
};
interface HistoricalImportSectionProps {
responseCount: number;
elementCount: number;
totalFeedbackRecords: number;
importHistorical: boolean;
onImportHistoricalChange: (checked: boolean) => void;
interface AggregateImportSectionProps {
surveyEntries: {
surveyId: string;
surveyName: string;
responseCount: number;
elementCount: number;
importHistorical: boolean;
}[];
onImportHistoricalChange: (surveyId: string, checked: boolean) => void;
t: (key: string, options?: Record<string, unknown>) => string;
}
const HistoricalImportSection = ({
responseCount,
elementCount,
totalFeedbackRecords,
importHistorical,
const AggregateImportSection = ({
surveyEntries,
onImportHistoricalChange,
t,
}: HistoricalImportSectionProps) => (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p className="mb-2 text-xs text-amber-800">
{t("environments.unify.existing_responses_info", {
responseCount,
elementCount,
total: totalFeedbackRecords,
})}
</p>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={importHistorical}
onChange={(e) => onImportHistoricalChange(e.target.checked)}
className="h-4 w-4 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
/>
<span className="text-sm font-medium text-amber-900">
{t("environments.unify.import_existing_responses")}
</span>
</label>
</div>
);
}: AggregateImportSectionProps) => {
const totalRecords = surveyEntries.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
const checkedCount = surveyEntries.filter((e) => e.importHistorical).length;
const checkedTotal = surveyEntries
.filter((e) => e.importHistorical)
.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="space-y-2">
{surveyEntries.map((entry) => (
<label key={entry.surveyId} className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={entry.importHistorical}
onChange={(e) => onImportHistoricalChange(entry.surveyId, e.target.checked)}
className="h-4 w-4 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
/>
<span className="text-xs text-amber-800">
{t("environments.unify.survey_import_line", {
surveyName: entry.surveyName,
responseCount: entry.responseCount,
questionCount: entry.elementCount,
total: entry.responseCount * entry.elementCount,
})}
</span>
</label>
))}
</div>
{surveyEntries.length > 1 && (
<p className="mt-3 border-t border-amber-200 pt-2 text-xs font-medium text-amber-900">
{t("environments.unify.total_feedback_records", {
checked: checkedTotal,
total: totalRecords,
surveyCount: checkedCount,
})}
</p>
)}
</div>
);
};
export const CreateConnectorModal = ({
open,
@@ -147,37 +166,37 @@ export const CreateConnectorModal = ({
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
const [csvParsedData, setCsvParsedData] = useState<Record<string, string>[]>([]);
const [enumValidationErrors, setEnumValidationErrors] = useState<TEnumValidationError[]>([]);
const [responseCount, setResponseCount] = useState<number | null>(null);
const [importHistorical, setImportHistorical] = useState(false);
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
const [responseCountBySurvey, setResponseCountBySurvey] = useState<Record<string, number | null>>({});
const [importHistoricalBySurvey, setImportHistoricalBySurvey] = useState<Record<string, boolean>>({});
const [isImporting, setIsImporting] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const fetchResponseCount = useCallback(
async (surveyId: string) => {
setResponseCount(null);
if (responseCountBySurvey[surveyId] !== undefined) return;
try {
const result = await getResponseCountAction({ surveyId, environmentId });
if (result?.data !== undefined) {
setResponseCount(result.data);
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: result.data ?? null }));
}
} catch {
setResponseCount(null);
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: null }));
}
},
[environmentId]
[environmentId, responseCountBySurvey]
);
useEffect(() => {
if (selectedSurveyId && selectedType === "formbricks") {
fetchResponseCount(selectedSurveyId);
} else {
setResponseCount(null);
}
}, [selectedSurveyId, selectedType, fetchResponseCount]);
@@ -190,9 +209,9 @@ export const CreateConnectorModal = ({
setCsvParsedData([]);
setEnumValidationErrors([]);
setSelectedSurveyId(null);
setSelectedElementIds([]);
setResponseCount(null);
setImportHistorical(false);
setElementIdsBySurvey({});
setResponseCountBySurvey({});
setImportHistoricalBySurvey({});
setIsImporting(false);
setIsCreating(false);
};
@@ -217,28 +236,39 @@ export const CreateConnectorModal = ({
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
setImportHistorical(false);
};
const handleElementToggle = (elementId: string) => {
setSelectedElementIds((prev) =>
prev.includes(elementId) ? prev.filter((id) => id !== elementId) : [...prev, elementId]
);
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => {
const current = prev[selectedSurveyId] ?? [];
return {
...prev,
[selectedSurveyId]: current.includes(elementId)
? current.filter((id) => id !== elementId)
: [...current, elementId],
};
});
};
const handleSelectAllElements = (surveyId: string) => {
const survey = surveys.find((s) => s.id === surveyId);
if (survey) {
setSelectedElementIds(
survey.elements
setElementIdsBySurvey((prev) => ({
...prev,
[surveyId]: survey.elements
.filter((e) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(e.type))
.map((e) => e.id)
);
.map((e) => e.id),
}));
}
};
const handleDeselectAllElements = () => {
setSelectedElementIds([]);
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => ({
...prev,
[selectedSurveyId]: [],
}));
};
const handleBack = () => {
@@ -249,21 +279,49 @@ export const CreateConnectorModal = ({
}
};
const handleHistoricalImport = async (connectorId: string, surveyId: string) => {
const getSurveyMappings = () =>
Object.entries(elementIdsBySurvey)
.filter(([, ids]) => ids.length > 0)
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
const handleHistoricalImports = async (connectorId: string) => {
const surveysToImport = Object.entries(importHistoricalBySurvey)
.filter(([surveyId, checked]) => checked && (elementIdsBySurvey[surveyId]?.length ?? 0) > 0)
.map(([surveyId]) => surveyId);
if (surveysToImport.length === 0) return;
setIsImporting(true);
const importResult = await importHistoricalResponsesAction({ connectorId, environmentId, surveyId });
let totalSuccesses = 0;
let totalFailures = 0;
let totalSkipped = 0;
for (const surveyId of surveysToImport) {
const importResult = await importHistoricalResponsesAction({
connectorId,
environmentId,
surveyId,
});
if (importResult?.data) {
totalSuccesses += importResult.data.successes;
totalFailures += importResult.data.failures;
totalSkipped += importResult.data.skipped;
} else {
toast.error(getFormattedErrorMessage(importResult));
}
}
setIsImporting(false);
if (importResult?.data) {
if (totalSuccesses > 0 || totalFailures > 0) {
toast.success(
t("environments.unify.historical_import_complete", {
successes: importResult.data.successes,
failures: importResult.data.failures,
skipped: importResult.data.skipped,
successes: totalSuccesses,
failures: totalFailures,
skipped: totalSkipped,
})
);
} else {
toast.error(getFormattedErrorMessage(importResult));
}
};
@@ -303,16 +361,17 @@ export const CreateConnectorModal = ({
setIsCreating(true);
const surveyMappings = getSurveyMappings();
const connectorId = await onCreateConnector({
name: connectorName.trim(),
type: selectedType,
surveyId: selectedType === "formbricks" ? (selectedSurveyId ?? undefined) : undefined,
elementIds: selectedType === "formbricks" ? selectedElementIds : undefined,
surveyMappings: selectedType === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
fieldMappings: selectedType !== "formbricks" && mappings.length > 0 ? mappings : undefined,
});
if (connectorId && importHistorical && selectedSurveyId && selectedType === "formbricks") {
await handleHistoricalImport(connectorId, selectedSurveyId);
if (connectorId && selectedType === "formbricks") {
await handleHistoricalImports(connectorId);
}
if (connectorId && selectedType === "csv" && csvParsedData.length > 0) {
@@ -329,8 +388,8 @@ export const CreateConnectorModal = ({
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
);
const isFormbricksValid =
selectedType === "formbricks" && selectedSurveyId && selectedElementIds.length > 0;
const hasAnyElementSelections = Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
const isFormbricksValid = selectedType === "formbricks" && hasAnyElementSelections;
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
const handleLoadSourceFields = () => {
@@ -340,11 +399,6 @@ export const CreateConnectorModal = ({
}
};
const totalFeedbackRecords =
responseCount !== null && selectedElementIds.length > 0
? responseCount * selectedElementIds.length
: null;
return (
<>
<Button onClick={() => onOpenChange(true)} size="sm">
@@ -358,9 +412,7 @@ export const CreateConnectorModal = ({
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg bg-white/80">
<div className="flex flex-col items-center gap-3">
<Loader2Icon className="h-8 w-8 animate-spin text-slate-500" />
<p className="text-sm font-medium text-slate-700">
{t("environments.unify.importing_data")}
</p>
<p className="text-sm font-medium text-slate-700">{t("environments.unify.importing_data")}</p>
</div>
</div>
)}
@@ -399,19 +451,30 @@ export const CreateConnectorModal = ({
/>
</div>
{responseCount !== null &&
responseCount > 0 &&
selectedElementIds.length > 0 &&
totalFeedbackRecords !== null && (
<HistoricalImportSection
responseCount={responseCount}
elementCount={selectedElementIds.length}
totalFeedbackRecords={totalFeedbackRecords}
importHistorical={importHistorical}
onImportHistoricalChange={setImportHistorical}
{(() => {
const entries = Object.entries(elementIdsBySurvey)
.filter(([, ids]) => ids.length > 0)
.map(([surveyId, ids]) => ({
surveyId,
surveyName: surveys.find((s) => s.id === surveyId)?.name ?? surveyId,
responseCount: responseCountBySurvey[surveyId] ?? 0,
elementCount: ids.length,
importHistorical: importHistoricalBySurvey[surveyId] ?? false,
}))
.filter((e) => e.responseCount > 0);
if (entries.length === 0) return null;
return (
<AggregateImportSection
surveyEntries={entries}
onImportHistoricalChange={(surveyId, checked) => {
setImportHistoricalBySurvey((prev) => ({ ...prev, [surveyId]: checked }));
}}
t={t}
/>
)}
);
})()}
</div>
)}

View File

@@ -29,15 +29,13 @@ interface EditConnectorModalProps {
connectorId: string;
environmentId: string;
name: string;
surveyId?: string;
elementIds?: string[];
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<void>;
onDeleteConnector: (connectorId: string) => Promise<void>;
surveys: TUnifySurvey[];
}
function getConnectorIcon(type: TConnectorType) {
const getConnectorIcon = (type: TConnectorType) => {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
@@ -46,9 +44,9 @@ function getConnectorIcon(type: TConnectorType) {
default:
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
}
}
};
function getConnectorTypeLabelKey(type: TConnectorType): string {
const getConnectorTypeLabelKey = (type: TConnectorType): string => {
switch (type) {
case "formbricks":
return "environments.unify.formbricks_surveys";
@@ -57,24 +55,35 @@ function getConnectorTypeLabelKey(type: TConnectorType): string {
default:
return type;
}
}
};
export function EditConnectorModal({
const groupMappingsBySurvey = (
mappings: { surveyId: string; elementId: string }[]
): Record<string, string[]> => {
const grouped: Record<string, string[]> = {};
for (const m of mappings) {
if (!grouped[m.surveyId]) grouped[m.surveyId] = [];
grouped[m.surveyId].push(m.elementId);
}
return grouped;
};
export const EditConnectorModal = ({
connector,
open,
onOpenChange,
onUpdateConnector,
onDeleteConnector,
surveys,
}: EditConnectorModalProps) {
}: EditConnectorModalProps) => {
const { t } = useTranslation();
const [connectorName, setConnectorName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
useEffect(() => {
if (connector) {
@@ -83,7 +92,7 @@ export function EditConnectorModal({
if (connector.type === "formbricks") {
const fbMappings = connector.formbricksMappings;
setSelectedSurveyId(fbMappings.length > 0 ? fbMappings[0].surveyId : null);
setSelectedElementIds(fbMappings.map((m) => m.elementId));
setElementIdsBySurvey(groupMappingsBySurvey(fbMappings));
setSourceFields([]);
setMappings([]);
} else if (connector.type === "csv") {
@@ -103,12 +112,12 @@ export function EditConnectorModal({
}))
);
setSelectedSurveyId(null);
setSelectedElementIds([]);
setElementIdsBySurvey({});
} else {
setSourceFields([]);
setMappings([]);
setSelectedSurveyId(null);
setSelectedElementIds([]);
setElementIdsBySurvey({});
}
}
}, [connector]);
@@ -117,9 +126,8 @@ export function EditConnectorModal({
setConnectorName("");
setMappings([]);
setSourceFields([]);
setShowDeleteConfirm(false);
setSelectedSurveyId(null);
setSelectedElementIds([]);
setElementIdsBySurvey({});
};
const handleOpenChange = (newOpen: boolean) => {
@@ -134,42 +142,54 @@ export function EditConnectorModal({
};
const handleElementToggle = (elementId: string) => {
setSelectedElementIds((prev) =>
prev.includes(elementId) ? prev.filter((id) => id !== elementId) : [...prev, elementId]
);
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => {
const current = prev[selectedSurveyId] ?? [];
return {
...prev,
[selectedSurveyId]: current.includes(elementId)
? current.filter((id) => id !== elementId)
: [...current, elementId],
};
});
};
const handleSelectAllElements = (surveyId: string) => {
const survey = surveys.find((s) => s.id === surveyId);
if (survey) {
setSelectedElementIds(survey.elements.map((e) => e.id));
setElementIdsBySurvey((prev) => ({
...prev,
[surveyId]: survey.elements.map((e) => e.id),
}));
}
};
const handleDeselectAllElements = () => {
setSelectedElementIds([]);
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => ({
...prev,
[selectedSurveyId]: [],
}));
};
const handleUpdate = async () => {
if (!connector || !connectorName.trim()) return;
const surveyMappings = Object.entries(elementIdsBySurvey)
.filter(([, ids]) => ids.length > 0)
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
await onUpdateConnector({
connectorId: connector.id,
environmentId: connector.environmentId,
name: connectorName.trim(),
surveyId: connector.type === "formbricks" ? (selectedSurveyId ?? undefined) : undefined,
elementIds: connector.type === "formbricks" ? selectedElementIds : undefined,
surveyMappings:
connector.type === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
fieldMappings: connector.type !== "formbricks" && mappings.length > 0 ? mappings : undefined,
});
handleOpenChange(false);
};
const handleDelete = async () => {
if (!connector) return;
await onDeleteConnector(connector.id);
handleOpenChange(false);
};
if (!connector) return null;
return (
@@ -233,29 +253,13 @@ export function EditConnectorModal({
)}
</div>
<DialogFooter className="flex justify-between">
<div>
{showDeleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-sm text-red-600">{t("environments.unify.are_you_sure")}</span>
<Button variant="destructive" size="sm" onClick={handleDelete}>
{t("environments.unify.yes_delete")}
</Button>
<Button variant="outline" size="sm" onClick={() => setShowDeleteConfirm(false)}>
{t("common.cancel")}
</Button>
</div>
) : (
<Button variant="outline" onClick={() => setShowDeleteConfirm(true)}>
{t("environments.unify.delete_source")}
</Button>
)}
</div>
<DialogFooter>
<Button
onClick={handleUpdate}
disabled={
!connectorName.trim() ||
(connector.type === "formbricks" && (!selectedSurveyId || selectedElementIds.length === 0))
(connector.type === "formbricks" &&
!Object.values(elementIdsBySurvey).some((ids) => ids.length > 0))
}>
{t("environments.unify.save_changes")}
</Button>
@@ -263,4 +267,4 @@ export function EditConnectorModal({
</DialogContent>
</Dialog>
);
}
};

View File

@@ -1,18 +1,9 @@
"use client";
import {
CheckCircle2Icon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleIcon,
FileTextIcon,
MessageSquareTextIcon,
StarIcon,
} from "lucide-react";
import { useState } from "react";
import { CheckIcon, ChevronRightIcon, FileTextIcon, MessageSquareTextIcon, StarIcon } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
import { Badge } from "@/modules/ui/components/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
@@ -28,7 +19,7 @@ interface FormbricksSurveySelectorProps {
onDeselectAllElements: () => void;
}
function getElementIcon(type: string) {
const getElementIcon = (type: TSurveyElementTypeEnum) => {
switch (type) {
case "openText":
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
@@ -38,13 +29,13 @@ function getElementIcon(type: string) {
default:
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
}
}
const isUnsupportedType = (type: string): boolean => {
return (UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(type);
};
export function FormbricksSurveySelector({
const isUnsupportedType = (type: TSurveyElementTypeEnum): boolean => {
return UNSUPPORTED_CONNECTOR_ELEMENT_TYPES.includes(type);
};
export const FormbricksSurveySelector = ({
surveys,
selectedSurveyId,
selectedElementIds,
@@ -52,9 +43,8 @@ export function FormbricksSurveySelector({
onElementToggle,
onSelectAllElements,
onDeselectAllElements,
}: FormbricksSurveySelectorProps) {
}: FormbricksSurveySelectorProps) => {
const { t } = useTranslation();
const [expandedSurveyId, setExpandedSurveyId] = useState<string | null>(null);
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
const supportedElements = selectedSurvey?.elements.filter((e) => !isUnsupportedType(e.type)) ?? [];
@@ -62,12 +52,8 @@ export function FormbricksSurveySelector({
supportedElements.length > 0 && supportedElements.every((e) => selectedElementIds.includes(e.id));
const handleSurveyClick = (survey: TUnifySurvey) => {
if (selectedSurveyId === survey.id) {
setExpandedSurveyId(expandedSurveyId === survey.id ? null : survey.id);
} else {
if (selectedSurveyId !== survey.id) {
onSurveySelect(survey.id);
onDeselectAllElements();
setExpandedSurveyId(survey.id);
}
};
@@ -108,7 +94,6 @@ export function FormbricksSurveySelector({
) : (
surveys.map((survey) => {
const isSelected = selectedSurveyId === survey.id;
const isExpanded = expandedSurveyId === survey.id;
return (
<div key={survey.id}>
@@ -120,25 +105,18 @@ export function FormbricksSurveySelector({
? "border-brand-dark bg-slate-50"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-slate-100">
{isExpanded ? (
<ChevronDownIcon className="h-4 w-4 text-slate-600" />
) : (
<ChevronRightIcon className="h-4 w-4 text-slate-600" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-900">{survey.name}</span>
{getStatusBadge(survey.status)}
</div>
<p className="text-xs text-slate-500">
{t("environments.unify.n_supported_elements", {
{t("environments.unify.n_supported_questions", {
count: getSupportedElementCount(survey),
})}
</p>
</div>
{isSelected && <CheckCircle2Icon className="text-brand-dark h-5 w-5" />}
{isSelected && <ChevronRightIcon className="text-brand-dark h-5 w-5 shrink-0" />}
</button>
</div>
);
@@ -150,7 +128,7 @@ export function FormbricksSurveySelector({
{/* Right: Element Selection */}
<div className="flex flex-col gap-3 overflow-hidden">
<div className="flex shrink-0 items-center justify-between">
<h4 className="text-sm font-medium text-slate-700">{t("environments.unify.select_elements")}</h4>
<h4 className="text-sm font-medium text-slate-700">{t("environments.unify.select_questions")}</h4>
{selectedSurvey && supportedElements.length > 0 && (
<button
type="button"
@@ -168,12 +146,12 @@ export function FormbricksSurveySelector({
{!selectedSurvey ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">
{t("environments.unify.select_a_survey_to_see_elements")}
{t("environments.unify.select_a_survey_to_see_questions")}
</p>
</div>
) : selectedSurvey.elements.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("environments.unify.survey_has_no_elements")}</p>
<p className="text-sm text-slate-500">{t("environments.unify.survey_has_no_questions")}</p>
</div>
) : (
<div className="space-y-2 overflow-y-auto pr-1">
@@ -210,17 +188,9 @@ export function FormbricksSurveySelector({
<p className={`text-sm ${unsupported ? "text-slate-400" : "text-slate-900"}`}>
{element.headline}
</p>
<div className="flex items-center gap-2">
<span className={`text-xs ${unsupported ? "text-slate-300" : "text-slate-500"}`}>
{getTSurveyElementTypeEnumName(element.type, t) ?? element.type}
</span>
{element.required && (
<span className="text-xs text-red-500">
<CircleIcon className="inline h-1.5 w-1.5 fill-current" />{" "}
{t("environments.unify.required")}
</span>
)}
</div>
<span className={`text-xs ${unsupported ? "text-slate-300" : "text-slate-500"}`}>
{getTSurveyElementTypeEnumName(element.type, t) ?? element.type}
</span>
</div>
</button>
);
@@ -244,8 +214,8 @@ export function FormbricksSurveySelector({
<Trans
i18nKey={
selectedElementIds.length === 1
? "environments.unify.element_selected"
: "environments.unify.elements_selected"
? "environments.unify.question_selected"
: "environments.unify.questions_selected"
}
values={{ count: selectedElementIds.length }}
components={{ strong: <strong /> }}
@@ -258,4 +228,4 @@ export function FormbricksSurveySelector({
</div>
</div>
);
}
};

View File

@@ -1935,7 +1935,6 @@ checksums:
environments/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
environments/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
environments/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
environments/unify/are_you_sure: 6d5cd13628a7887711fd0c29f1123652
environments/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
environments/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
environments/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
@@ -1961,42 +1960,38 @@ checksums:
environments/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
environments/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
environments/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
environments/unify/delete_source: f1efd5e1c403192a063b761ddfeaf34a
environments/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922
environments/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
environments/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
environments/unify/edit_source_connection: eb42476becc8de3de4ca9626828573f0
environments/unify/element_selected: f194010dff50242e6f123e0a7da2094c
environments/unify/elements_selected: 058a38789415da7fc08b976cdcc1ac66
environments/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
environments/unify/enter_value: 4f068bb59617975c1e546218373122cd
environments/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
environments/unify/existing_responses_info: b2c0ed5b06e3be6ea034733ce4967d23
environments/unify/feedback_date: 4ada116cc8375dd67483108eeb0ddfe8
environments/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
environments/unify/historical_import_complete: f46f98bf4db63bf2993bfb234dc95f62
environments/unify/hub_feedback_record_fields: d8e7b6bb8b7c45d8bd69e5f32193dde4
environments/unify/import_csv_data: e5f873b0e6116c5144677acf38607f2e
environments/unify/import_existing_responses: bac1f2f27e987fd02b127c6546fc45be
environments/unify/import_rows: d2963498a7d2766264c4d67db677e8ff
environments/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984
environments/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20
environments/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6
environments/unify/invalid_values_found: 5011dc9c0294a222033f9910ea919b8a
environments/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
environments/unify/n_supported_elements: 6b4d24c8c1da55825529bb890506137e
environments/unify/n_supported_questions: d75413d386441b5eb137a1ea191e4bd9
environments/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
environments/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
environments/unify/no_surveys_found: 649a2f29b4c34525778d9177605fb326
environments/unify/optional: 396fb9a0472daf401c392bdc3e248943
environments/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
environments/unify/question_selected: b9ff13b6212874258da911867932dc7d
environments/unify/question_type_not_supported: 8d9f7554e3b509dfd5307d8d1fef08d7
environments/unify/questions_selected: 1f13d6fecafa2ce5ea9e6d07078a1d38
environments/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
environments/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
environments/unify/select_a_survey_to_see_elements: e549e92e8e2fda4fc6cfc62661a4b328
environments/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
environments/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
environments/unify/select_all: eedc7cdb02de467c15dc418a066a77f2
environments/unify/select_elements: c336db5308ff54b1dd8b717fad7dbaff
environments/unify/select_questions: 13c79b8c284423eb6140534bf2137e56
environments/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc
environments/unify/select_source_type_prompt: c3fce7d908ee62b9e1b7fab1b17606d7
environments/unify/select_survey: bac52e59c7847417bef6fe7b7096b475
@@ -2012,18 +2007,19 @@ checksums:
environments/unify/source_name: 157675beca12efcd8ec512c5256b1a61
environments/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
environments/unify/sources: ecbbe6e49baa335c5afd7b04b609d006
environments/unify/status_active: 3e1ec025c4a50830bbb9ad57a176630a
environments/unify/status_active: 3de9afebcb9d4ce8ac42e14995f79ffd
environments/unify/status_completed: 0e4bbce9985f25eb673d9a054c8d5334
environments/unify/status_draft: e8a92958ad300aacfe46c2bf6644927e
environments/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
environments/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
environments/unify/survey_has_no_elements: 0379106932976c0a61119a20992d4b18
environments/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d
environments/unify/survey_import_line: 63fa0ea1d7daa3ba333436fbc65f8b19
environments/unify/total_feedback_records: 8962087650b62e4a12b81e7d09317ffa
environments/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
environments/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
environments/unify/updated_at: 8fdb85248e591254973403755dcc3724
environments/unify/upload_csv_data_description: 777ed9a77b45cf399f389a73ac499560
environments/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
environments/unify/yes_delete: 7a260e784409a9112f77d213754cd3e0
environments/workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da
environments/workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985
environments/workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c

View File

@@ -5,6 +5,7 @@ import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import {
TConnectorWithMappings,
THubFieldType,
ZConnectorCreateInput,
ZConnectorFieldMappingCreateInput,
ZConnectorUpdateInput,
@@ -71,10 +72,10 @@ export const deleteConnectorAction = authenticatedActionClient
}
);
const resolveFormbricksMappingsInput = async (
const resolveSurveyMappings = async (
surveyId: string,
elementIds: string[]
): Promise<TMappingsInput> => {
): Promise<{ surveyId: string; elementId: string; hubFieldType: THubFieldType }[]> => {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
@@ -83,7 +84,7 @@ const resolveFormbricksMappingsInput = async (
const elements = getElementsFromBlocks(survey.blocks);
const elementMap = new Map(elements.map((el) => [el.id, el]));
const mappings = elementIds
return elementIds
.filter((elementId) => {
if (elementMap.has(elementId)) return true;
logger.warn({ surveyId, elementId }, "Skipping unknown elementId when building connector mappings");
@@ -97,19 +98,26 @@ const resolveFormbricksMappingsInput = async (
hubFieldType: getHubFieldTypeFromElementType(element.type),
};
});
return { type: "formbricks", mappings };
};
const resolveFormbricksMappingsInput = async (
entries: { surveyId: string; elementIds: string[] }[]
): Promise<TMappingsInput> => {
const allMappings = await Promise.all(
entries.map(({ surveyId, elementIds }) => resolveSurveyMappings(surveyId, elementIds))
);
return { type: "formbricks", mappings: allMappings.flat() };
};
const ZFormbricksSurveyMapping = z.object({
surveyId: ZId,
elementIds: z.array(z.string()).min(1),
});
const ZCreateConnectorWithMappingsAction = z.object({
environmentId: ZId,
connectorInput: ZConnectorCreateInput,
formbricksMappings: z
.object({
surveyId: ZId,
elementIds: z.array(z.string()).min(1),
})
.optional(),
formbricksMappings: z.array(ZFormbricksSurveyMapping).min(1).optional(),
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
});
@@ -144,16 +152,17 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
const { formbricksMappings, fieldMappings } = parsedInput;
if (formbricksMappings) {
const organizationIdFromSurvey = await getOrganizationIdFromSurveyId(formbricksMappings.surveyId);
if (organizationIdFromSurvey !== organizationId) {
throw new AuthorizationError("You are not authorized to access this survey");
}
mappingsInput = await resolveFormbricksMappingsInput(
formbricksMappings.surveyId,
formbricksMappings.elementIds
if (formbricksMappings?.length) {
await Promise.all(
formbricksMappings.map(async ({ surveyId }) => {
const orgId = await getOrganizationIdFromSurveyId(surveyId);
if (orgId !== organizationId) {
throw new AuthorizationError("You are not authorized to access this survey");
}
})
);
mappingsInput = await resolveFormbricksMappingsInput(formbricksMappings);
} else if (fieldMappings?.length) {
mappingsInput = { type: "field", mappings: fieldMappings };
}
@@ -170,12 +179,7 @@ const ZUpdateConnectorWithMappingsAction = z.object({
connectorId: ZId,
environmentId: ZId,
connectorInput: ZConnectorUpdateInput,
formbricksMappings: z
.object({
surveyId: ZId,
elementIds: z.array(z.string()).min(1),
})
.optional(),
formbricksMappings: z.array(ZFormbricksSurveyMapping).min(1).optional(),
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
});
@@ -208,18 +212,17 @@ export const updateConnectorWithMappingsAction = authenticatedActionClient
let mappingsInput: TMappingsInput | undefined;
if (parsedInput.formbricksMappings) {
const organizationIdFromSurvey = await getOrganizationIdFromSurveyId(
parsedInput.formbricksMappings.surveyId
if (parsedInput.formbricksMappings?.length) {
await Promise.all(
parsedInput.formbricksMappings.map(async ({ surveyId }) => {
const orgId = await getOrganizationIdFromSurveyId(surveyId);
if (orgId !== organizationId) {
throw new AuthorizationError("You are not authorized to access this survey");
}
})
);
if (organizationIdFromSurvey !== organizationId) {
throw new AuthorizationError("You are not authorized to access this survey");
}
mappingsInput = await resolveFormbricksMappingsInput(
parsedInput.formbricksMappings.surveyId,
parsedInput.formbricksMappings.elementIds
);
mappingsInput = await resolveFormbricksMappingsInput(parsedInput.formbricksMappings);
} else if (parsedInput.fieldMappings && parsedInput.fieldMappings.length > 0) {
mappingsInput = { type: "field", mappings: parsedInput.fieldMappings };
}

View File

@@ -1,317 +0,0 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { HUB_API_KEY, HUB_API_URL } from "@/lib/constants";
// Hub field types (from OpenAPI spec)
export type THubFieldType =
| "text"
| "categorical"
| "nps"
| "csat"
| "ces"
| "rating"
| "number"
| "boolean"
| "date";
// Create FeedbackRecord input
export interface TCreateFeedbackRecordInput {
collected_at?: string;
source_type: string;
field_id: string;
field_type: THubFieldType;
field_label?: string;
field_group_id?: string;
field_group_label?: string;
tenant_id?: string;
source_id?: string;
source_name?: string;
value_text?: string;
value_number?: number;
value_boolean?: boolean;
value_date?: string;
metadata?: Record<string, unknown>;
language?: string;
user_identifier?: string;
}
// FeedbackRecord data (response from Hub)
export interface TFeedbackRecordData {
id: string;
collected_at: string;
created_at: string;
updated_at: string;
source_type: string;
field_id: string;
field_type: THubFieldType;
field_label?: string;
field_group_id?: string;
field_group_label?: string;
tenant_id?: string;
source_id?: string;
source_name?: string;
value_text?: string;
value_number?: number;
value_boolean?: boolean;
value_date?: string;
metadata?: Record<string, unknown>;
language?: string;
user_identifier?: string;
}
// List FeedbackRecords response
export interface TListFeedbackRecordsResponse {
data: TFeedbackRecordData[];
total: number;
limit: number;
offset: number;
}
// Update FeedbackRecord input
export interface TUpdateFeedbackRecordInput {
value_text?: string;
value_number?: number;
value_boolean?: boolean;
value_date?: string;
metadata?: Record<string, unknown>;
language?: string;
user_identifier?: string;
}
// List FeedbackRecords filters
export interface TListFeedbackRecordsFilters {
tenant_id?: string;
source_type?: string;
source_id?: string;
field_id?: string;
field_group_id?: string;
field_type?: THubFieldType;
user_identifier?: string;
since?: string;
until?: string;
limit?: number;
offset?: number;
}
// Error response from Hub
export interface THubErrorResponse {
type?: string;
title: string;
status: number;
detail: string;
instance?: string;
errors?: Array<{
location?: string;
message?: string;
value?: unknown;
}>;
}
// Hub API Error class
export class HubApiError extends Error {
status: number;
detail: string;
errors?: THubErrorResponse["errors"];
constructor(response: THubErrorResponse) {
super(response.detail || response.title);
this.name = "HubApiError";
this.status = response.status;
this.detail = response.detail;
this.errors = response.errors;
}
}
// Make authenticated request to Hub API
async function hubFetch<T>(
path: string,
options: RequestInit = {}
): Promise<{ data: T | null; error: HubApiError | null }> {
const url = `${HUB_API_URL}${path}`;
const headers: HeadersInit = {
"Content-Type": "application/json",
...(HUB_API_KEY && { Authorization: `Bearer ${HUB_API_KEY}` }),
...options.headers,
};
try {
const response = await fetch(url, {
...options,
headers,
});
// Handle no content response (e.g., DELETE)
if (response.status === 204) {
return { data: null, error: null };
}
const contentType = response.headers.get("content-type");
if (!response.ok) {
// Try to parse error response
if (contentType?.includes("application/problem+json") || contentType?.includes("application/json")) {
const errorBody = (await response.json()) as THubErrorResponse;
return { data: null, error: new HubApiError(errorBody) };
}
// Fallback for non-JSON errors
const errorText = await response.text();
return {
data: null,
error: new HubApiError({
title: "Error",
status: response.status,
detail: errorText || `HTTP ${response.status}`,
}),
};
}
// Parse successful response
if (contentType?.includes("application/json")) {
const data = (await response.json()) as T;
return { data, error: null };
}
return { data: null, error: null };
} catch (error) {
logger.error(
{ url, error: error instanceof Error ? error.message : "Unknown error" },
"Hub API request failed"
);
return {
data: null,
error: new HubApiError({
title: "Network Error",
status: 0,
detail: error instanceof Error ? error.message : "Failed to connect to Hub API",
}),
};
}
}
/**
* Create a new FeedbackRecord in the Hub
*/
export async function createFeedbackRecord(
input: TCreateFeedbackRecordInput
): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> {
return hubFetch<TFeedbackRecordData>("/v1/feedback-records", {
method: "POST",
body: JSON.stringify(input),
});
}
/**
* Create multiple FeedbackRecords in the Hub (batch)
*/
export async function createFeedbackRecordsBatch(
inputs: TCreateFeedbackRecordInput[]
): Promise<{ results: Array<{ data: TFeedbackRecordData | null; error: HubApiError | null }> }> {
// Hub doesn't have a batch endpoint, so we'll do parallel requests
// In production, you might want to implement rate limiting or chunking
const results = await Promise.all(inputs.map((input) => createFeedbackRecord(input)));
return { results };
}
/**
* List FeedbackRecords from the Hub with optional filters
*/
export async function listFeedbackRecords(
filters: TListFeedbackRecordsFilters = {}
): Promise<{ data: TListFeedbackRecordsResponse | null; error: HubApiError | null }> {
const searchParams = new URLSearchParams();
if (filters.tenant_id) searchParams.set("tenant_id", filters.tenant_id);
if (filters.source_type) searchParams.set("source_type", filters.source_type);
if (filters.source_id) searchParams.set("source_id", filters.source_id);
if (filters.field_id) searchParams.set("field_id", filters.field_id);
if (filters.field_group_id) searchParams.set("field_group_id", filters.field_group_id);
if (filters.field_type) searchParams.set("field_type", filters.field_type);
if (filters.user_identifier) searchParams.set("user_identifier", filters.user_identifier);
if (filters.since) searchParams.set("since", filters.since);
if (filters.until) searchParams.set("until", filters.until);
if (filters.limit !== undefined) searchParams.set("limit", String(filters.limit));
if (filters.offset !== undefined) searchParams.set("offset", String(filters.offset));
const queryString = searchParams.toString();
const path = queryString ? `/v1/feedback-records?${queryString}` : "/v1/feedback-records";
return hubFetch<TListFeedbackRecordsResponse>(path, { method: "GET" });
}
/**
* Get a single FeedbackRecord from the Hub by ID
*/
export async function getFeedbackRecord(
id: string
): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> {
return hubFetch<TFeedbackRecordData>(`/v1/feedback-records/${id}`, { method: "GET" });
}
/**
* Update a FeedbackRecord in the Hub
*/
export async function updateFeedbackRecord(
id: string,
input: TUpdateFeedbackRecordInput
): Promise<{ data: TFeedbackRecordData | null; error: HubApiError | null }> {
return hubFetch<TFeedbackRecordData>(`/v1/feedback-records/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
/**
* Delete a FeedbackRecord from the Hub
*/
export async function deleteFeedbackRecord(id: string): Promise<{ error: HubApiError | null }> {
const result = await hubFetch<null>(`/v1/feedback-records/${id}`, { method: "DELETE" });
return { error: result.error };
}
/**
* Bulk delete FeedbackRecords by user identifier (GDPR compliance)
*/
export async function bulkDeleteFeedbackRecordsByUser(
userIdentifier: string,
tenantId?: string
): Promise<{ data: { deleted_count: number; message: string } | null; error: HubApiError | null }> {
const searchParams = new URLSearchParams();
searchParams.set("user_identifier", userIdentifier);
if (tenantId) searchParams.set("tenant_id", tenantId);
return hubFetch<{ deleted_count: number; message: string }>(
`/v1/feedback-records?${searchParams.toString()}`,
{ method: "DELETE" }
);
}
/**
* Check Hub API health
*/
export async function checkHubHealth(): Promise<{ healthy: boolean; error: HubApiError | null }> {
try {
const response = await fetch(`${HUB_API_URL}/health`);
if (response.ok) {
return { healthy: true, error: null };
}
return {
healthy: false,
error: new HubApiError({
title: "Health Check Failed",
status: response.status,
detail: "Hub API health check failed",
}),
};
} catch (error) {
return {
healthy: false,
error: new HubApiError({
title: "Network Error",
status: 0,
detail: error instanceof Error ? error.message : "Failed to connect to Hub API",
}),
};
}
}

View File

@@ -8,7 +8,7 @@ vi.mock("../response/service", () => ({
getResponses: vi.fn(),
}));
vi.mock("./hub-client", () => ({
vi.mock("@/modules/hub", () => ({
createFeedbackRecordsBatch: vi.fn(),
}));
@@ -17,7 +17,7 @@ vi.mock("./transform", () => ({
}));
const { getResponses } = vi.mocked(await import("../response/service"));
const { createFeedbackRecordsBatch } = vi.mocked(await import("./hub-client"));
const { createFeedbackRecordsBatch } = vi.mocked(await import("@/modules/hub"));
const { transformResponseToFeedbackRecords } = vi.mocked(await import("./transform"));
const ENV_ID = "clxxxxxxxxxxxxxxxx001";

View File

@@ -2,8 +2,8 @@ import "server-only";
import { TConnectorFormbricksMapping, TConnectorWithMappings } from "@formbricks/types/connector";
import { InvalidInputError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { createFeedbackRecordsBatch } from "@/modules/hub";
import { getResponses } from "../response/service";
import { createFeedbackRecordsBatch } from "./hub-client";
import { transformResponseToFeedbackRecords } from "./transform";
const IMPORT_BATCH_SIZE = 50;

View File

@@ -0,0 +1,225 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
const mockCreateFeedbackRecordsBatch = vi.fn();
vi.mock("@/modules/hub", () => ({
createFeedbackRecordsBatch: (...args: unknown[]) => mockCreateFeedbackRecordsBatch(...args),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("./service", () => ({
getConnectorsBySurveyId: vi.fn(),
updateConnector: vi.fn(),
}));
vi.mock("./transform", () => ({
transformResponseToFeedbackRecords: vi.fn(),
}));
const { getConnectorsBySurveyId, updateConnector } = await import("./service");
const { transformResponseToFeedbackRecords } = await import("./transform");
const { handleConnectorPipeline } = await import("./pipeline-handler");
const mockResponse = {
id: "resp-1",
createdAt: new Date("2026-02-24T10:00:00.000Z"),
surveyId: "survey-1",
data: { "el-1": "answer" },
} as unknown as TResponse;
const mockSurvey = {
id: "survey-1",
name: "Test Survey",
blocks: [{ id: "block-1", name: "Block", elements: [{ id: "el-1", headline: { default: "Question?" } }] }],
} as unknown as TSurvey;
function createConnector(
overrides: Partial<Pick<TConnectorWithMappings, "id" | "formbricksMappings">> = {}
): TConnectorWithMappings {
return {
id: "conn-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Connector",
type: "formbricks",
status: "active",
environmentId: "env-1",
lastSyncAt: null,
errorMessage: null,
formbricksMappings: [
{
id: "map-1",
createdAt: new Date(),
connectorId: "conn-1",
environmentId: "env-1",
surveyId: "survey-1",
elementId: "el-1",
hubFieldType: "rating",
customFieldLabel: null,
},
],
fieldMappings: [],
...overrides,
} as TConnectorWithMappings;
}
const oneFeedbackRecord = [
{
field_id: "el-1",
field_type: "rating" as const,
source_type: "formbricks",
source_id: "survey-1",
source_name: "Test Survey",
field_label: "Question?",
value_number: 5,
collected_at: "2026-02-24T10:00:00.000Z",
},
];
const noConfigError = {
status: 0,
message: "HUB_API_KEY is not set; Hub integration is disabled.",
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
};
describe("handleConnectorPipeline", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns early when no connectors for survey", async () => {
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([]);
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(transformResponseToFeedbackRecords).not.toHaveBeenCalled();
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
expect(updateConnector).not.toHaveBeenCalled();
});
test("continues when transform returns no feedback records", async () => {
const connector = createConnector();
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([connector]);
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue([]);
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(transformResponseToFeedbackRecords).toHaveBeenCalledWith(
mockResponse,
mockSurvey,
connector.formbricksMappings,
"env-1"
);
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
expect(updateConnector).not.toHaveBeenCalled();
});
test("updates connector to error when Hub returns no-config (HUB_API_KEY not set)", async () => {
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
mockCreateFeedbackRecordsBatch.mockResolvedValue({
results: oneFeedbackRecord.map(() => ({ data: null, error: noConfigError })),
});
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(mockCreateFeedbackRecordsBatch).toHaveBeenCalledWith(oneFeedbackRecord);
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
status: "error",
errorMessage: expect.stringContaining("HUB_API_KEY"),
});
});
test("sends records to Hub and updates connector to active on full success", async () => {
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
mockCreateFeedbackRecordsBatch.mockResolvedValue({
results: [{ data: { id: "hub-1", ...oneFeedbackRecord[0] }, error: null }],
});
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(mockCreateFeedbackRecordsBatch).toHaveBeenCalledWith(oneFeedbackRecord);
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
status: "active",
errorMessage: null,
lastSyncAt: expect.any(Date),
});
});
test("updates connector to error when all Hub creates fail", async () => {
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
mockCreateFeedbackRecordsBatch.mockResolvedValue({
results: [
{ data: null, error: { status: 500, message: "Hub unavailable", detail: "Hub unavailable" } },
],
});
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
status: "error",
errorMessage: expect.stringContaining("Failed to send FeedbackRecords"),
});
});
test("updates connector to active with partial message when some creates fail", async () => {
const twoRecords = [...oneFeedbackRecord, { ...oneFeedbackRecord[0], field_id: "el-2", value_number: 3 }];
const baseMapping = {
createdAt: new Date(),
connectorId: "conn-1",
environmentId: "env-1",
surveyId: "survey-1",
hubFieldType: "rating" as const,
customFieldLabel: null as string | null,
};
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([
createConnector({
formbricksMappings: [
{ ...baseMapping, id: "m1", elementId: "el-1" },
{ ...baseMapping, id: "m2", elementId: "el-2" },
],
}),
]);
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(twoRecords as any);
mockCreateFeedbackRecordsBatch.mockResolvedValue({
results: [
{ data: { id: "hub-1" }, error: null },
{ data: null, error: { status: 429, message: "Rate limited", detail: "Rate limited" } },
],
});
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
status: "active",
errorMessage: "Partial failure: 1/2 records sent",
lastSyncAt: expect.any(Date),
});
});
test("updates connector to error when transform throws", async () => {
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
vi.mocked(transformResponseToFeedbackRecords).mockImplementation(() => {
throw new Error("Transform failed");
});
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
status: "error",
errorMessage: "Transform failed",
});
});
});

View File

@@ -2,7 +2,7 @@ import "server-only";
import { logger } from "@formbricks/logger";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { createFeedbackRecordsBatch } from "./hub-client";
import { createFeedbackRecordsBatch } from "@/modules/hub";
import { getConnectorsBySurveyId, updateConnector } from "./service";
import { transformResponseToFeedbackRecords } from "./transform";

View File

@@ -6,7 +6,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { TCreateFeedbackRecordInput } from "./hub-client";
import type { FeedbackRecordCreateParams } from "@/modules/hub";
const getHeadlineFromElement = (element?: TSurveyElement): string => {
if (!element?.headline) return "Untitled";
@@ -23,7 +23,7 @@ const convertValueToHubFields = (
value: TResponseDataValue,
hubFieldType: THubFieldType
): Partial<
Pick<TCreateFeedbackRecordInput, "value_text" | "value_number" | "value_boolean" | "value_date">
Pick<FeedbackRecordCreateParams, "value_text" | "value_number" | "value_boolean" | "value_date">
> => {
if (value === undefined || value === null) {
return {};
@@ -82,14 +82,14 @@ export function transformResponseToFeedbackRecords(
survey: TSurvey,
mappings: TConnectorFormbricksMapping[],
tenantId?: string
): TCreateFeedbackRecordInput[] {
): FeedbackRecordCreateParams[] {
const responseData = response.data;
if (!responseData) return [];
const surveyMappings = mappings.filter((m) => m.surveyId === survey.id);
const elements = getElementsFromBlocks(survey.blocks);
const elementMap = new Map(elements.map((el) => [el.id, el]));
const feedbackRecords: TCreateFeedbackRecordInput[] = [];
const feedbackRecords: FeedbackRecordCreateParams[] = [];
for (const mapping of surveyMappings) {
const value = extractResponseValue(responseData, mapping.elementId);
@@ -98,7 +98,7 @@ export function transformResponseToFeedbackRecords(
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(elementMap.get(mapping.elementId));
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
const feedbackRecord: TCreateFeedbackRecordInput = {
const feedbackRecord: FeedbackRecordCreateParams = {
collected_at:
response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt),
source_type: "formbricks",

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "Feedback-Quelle hinzufügen",
"add_source": "Quelle hinzufügen",
"allowed_values": "Erlaubte Werte: {values}",
"are_you_sure": "Bist Du sicher?",
"change_file": "Datei ändern",
"click_load_sample_csv": "Klicke auf 'Beispiel-CSV laden', um Spalten zu sehen",
"click_to_upload": "Klicke zum Hochladen",
@@ -2069,42 +2068,38 @@
"csv_max_records": "Maximal {max} Datensätze erlaubt.",
"default_connector_name_csv": "CSV-Import",
"default_connector_name_formbricks": "Formbricks Umfrage-Verbindung",
"delete_source": "Quelle löschen",
"deselect_all": "Alle abwählen",
"drop_a_field_here": "Feld hier ablegen",
"drop_field_or": "Feld ablegen oder",
"edit_source_connection": "Quellverbindung bearbeiten",
"element_selected": "<strong>{count}</strong> Element ausgewählt. Jede Antwort auf dieses Element erstellt einen FeedbackRecord im Hub.",
"elements_selected": "<strong>{count}</strong> Elemente ausgewählt. Jede Antwort auf diese Elemente erstellt einen FeedbackRecord im Hub.",
"enter_name_for_source": "Gib einen Namen für diese Quelle ein",
"enter_value": "Wert eingeben...",
"enum": "Enum",
"existing_responses_info": "{responseCount} vorhandene Antworten × {elementCount} Elemente = {total} Feedback-Datensätze",
"feedback_date": "Feedback-Datum",
"formbricks_surveys": "Formbricks Umfragen",
"historical_import_complete": "Import abgeschlossen: {successes} erfolgreich, {failures} fehlgeschlagen, {skipped} übersprungen (keine Daten)",
"hub_feedback_record_fields": "Hub Feedback-Record-Felder",
"import_csv_data": "CSV-Daten importieren",
"import_existing_responses": "Vorhandene Antworten importieren",
"import_rows": "{count, plural, one {1 Zeile importieren} other {# Zeilen importieren}}",
"importing_data": "Importiere Daten...",
"importing_historical_data": "Importiere historische Daten...",
"importing_data": "Daten werden importiert...",
"invalid_enum_values": "Ungültige Werte in Spalte, die auf {field} gemappt ist",
"invalid_values_found": "Gefunden: {values} (Zeilen: {rows}) {extra}",
"load_sample_csv": "Beispiel-CSV laden",
"n_supported_elements": "{count} unterstützte Elemente",
"n_supported_questions": "{count} unterstützte Fragen",
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
"no_surveys_found": "Keine Umfragen in dieser Umgebung gefunden",
"optional": "Optional",
"or_drag_and_drop": "oder per Drag & Drop",
"question_selected": "<strong>{count}</strong> Frage ausgewählt. Jede Antwort auf diese Frage erstellt einen neuen Feedback-Datensatz.",
"question_type_not_supported": "Dieser Fragetyp wird nicht unterstützt",
"questions_selected": "<strong>{count}</strong> Fragen ausgewählt. Jede Antwort auf diese Fragen erstellt einen neuen Feedback-Datensatz.",
"required": "Erforderlich",
"save_changes": "Änderungen speichern",
"select_a_survey_to_see_elements": "Wähle eine Umfrage aus, um ihre Elemente zu sehen",
"select_a_survey_to_see_questions": "Wähle eine Umfrage aus, um ihre Fragen zu sehen",
"select_a_value": "Wähle einen Wert aus...",
"select_all": "Alles auswählen",
"select_elements": "Elemente auswählen",
"select_questions": "Fragen auswählen",
"select_source_type_description": "Wähle den Typ der Feedbackquelle aus, die du verbinden möchtest.",
"select_source_type_prompt": "Wähle den Typ der Feedbackquelle, die du verbinden möchtest:",
"select_survey": "Umfrage auswählen",
@@ -2120,18 +2115,19 @@
"source_name": "Quellenname",
"source_type_cannot_be_changed": "Quellentyp kann nicht geändert werden",
"sources": "Quellen",
"status_active": "Aktiv",
"status_active": "Im Gange",
"status_completed": "Abgeschlossen",
"status_draft": "Entwurf",
"status_error": "Fehler",
"status_paused": "Pausiert",
"survey_has_no_elements": "Diese Umfrage hat keine Fragen",
"survey_has_no_questions": "Diese Umfrage hat keine Fragen",
"survey_import_line": "{surveyName}: {responseCount} Antworten × {questionCount} Fragen = {total} Feedback-Datensätze",
"total_feedback_records": "Gesamt: {checked} von {total} Feedback-Datensätzen ausgewählt über {surveyCount} Umfragen",
"unify_feedback": "Feedback vereinheitlichen",
"update_mapping_description": "Aktualisiere die Mapping-Konfiguration für diese Quelle.",
"updated_at": "Aktualisiert am",
"upload_csv_data_description": "Lade eine CSV-Datei hoch, um Feedback-Daten in den Hub zu importieren.",
"upload_csv_file": "CSV-Datei hochladen",
"yes_delete": "Ja, löschen"
"upload_csv_file": "CSV-Datei hochladen"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "Add Feedback Source",
"add_source": "Add source",
"allowed_values": "Allowed values: {values}",
"are_you_sure": "Are you sure?",
"change_file": "Change file",
"click_load_sample_csv": "Click 'Load sample CSV' to see columns",
"click_to_upload": "Click to upload",
@@ -2069,42 +2068,38 @@
"csv_max_records": "Maximum {max} records allowed.",
"default_connector_name_csv": "CSV Import",
"default_connector_name_formbricks": "Formbricks Survey Connection",
"delete_source": "Delete source",
"deselect_all": "Deselect all",
"drop_a_field_here": "Drop a field here",
"drop_field_or": "Drop field or",
"edit_source_connection": "Edit Source Connection",
"element_selected": "<strong>{count}</strong> element selected. Each response to these elements will create a FeedbackRecord in the Hub.",
"elements_selected": "<strong>{count}</strong> elements selected. Each response to these elements will create a FeedbackRecord in the Hub.",
"enter_name_for_source": "Enter a name for this source",
"enter_value": "Enter value...",
"enum": "enum",
"existing_responses_info": "{responseCount} existing responses \u00d7 {elementCount} elements = {total} Feedback Records",
"feedback_date": "Feedback date",
"formbricks_surveys": "Formbricks Surveys",
"historical_import_complete": "Import complete: {successes} succeeded, {failures} failed, {skipped} skipped (no data)",
"hub_feedback_record_fields": "Hub Feedback Record Fields",
"import_csv_data": "Import CSV Data",
"import_existing_responses": "Import existing responses",
"import_rows": "Import {count} rows",
"importing_data": "Importing data...",
"importing_historical_data": "Importing historical data...",
"invalid_enum_values": "Invalid values in column mapped to {field}",
"invalid_values_found": "Found: {values} (rows: {rows}) {extra}",
"load_sample_csv": "Load sample CSV",
"n_supported_elements": "{count} supported elements",
"n_supported_questions": "{count} supported questions",
"no_source_fields_loaded": "No source fields loaded yet",
"no_sources_connected": "No sources connected yet. Add a source to get started.",
"no_surveys_found": "No surveys found in this environment",
"optional": "Optional",
"or_drag_and_drop": "or drag and drop",
"question_selected": "<strong>{count}</strong> question selected. Each response to these questions will create a new Feedback Record.",
"question_type_not_supported": "This question type is not supported",
"questions_selected": "<strong>{count}</strong> questions selected. Each response to these questions will create a new Feedback Record.",
"required": "Required",
"save_changes": "Save changes",
"select_a_survey_to_see_elements": "Select a survey to see its elements",
"select_a_survey_to_see_questions": "Select a survey to see its questions",
"select_a_value": "Select a value...",
"select_all": "Select all",
"select_elements": "Select Elements",
"select_questions": "Select questions",
"select_source_type_description": "Select the type of feedback source you want to connect.",
"select_source_type_prompt": "Select the type of feedback source you want to connect:",
"select_survey": "Select Survey",
@@ -2120,18 +2115,19 @@
"source_name": "Source Name",
"source_type_cannot_be_changed": "Source type cannot be changed",
"sources": "Sources",
"status_active": "Active",
"status_active": "In Progress",
"status_completed": "Completed",
"status_draft": "Draft",
"status_error": "Error",
"status_paused": "Paused",
"survey_has_no_elements": "This survey has no question elements",
"survey_has_no_questions": "This survey has no questions",
"survey_import_line": "{surveyName}: {responseCount} responses × {questionCount} questions = {total} Feedback Records",
"total_feedback_records": "Total: {checked} of {total} Feedback Records selected across {surveyCount} surveys",
"unify_feedback": "Unify Feedback",
"update_mapping_description": "Update the mapping configuration for this source.",
"updated_at": "Updated at",
"upload_csv_data_description": "Upload a CSV file to import feedback data into the Hub.",
"upload_csv_file": "Upload CSV File",
"yes_delete": "Yes, delete"
"upload_csv_file": "Upload CSV File"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "Añadir fuente de feedback",
"add_source": "Añadir fuente",
"allowed_values": "Valores permitidos: {values}",
"are_you_sure": "¿Estás seguro?",
"change_file": "Cambiar archivo",
"click_load_sample_csv": "Haz clic en 'Cargar CSV de muestra' para ver las columnas",
"click_to_upload": "Haz clic para subir",
@@ -2069,42 +2068,38 @@
"csv_max_records": "Máximo de {max} registros permitidos.",
"default_connector_name_csv": "Importación CSV",
"default_connector_name_formbricks": "Conexión de encuesta de Formbricks",
"delete_source": "Eliminar fuente",
"deselect_all": "Deseleccionar todo",
"drop_a_field_here": "Suelta un campo aquí",
"drop_field_or": "Suelta el campo o",
"edit_source_connection": "Editar conexión de origen",
"element_selected": "<strong>{count}</strong> elemento seleccionado. Cada respuesta a este elemento creará un FeedbackRecord en el Hub.",
"elements_selected": "<strong>{count}</strong> elementos seleccionados. Cada respuesta a estos elementos creará un FeedbackRecord en el Hub.",
"enter_name_for_source": "Introduce un nombre para este origen",
"enter_value": "Introduce un valor...",
"enum": "enum",
"existing_responses_info": "{responseCount} respuestas existentes × {elementCount} elementos = {total} registros de feedback",
"feedback_date": "Fecha del feedback",
"formbricks_surveys": "Formbricks Surveys",
"historical_import_complete": "Importación completada: {successes} correctas, {failures} fallidas, {skipped} omitidas (sin datos)",
"hub_feedback_record_fields": "Campos de FeedbackRecord del Hub",
"import_csv_data": "Importar datos CSV",
"import_existing_responses": "Importar respuestas existentes",
"import_rows": "Importar {count} filas",
"importing_data": "Importando datos...",
"importing_historical_data": "Importando datos históricos...",
"invalid_enum_values": "Valores no válidos en la columna asignada a {field}",
"invalid_values_found": "Encontrados: {values} (filas: {rows}) {extra}",
"load_sample_csv": "Cargar CSV de muestra",
"n_supported_elements": "{count} elementos compatibles",
"n_supported_questions": "{count} preguntas compatibles",
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
"no_surveys_found": "No se encontraron encuestas en este entorno",
"optional": "Opcional",
"or_drag_and_drop": "o arrastra y suelta",
"question_selected": "<strong>{count}</strong> pregunta seleccionada. Cada respuesta a esta pregunta creará un registro de feedback nuevo.",
"question_type_not_supported": "Este tipo de pregunta no es compatible",
"questions_selected": "<strong>{count}</strong> preguntas seleccionadas. Cada respuesta a estas preguntas creará un registro de feedback nuevo.",
"required": "Obligatorio",
"save_changes": "Guardar cambios",
"select_a_survey_to_see_elements": "Selecciona una encuesta para ver sus elementos",
"select_a_survey_to_see_questions": "Selecciona una encuesta para ver sus preguntas",
"select_a_value": "Selecciona un valor...",
"select_all": "Seleccionar todo",
"select_elements": "Seleccionar elementos",
"select_questions": "Seleccionar preguntas",
"select_source_type_description": "Selecciona el tipo de fuente de feedback que quieres conectar.",
"select_source_type_prompt": "Selecciona el tipo de fuente de feedback que quieres conectar:",
"select_survey": "Seleccionar encuesta",
@@ -2120,18 +2115,19 @@
"source_name": "Nombre de origen",
"source_type_cannot_be_changed": "El tipo de origen no se puede cambiar",
"sources": "Orígenes",
"status_active": "Activo",
"status_active": "En progreso",
"status_completed": "Completado",
"status_draft": "Borrador",
"status_error": "Error",
"status_paused": "Pausado",
"survey_has_no_elements": "Esta encuesta no tiene elementos de pregunta",
"survey_has_no_questions": "Esta encuesta no tiene preguntas",
"survey_import_line": "{surveyName}: {responseCount} respuestas × {questionCount} preguntas = {total} registros de feedback",
"total_feedback_records": "Total: {checked} de {total} registros de feedback seleccionados en {surveyCount} encuestas",
"unify_feedback": "Unificar feedback",
"update_mapping_description": "Actualiza la configuración de mapeo para esta fuente.",
"updated_at": "Actualizado el",
"upload_csv_data_description": "Sube un archivo CSV para importar datos de feedback en el Hub.",
"upload_csv_file": "Subir archivo CSV",
"yes_delete": "Sí, eliminar"
"upload_csv_file": "Subir archivo CSV"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "Ajouter une source de feedback",
"add_source": "Ajouter une source",
"allowed_values": "Valeurs autorisées: {values}",
"are_you_sure": "Es-tu sûr?",
"change_file": "Changer de fichier",
"click_load_sample_csv": "Clique sur «Charger un exemple CSV» pour voir les colonnes",
"click_to_upload": "Clique pour charger",
@@ -2069,42 +2068,38 @@
"csv_max_records": "Maximum {max} enregistrements autorisés.",
"default_connector_name_csv": "Importation CSV",
"default_connector_name_formbricks": "Connexion de sondage Formbricks",
"delete_source": "Supprimer la source",
"deselect_all": "Tout désélectionner",
"drop_a_field_here": "Déposez un champ ici",
"drop_field_or": "Déposez un champ ou",
"edit_source_connection": "Modifier la connexion source",
"element_selected": "<strong>{count}</strong> élément sélectionné. Chaque réponse à cet élément créera un FeedbackRecord dans le Hub.",
"elements_selected": "<strong>{count}</strong> éléments sélectionnés. Chaque réponse à ces éléments créera un FeedbackRecord dans le Hub.",
"enter_name_for_source": "Entrez un nom pour cette source",
"enter_value": "Saisir une valeur...",
"enum": "enum",
"existing_responses_info": "{responseCount} réponses existantes × {elementCount} éléments = {total} enregistrements de feedback",
"feedback_date": "Date du feedback",
"formbricks_surveys": "Sondages Formbricks",
"historical_import_complete": "Importation terminée: {successes} réussies, {failures} échouées, {skipped} ignorées (aucune donnée)",
"hub_feedback_record_fields": "Champs d'enregistrement de feedback du Hub",
"import_csv_data": "Importer des données CSV",
"import_existing_responses": "Importer les réponses existantes",
"import_rows": "Importer {count} lignes",
"importing_data": "Importation des données...",
"importing_historical_data": "Importation des données historiques...",
"invalid_enum_values": "Valeurs non valides dans la colonne mappée à {field}",
"invalid_values_found": "Trouvées: {values} (lignes: {rows}) {extra}",
"load_sample_csv": "Charger un exemple de CSV",
"n_supported_elements": "{count} éléments pris en charge",
"n_supported_questions": "{count} questions prises en charge",
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
"no_surveys_found": "Aucune enquête trouvée dans cet environnement",
"optional": "Facultatif",
"or_drag_and_drop": "ou glisser-déposer",
"question_selected": "<strong>{count}</strong> question sélectionnée. Chaque réponse à cette question créera un nouvel enregistrement de feedback.",
"question_type_not_supported": "Ce type de question n'est pas pris en charge",
"questions_selected": "<strong>{count}</strong> questions sélectionnées. Chaque réponse à ces questions créera un nouvel enregistrement de feedback.",
"required": "Requis",
"save_changes": "Enregistrer les modifications",
"select_a_survey_to_see_elements": "Sélectionnez une enquête pour voir ses éléments",
"select_a_survey_to_see_questions": "Sélectionnez une enquête pour voir ses questions",
"select_a_value": "Sélectionnez une valeur...",
"select_all": "Sélectionner tout",
"select_elements": "Sélectionner les éléments",
"select_questions": "Sélectionner les questions",
"select_source_type_description": "Sélectionnez le type de source de feedback que vous souhaitez connecter.",
"select_source_type_prompt": "Sélectionnez le type de source de feedback que vous souhaitez connecter:",
"select_survey": "Sélectionner l'enquête",
@@ -2120,18 +2115,19 @@
"source_name": "Nom de la source",
"source_type_cannot_be_changed": "Le type de source ne peut pas être modifié",
"sources": "Sources",
"status_active": "Active",
"status_active": "En cours",
"status_completed": "Terminé",
"status_draft": "Brouillon",
"status_error": "Erreur",
"status_paused": "En pause",
"survey_has_no_elements": "Cette enquête n'a aucun élément de question",
"survey_has_no_questions": "Ce sondage n'a pas de questions",
"survey_import_line": "{surveyName}: {responseCount} réponses × {questionCount} questions = {total} enregistrements de feedback",
"total_feedback_records": "Total: {checked} sur {total} enregistrements de feedback sélectionnés parmi {surveyCount} sondages",
"unify_feedback": "Unifier les retours",
"update_mapping_description": "Mettre à jour la configuration de mappage pour cette source.",
"updated_at": "Mis à jour à",
"upload_csv_data_description": "Téléchargez un fichier CSV pour importer des données de feedback dans le Hub.",
"upload_csv_file": "Télécharger un fichier CSV",
"yes_delete": "Oui, supprimer"
"upload_csv_file": "Télécharger un fichier CSV"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "Visszajelzési forrás hozzáadása",
"add_source": "Forrás hozzáadása",
"allowed_values": "Engedélyezett értékek: {values}",
"are_you_sure": "Biztos benne?",
"change_file": "Fájl módosítása",
"click_load_sample_csv": "Kattintson a 'Minta CSV betöltése' gombra az oszlopok megtekintéséhez",
"click_to_upload": "Kattintson a feltöltéshez",
@@ -2069,42 +2068,38 @@
"csv_max_records": "Maximum {max} rekord engedélyezett.",
"default_connector_name_csv": "CSV importálás",
"default_connector_name_formbricks": "Formbricks kérdőív kapcsolat",
"delete_source": "Forrás törlése",
"deselect_all": "Összes kijelölés törlése",
"drop_a_field_here": "Húzz ide egy mezőt",
"drop_field_or": "Húzz ide egy mezőt vagy",
"edit_source_connection": "Forráskapcsolat szerkesztése",
"element_selected": "<strong>{count}</strong> elem kiválasztva. Az ezekre az elemekre adott minden válasz létrehoz egy FeedbackRecord-ot a központban.",
"elements_selected": "<strong>{count}</strong> elem kiválasztva. Az ezekre az elemekre adott minden válasz létrehoz egy FeedbackRecord-ot a központban.",
"enter_name_for_source": "Adj nevet ennek a forrásnak",
"enter_value": "Érték megadása...",
"enum": "felsorolás",
"existing_responses_info": "{responseCount} meglévő válasz × {elementCount} elem = {total} visszajelzési rekord",
"feedback_date": "Visszajelzés dátuma",
"formbricks_surveys": "Formbricks kérdőívek",
"historical_import_complete": "Importálás befejezve: {successes} sikeres, {failures} sikertelen, {skipped} kihagyva (nincs adat)",
"hub_feedback_record_fields": "Központi visszajelzési rekord mezők",
"import_csv_data": "CSV adatok importálása",
"import_existing_responses": "Meglévő válaszok importálása",
"import_rows": "{count} sor importálása",
"importing_data": "Adatok importálása...",
"importing_historical_data": "Történeti adatok importálása...",
"invalid_enum_values": "Érvénytelen értékek a(z) {field} mezőhöz rendelt oszlopban",
"invalid_values_found": "Talált értékek: {values} (sorok: {rows}) {extra}",
"load_sample_csv": "Minta CSV betöltése",
"n_supported_elements": "{count} támogatott elem",
"n_supported_questions": "{count} támogatott kérdés",
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
"no_surveys_found": "Nem találhatók kérdőívek ebben a környezetben",
"optional": "Elhagyható",
"or_drag_and_drop": "vagy húzd ide",
"question_selected": "<strong>{count}</strong> kérdés kiválasztva. Minden válasz ezekre a kérdésekre új visszajelzési rekordot hoz létre.",
"question_type_not_supported": "Ez a kérdéstípus nem támogatott",
"questions_selected": "<strong>{count}</strong> kérdés kiválasztva. Minden válasz ezekre a kérdésekre új visszajelzési rekordot hoz létre.",
"required": "Kötelező",
"save_changes": "Változtatások mentése",
"select_a_survey_to_see_elements": "Válassz egy kérdőívet az elemek megtekintéséhez",
"select_a_survey_to_see_questions": "Válassz egy kérdőívet a kérdések megtekintéséhez",
"select_a_value": "Válassz egy értéket...",
"select_all": "Összes kiválasztása",
"select_elements": "Elemek kiválasztása",
"select_questions": "Kérdések kiválasztása",
"select_source_type_description": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát.",
"select_source_type_prompt": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát:",
"select_survey": "Kérdőív kiválasztása",
@@ -2120,18 +2115,19 @@
"source_name": "Forrásnév",
"source_type_cannot_be_changed": "A forrástípus nem módosítható",
"sources": "Források",
"status_active": "Aktív",
"status_active": "Folyamatban",
"status_completed": "Befejezve",
"status_draft": "Piszkozat",
"status_error": "Hiba",
"status_paused": "Szüneteltetve",
"survey_has_no_elements": "Ez a kérdőív nem tartalmaz kérdéselemeket",
"survey_has_no_questions": "Ez a felmérés nem tartalmaz kérdéseket",
"survey_import_line": "{surveyName}: {responseCount} válasz × {questionCount} kérdés = {total} visszajelzési rekord",
"total_feedback_records": "Összesen: {checked} / {total} visszajelzési rekord kiválasztva {surveyCount} felmérésből",
"unify_feedback": "Visszajelzések egyesítése",
"update_mapping_description": "Frissítse a leképezési konfigurációt ehhez a forráshoz.",
"updated_at": "Frissítve",
"upload_csv_data_description": "Tölts fel egy CSV fájlt, hogy visszajelzési adatokat importálj a központba.",
"upload_csv_file": "CSV fájl feltöltése",
"yes_delete": "Igen, törlés"
"upload_csv_file": "CSV fájl feltöltése"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "フィードバックソースを追加",
"add_source": "ソースを追加",
"allowed_values": "許可される値: {values}",
"are_you_sure": "よろしいですか?",
"change_file": "ファイルを変更",
"click_load_sample_csv": "「サンプルCSVを読み込む」をクリックして列を表示",
"click_to_upload": "クリックしてアップロード",
@@ -2069,42 +2068,38 @@
"csv_max_records": "最大 {max} 件のレコードまで許可されています。",
"default_connector_name_csv": "CSVインポート",
"default_connector_name_formbricks": "Formbricks フォーム接続",
"delete_source": "ソースを削除",
"deselect_all": "すべて選択解除",
"drop_a_field_here": "ここにフィールドをドロップ",
"drop_field_or": "フィールドをドロップまたは",
"edit_source_connection": "ソース接続を編集",
"element_selected": "<strong>{count}</strong>個の要素が選択されています。これらの要素への各回答は、ハブにフィードバックレコードを作成します。",
"elements_selected": "<strong>{count}</strong>個の要素が選択されています。これらの要素への各回答は、ハブにフィードバックレコードを作成します。",
"enter_name_for_source": "このソースの名前を入力",
"enter_value": "値を入力...",
"enum": "列挙型",
"existing_responses_info": "{responseCount} 件の既存の回答 × {elementCount} 個の要素 = {total} 件のフィードバックレコード",
"feedback_date": "フィードバック日時",
"formbricks_surveys": "Formbricks フォーム",
"historical_import_complete": "インポート完了: {successes}件成功、{failures}件失敗、{skipped}件スキップ(データなし)",
"hub_feedback_record_fields": "ハブフィードバックレコードフィールド",
"import_csv_data": "CSVデータをインポート",
"import_existing_responses": "既存の回答をインポート",
"import_rows": "{count}行をインポート",
"importing_data": "データをインポート中...",
"importing_historical_data": "過去のデータをインポート中...",
"invalid_enum_values": "{field}にマッピングされた列に無効な値があります",
"invalid_values_found": "検出された値: {values}(行: {rows}{extra}",
"load_sample_csv": "サンプルCSVを読み込む",
"n_supported_elements": "{count} のサポートされている要素",
"n_supported_questions": "{count} のサポートされている質問",
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
"no_surveys_found": "この環境にフォームが見つかりません",
"optional": "任意",
"or_drag_and_drop": "またはドラッグ&ドロップ",
"question_selected": "<strong>{count}</strong>件の質問が選択されています。これらの質問への各回答は、新しいフィードバックレコードを作成します。",
"question_type_not_supported": "この質問タイプはサポートされていません",
"questions_selected": "<strong>{count}</strong>件の質問が選択されています。これらの質問への各回答は、新しいフィードバックレコードを作成します。",
"required": "必須",
"save_changes": "変更を保存",
"select_a_survey_to_see_elements": "フォームを選択して要素を表示",
"select_a_survey_to_see_questions": "フォームを選択して質問を表示",
"select_a_value": "値を選択...",
"select_all": "すべて選択",
"select_elements": "要素を選択",
"select_questions": "質問を選択",
"select_source_type_description": "接続するフィードバックソースの種類を選択してください。",
"select_source_type_prompt": "接続するフィードバックソースの種類を選択してください:",
"select_survey": "フォームを選択",
@@ -2120,18 +2115,19 @@
"source_name": "ソース名",
"source_type_cannot_be_changed": "ソースタイプは変更できません",
"sources": "ソース",
"status_active": "有効",
"status_active": "進行中",
"status_completed": "完了",
"status_draft": "下書き",
"status_error": "エラー",
"status_paused": "一時停止",
"survey_has_no_elements": "このフォームには質問要素がありません",
"survey_has_no_questions": "このアンケートには質問がありません",
"survey_import_line": "{surveyName}: {responseCount}件の回答 × {questionCount}件の質問 = {total}件のフィードバックレコード",
"total_feedback_records": "合計: {surveyCount}件のアンケート全体で{total}件中{checked}件のフィードバックレコードが選択されています",
"unify_feedback": "フィードバックを統合",
"update_mapping_description": "このソースのマッピング設定を更新します。",
"updated_at": "更新日時",
"upload_csv_data_description": "CSVファイルをアップロードして、フィードバックデータをハブにインポートします。",
"upload_csv_file": "CSVファイルをアップロード",
"yes_delete": "はい、削除します"
"upload_csv_file": "CSVファイルをアップロード"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "Feedbackbron toevoegen",
"add_source": "Bron toevoegen",
"allowed_values": "Toegestane waarden: {values}",
"are_you_sure": "Weet je het zeker?",
"change_file": "Bestand wijzigen",
"click_load_sample_csv": "Klik op 'Voorbeeld CSV laden' om kolommen te zien",
"click_to_upload": "Klik om te uploaden",
@@ -2069,42 +2068,38 @@
"csv_max_records": "Maximaal {max} records toegestaan.",
"default_connector_name_csv": "CSV import",
"default_connector_name_formbricks": "Formbricks Survey verbinding",
"delete_source": "Bron verwijderen",
"deselect_all": "Alles deselecteren",
"drop_a_field_here": "Zet hier een veld neer",
"drop_field_or": "Zet veld neer of",
"edit_source_connection": "Bronverbinding bewerken",
"element_selected": "<strong>{count}</strong> element geselecteerd. Elke reactie op dit element zal een FeedbackRecord aanmaken in de Hub.",
"elements_selected": "<strong>{count}</strong> elementen geselecteerd. Elke reactie op deze elementen zal een FeedbackRecord aanmaken in de Hub.",
"enter_name_for_source": "Voer een naam in voor deze bron",
"enter_value": "Voer waarde in...",
"enum": "enum",
"existing_responses_info": "{responseCount} bestaande antwoorden × {elementCount} elementen = {total} feedbackrecords",
"feedback_date": "Feedbackdatum",
"formbricks_surveys": "Formbricks Surveys",
"historical_import_complete": "Import voltooid: {successes} geslaagd, {failures} mislukt, {skipped} overgeslagen (geen data)",
"hub_feedback_record_fields": "Hub feedbackrecordvelden",
"import_csv_data": "CSV-gegevens importeren",
"import_existing_responses": "Bestaande antwoorden importeren",
"import_rows": "Importeer {count} rijen",
"import_rows": "{count, plural, one {Importeer 1 rij} other {Importeer # rijen}}",
"importing_data": "Gegevens importeren...",
"importing_historical_data": "Historische gegevens importeren...",
"invalid_enum_values": "Ongeldige waarden in kolom gekoppeld aan {field}",
"invalid_values_found": "Gevonden: {values} (rijen: {rows}) {extra}",
"load_sample_csv": "Voorbeeld-CSV laden",
"n_supported_elements": "{count} ondersteunde elementen",
"n_supported_questions": "{count} ondersteunde vragen",
"no_source_fields_loaded": "Nog geen bronvelden geladen",
"no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.",
"no_surveys_found": "Geen enquêtes gevonden in deze omgeving",
"optional": "Optioneel",
"or_drag_and_drop": "of sleep en zet neer",
"question_selected": "<strong>{count}</strong> vraag geselecteerd. Elk antwoord op deze vraag zal een nieuw feedbackrecord aanmaken.",
"question_type_not_supported": "Dit vraagtype wordt niet ondersteund",
"questions_selected": "<strong>{count}</strong> vragen geselecteerd. Elk antwoord op deze vragen zal een nieuw feedbackrecord aanmaken.",
"required": "Vereist",
"save_changes": "Wijzigingen opslaan",
"select_a_survey_to_see_elements": "Selecteer een enquête om de elementen te zien",
"select_a_survey_to_see_questions": "Selecteer een enquête om de vragen te zien",
"select_a_value": "Selecteer een waarde...",
"select_all": "Selecteer alles",
"select_elements": "Selecteer elementen",
"select_questions": "Selecteer vragen",
"select_source_type_description": "Selecteer het type feedbackbron dat je wilt verbinden.",
"select_source_type_prompt": "Selecteer het type feedbackbron dat je wilt verbinden:",
"select_survey": "Selecteer enquête",
@@ -2120,18 +2115,19 @@
"source_name": "Bronnaam",
"source_type_cannot_be_changed": "Brontype kan niet worden gewijzigd",
"sources": "Bronnen",
"status_active": "Actief",
"status_active": "In uitvoering",
"status_completed": "Voltooid",
"status_draft": "Voorlopige versie",
"status_error": "Fout",
"status_paused": "Gepauzeerd",
"survey_has_no_elements": "Deze enquête heeft geen vraagelementen",
"survey_has_no_questions": "Deze enquête heeft geen vragen",
"survey_import_line": "{surveyName}: {responseCount} antwoorden × {questionCount} vragen = {total} feedbackrecords",
"total_feedback_records": "Totaal: {checked} van {total} feedbackrecords geselecteerd over {surveyCount} enquêtes",
"unify_feedback": "Feedback verenigen",
"update_mapping_description": "Werk de mappingconfiguratie voor deze bron bij.",
"updated_at": "Bijgewerkt op",
"upload_csv_data_description": "Upload een CSV-bestand om feedbackgegevens te importeren in de Hub.",
"upload_csv_file": "CSV-bestand uploaden",
"yes_delete": "Ja, verwijderen"
"upload_csv_file": "CSV-bestand uploaden"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "Adicionar fonte de feedback",
"add_source": "Adicionar fonte",
"allowed_values": "Valores permitidos: {values}",
"are_you_sure": "Certeza?",
"change_file": "Alterar arquivo",
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
"click_to_upload": "Clique para fazer upload",
@@ -2069,42 +2068,38 @@
"csv_max_records": "Máximo de {max} registros permitidos.",
"default_connector_name_csv": "Importação CSV",
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
"delete_source": "Excluir fonte",
"deselect_all": "Desmarcar tudo",
"drop_a_field_here": "Solte um campo aqui",
"drop_field_or": "Solte o campo ou",
"edit_source_connection": "Editar conexão de origem",
"element_selected": "<strong>{count}</strong> elemento selecionado. Cada resposta a este elemento criará um FeedbackRecord no Hub.",
"elements_selected": "<strong>{count}</strong> elementos selecionados. Cada resposta a estes elementos criará um FeedbackRecord no Hub.",
"enter_name_for_source": "Digite um nome para esta origem",
"enter_value": "Digite o valor...",
"enum": "enum",
"existing_responses_info": "{responseCount} respostas existentes × {elementCount} elementos = {total} registros de feedback",
"feedback_date": "Data do feedback",
"formbricks_surveys": "Pesquisas Formbricks",
"historical_import_complete": "Importação concluída: {successes} bem-sucedidas, {failures} falharam, {skipped} ignoradas (sem dados)",
"hub_feedback_record_fields": "Campos de registro de feedback do Hub",
"import_csv_data": "Importar dados CSV",
"import_existing_responses": "Importar respostas existentes",
"import_rows": "Importar {count} linhas",
"importing_data": "Importando dados...",
"importing_historical_data": "Importando dados históricos...",
"invalid_enum_values": "Valores inválidos na coluna mapeada para {field}",
"invalid_values_found": "Encontrados: {values} (linhas: {rows}) {extra}",
"load_sample_csv": "Carregar CSV de exemplo",
"n_supported_elements": "{count, plural, one {# elemento suportado} other {# elementos suportados}}",
"n_supported_questions": "{count} perguntas suportadas",
"no_source_fields_loaded": "Nenhum campo de origem carregado ainda",
"no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.",
"no_surveys_found": "Nenhuma pesquisa encontrada neste ambiente",
"optional": "Opcional",
"or_drag_and_drop": "ou arraste e solte",
"question_selected": "<strong>{count}</strong> pergunta selecionada. Cada resposta a esta pergunta criará um novo registro de feedback.",
"question_type_not_supported": "Este tipo de pergunta não é suportado",
"questions_selected": "<strong>{count}</strong> perguntas selecionadas. Cada resposta a estas perguntas criará um novo registro de feedback.",
"required": "Obrigatório",
"save_changes": "Salvar alterações",
"select_a_survey_to_see_elements": "Selecione uma pesquisa para ver seus elementos",
"select_a_survey_to_see_questions": "Selecione uma pesquisa para ver suas perguntas",
"select_a_value": "Selecione um valor...",
"select_all": "Selecionar tudo",
"select_elements": "Selecionar elementos",
"select_questions": "Selecionar perguntas",
"select_source_type_description": "Selecione o tipo de fonte de feedback que você deseja conectar.",
"select_source_type_prompt": "Selecione o tipo de fonte de feedback que você deseja conectar:",
"select_survey": "Selecionar pesquisa",
@@ -2120,18 +2115,19 @@
"source_name": "Nome da origem",
"source_type_cannot_be_changed": "O tipo de origem não pode ser alterado",
"sources": "Origens",
"status_active": "Ativa",
"status_active": "Em andamento",
"status_completed": "Concluído",
"status_draft": "Rascunho",
"status_error": "Erro",
"status_paused": "Pausado",
"survey_has_no_elements": "Esta pesquisa não possui elementos de pergunta",
"survey_has_no_questions": "Esta pesquisa não possui perguntas",
"survey_import_line": "{surveyName}: {responseCount} respostas × {questionCount} perguntas = {total} registros de feedback",
"total_feedback_records": "Total: {checked} de {total} registros de feedback selecionados em {surveyCount} pesquisas",
"unify_feedback": "Unificar feedback",
"update_mapping_description": "Atualize a configuração de mapeamento para esta fonte.",
"updated_at": "Atualizado em",
"upload_csv_data_description": "Faça upload de um arquivo CSV para importar dados de feedback no Hub.",
"upload_csv_file": "Fazer upload de arquivo CSV",
"yes_delete": "Sim, deletar"
"upload_csv_file": "Fazer upload de arquivo CSV"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "Adicionar fonte de feedback",
"add_source": "Adicionar fonte",
"allowed_values": "Valores permitidos: {values}",
"are_you_sure": "Tem a certeza?",
"change_file": "Alterar ficheiro",
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
"click_to_upload": "Clique para carregar",
@@ -2069,42 +2068,38 @@
"csv_max_records": "Máximo de {max} registos permitidos.",
"default_connector_name_csv": "Importação CSV",
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
"delete_source": "Eliminar fonte",
"deselect_all": "Desselecionar tudo",
"drop_a_field_here": "Solte um campo aqui",
"drop_field_or": "Solte o campo ou",
"edit_source_connection": "Editar ligação de origem",
"element_selected": "<strong>{count}</strong> elemento selecionado. Cada resposta a este elemento criará um FeedbackRecord no Hub.",
"elements_selected": "<strong>{count}</strong> elementos selecionados. Cada resposta a estes elementos criará um FeedbackRecord no Hub.",
"enter_name_for_source": "Introduz um nome para esta origem",
"enter_value": "Introduzir valor...",
"enum": "enum",
"existing_responses_info": "{responseCount} respostas existentes × {elementCount} elementos = {total} registos de feedback",
"feedback_date": "Data do feedback",
"formbricks_surveys": "Pesquisas Formbricks",
"historical_import_complete": "Importação concluída: {successes} com sucesso, {failures} falharam, {skipped} ignorados (sem dados)",
"hub_feedback_record_fields": "Campos de registo de feedback do Hub",
"import_csv_data": "Importar dados CSV",
"import_existing_responses": "Importar respostas existentes",
"import_rows": "Importar {count} linhas",
"importing_data": "A importar dados...",
"importing_historical_data": "A importar dados históricos...",
"invalid_enum_values": "Valores inválidos na coluna mapeada para {field}",
"invalid_values_found": "Encontrados: {values} (linhas: {rows}) {extra}",
"load_sample_csv": "Carregar CSV de exemplo",
"n_supported_elements": "{count} elementos suportados",
"n_supported_questions": "{count} perguntas suportadas",
"no_source_fields_loaded": "Ainda não foram carregados campos de origem",
"no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.",
"no_surveys_found": "Nenhum inquérito encontrado neste ambiente",
"optional": "Opcional",
"or_drag_and_drop": "ou arraste e largue",
"question_selected": "<strong>{count}</strong> pergunta selecionada. Cada resposta a esta pergunta criará um novo registo de feedback.",
"question_type_not_supported": "Este tipo de pergunta não é suportado",
"questions_selected": "<strong>{count}</strong> perguntas selecionadas. Cada resposta a estas perguntas criará um novo registo de feedback.",
"required": "Obrigatório",
"save_changes": "Guardar alterações",
"select_a_survey_to_see_elements": "Selecione um inquérito para ver os seus elementos",
"select_a_survey_to_see_questions": "Selecione um inquérito para ver as suas perguntas",
"select_a_value": "Selecione um valor...",
"select_all": "Selecionar tudo",
"select_elements": "Selecionar elementos",
"select_questions": "Selecionar perguntas",
"select_source_type_description": "Selecione o tipo de fonte de feedback que pretende conectar.",
"select_source_type_prompt": "Selecione o tipo de fonte de feedback que pretende conectar:",
"select_survey": "Selecionar inquérito",
@@ -2120,18 +2115,19 @@
"source_name": "Nome da fonte",
"source_type_cannot_be_changed": "O tipo de fonte não pode ser alterado",
"sources": "Fontes",
"status_active": "Ativa",
"status_active": "Em progresso",
"status_completed": "Concluído",
"status_draft": "Rascunho",
"status_error": "Erro",
"status_paused": "Em pausa",
"survey_has_no_elements": "Este inquérito não tem elementos de pergunta",
"survey_has_no_questions": "Este inquérito não tem perguntas",
"survey_import_line": "{surveyName}: {responseCount} respostas × {questionCount} perguntas = {total} registos de feedback",
"total_feedback_records": "Total: {checked} de {total} registos de feedback selecionados em {surveyCount} inquéritos",
"unify_feedback": "Unificar feedback",
"update_mapping_description": "Atualiza a configuração de mapeamento para esta origem.",
"updated_at": "Atualizado em",
"upload_csv_data_description": "Carregue um ficheiro CSV para importar dados de feedback para o Hub.",
"upload_csv_file": "Carregar ficheiro CSV",
"yes_delete": "Sim, eliminar"
"upload_csv_file": "Carregar ficheiro CSV"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "Adaugă sursă de feedback",
"add_source": "Adaugă sursă",
"allowed_values": "Valori permise: {values}",
"are_you_sure": "Ești sigur?",
"change_file": "Schimbă fișierul",
"click_load_sample_csv": "Apasă pe „Încarcă CSV de exemplu” pentru a vedea coloanele",
"click_to_upload": "Apasă pentru a încărca",
@@ -2069,42 +2068,38 @@
"csv_max_records": "Sunt permise maximum {max} înregistrări.",
"default_connector_name_csv": "Import CSV",
"default_connector_name_formbricks": "Conexiune chestionar Formbricks",
"delete_source": "Șterge sursa",
"deselect_all": "Deselectează tot",
"drop_a_field_here": "Trage un câmp aici",
"drop_field_or": "Trage câmpul sau",
"edit_source_connection": "Editează conexiunea sursei",
"element_selected": "<strong>{count}</strong> element selectat. Fiecare răspuns la aceste elemente va crea un FeedbackRecord în Hub.",
"elements_selected": "<strong>{count}</strong> elemente selectate. Fiecare răspuns la aceste elemente va crea un FeedbackRecord în Hub.",
"enter_name_for_source": "Introdu un nume pentru această sursă",
"enter_value": "Introdu valoarea...",
"enum": "enum",
"existing_responses_info": "{responseCount} răspunsuri existente × {elementCount} elemente = {total} înregistrări de feedback",
"feedback_date": "Data feedbackului",
"formbricks_surveys": "Chestionare Formbricks",
"historical_import_complete": "Import finalizat: {successes} reușite, {failures} eșuate, {skipped} omise (fără date)",
"hub_feedback_record_fields": "Câmpuri FeedbackRecord din Hub",
"import_csv_data": "Importă date CSV",
"import_existing_responses": "Importă răspunsuri existente",
"import_rows": "Importă {count} rânduri",
"import_rows": "Importă {count, plural, one {# rând} few {# rânduri} other {# de rânduri}}",
"importing_data": "Se importă datele...",
"importing_historical_data": "Se importă date istorice...",
"invalid_enum_values": "Valori invalide în coloana mapată la {field}",
"invalid_values_found": "Găsite: {values} (rânduri: {rows}) {extra}",
"load_sample_csv": "Încarcă un CSV de exemplu",
"n_supported_elements": "{count} elemente suportate",
"n_supported_questions": "{count} întrebări acceptate",
"no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă",
"no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.",
"no_surveys_found": "Nu s-au găsit sondaje în acest mediu",
"optional": "Opțional",
"or_drag_and_drop": "sau trage și lasă aici",
"question_selected": "<strong>{count}</strong> întrebare selectată. Fiecare răspuns la aceste întrebări va crea un nou Feedback Record.",
"question_type_not_supported": "Acest tip de întrebare nu este suportat",
"questions_selected": "<strong>{count}</strong> întrebări selectate. Fiecare răspuns la aceste întrebări va crea un nou Feedback Record.",
"required": "Obligatoriu",
"save_changes": "Salvează modificările",
"select_a_survey_to_see_elements": "Selectează un sondaj pentru a vedea elementele",
"select_a_survey_to_see_questions": "Selectează un chestionar pentru a vedea întrebările",
"select_a_value": "Selectează o valoare...",
"select_all": "Selectează tot",
"select_elements": "Selectează elemente",
"select_questions": "Selectează întrebări",
"select_source_type_description": "Selectează tipul sursei de feedback pe care vrei să o conectezi.",
"select_source_type_prompt": "Selectează tipul sursei de feedback pe care vrei să o conectezi:",
"select_survey": "Selectează chestionar",
@@ -2120,18 +2115,19 @@
"source_name": "Nume sursă",
"source_type_cannot_be_changed": "Tipul sursei nu poate fi schimbat",
"sources": "Surse",
"status_active": "Activ",
"status_active": "În progres",
"status_completed": "Finalizat",
"status_draft": "Schiță",
"status_error": "Eroare",
"status_paused": "Pauzat",
"survey_has_no_elements": "Acest chestionar nu are elemente de întrebare",
"survey_has_no_questions": "Acest sondaj nu are întrebări",
"survey_import_line": "{surveyName}: {responseCount} răspunsuri × {questionCount} întrebări = {total} Feedback Records",
"total_feedback_records": "Total: {checked} din {total} Feedback Records selectate în {surveyCount} sondaje",
"unify_feedback": "Unify Feedback",
"update_mapping_description": "Actualizează configurația de mapare pentru această sursă.",
"updated_at": "Actualizat la",
"upload_csv_data_description": "Încarcă un fișier CSV pentru a importa date de feedback în Hub.",
"upload_csv_file": "Încarcă fișier CSV",
"yes_delete": "Da, șterge"
"upload_csv_file": "Încarcă fișier CSV"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "Добавить источник отзывов",
"add_source": "Добавить источник",
"allowed_values": "Допустимые значения: {values}",
"are_you_sure": "Вы уверены?",
"change_file": "Изменить файл",
"click_load_sample_csv": "Нажмите «Загрузить пример CSV», чтобы увидеть столбцы",
"click_to_upload": "Кликните для загрузки",
@@ -2069,42 +2068,38 @@
"csv_max_records": "Допустимо не более {max} записей.",
"default_connector_name_csv": "Импорт CSV",
"default_connector_name_formbricks": "Подключение опроса Formbricks",
"delete_source": "Удалить источник",
"deselect_all": "Снять выделение со всех",
"drop_a_field_here": "Перетащи сюда поле",
"drop_field_or": "Перетащи поле или",
"edit_source_connection": "Редактировать подключение источника",
"element_selected": "<strong>{count}</strong> элемент выбран. Каждый ответ на эти элементы создаст FeedbackRecord в Hub.",
"elements_selected": "<strong>{count}</strong> элементов выбрано. Каждый ответ на эти элементы создаст FeedbackRecord в Hub.",
"enter_name_for_source": "Введи имя для этого источника",
"enter_value": "Введите значение...",
"enum": "enum",
"existing_responses_info": "{responseCount} существующих ответов × {elementCount} элементов = {total} записей обратной связи",
"feedback_date": "Дата отзыва",
"formbricks_surveys": "Formbricks Surveys",
"historical_import_complete": "Импорт завершён: {successes} успешно, {failures} с ошибками, {skipped} пропущено (нет данных)",
"hub_feedback_record_fields": "Поля FeedbackRecord в Hub",
"import_csv_data": "Импортировать данные CSV",
"import_existing_responses": "Импортировать существующие ответы",
"import_rows": "Импортировать {count} строк",
"importing_data": "Импортируем данные...",
"importing_historical_data": "Импортируем исторические данные...",
"import_rows": "Импортировать {count, plural, one {# строку} few {# строки} many {# строк} other {# строки}}",
"importing_data": "Импорт данных...",
"invalid_enum_values": "Недопустимые значения в столбце, сопоставленном с {field}",
"invalid_values_found": "Найдено: {values} (строки: {rows}) {extra}",
"load_sample_csv": "Загрузить пример CSV",
"n_supported_elements": "{count} поддерживаемых элементов",
"n_supported_questions": "Поддерживается {count} вопрос(ов)",
"no_source_fields_loaded": "Поля источника ещё не загружены",
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
"no_surveys_found": "В этой среде не найдено опросов",
"optional": "Необязательно",
"or_drag_and_drop": "или перетащите файл",
"question_selected": "<strong>{count}</strong> выбранный вопрос. Каждый ответ на эти вопросы создаст новую запись обратной связи.",
"question_type_not_supported": "Этот тип вопроса не поддерживается",
"questions_selected": "<strong>{count}</strong> выбранных вопроса. Каждый ответ на эти вопросы создаст новую запись обратной связи.",
"required": "Обязательно",
"save_changes": "Сохранить изменения",
"select_a_survey_to_see_elements": "Выберите опрос, чтобы увидеть его элементы",
"select_a_survey_to_see_questions": "Выберите опрос, чтобы увидеть его вопросы",
"select_a_value": "Выберите значение...",
"select_all": "Выбрать все",
"select_elements": "Выбрать элементы",
"select_questions": "Выберите вопросы",
"select_source_type_description": "Выберите тип источника отзывов, который хотите подключить.",
"select_source_type_prompt": "Выберите тип источника отзывов, который хотите подключить:",
"select_survey": "Выбрать опрос",
@@ -2120,18 +2115,19 @@
"source_name": "Имя источника",
"source_type_cannot_be_changed": "Тип источника нельзя изменить",
"sources": "Источники",
"status_active": "Активен",
"status_active": "В процессе",
"status_completed": "Завершён",
"status_draft": "Черновик",
"status_error": "Ошибка",
"status_paused": "Приостановлен",
"survey_has_no_elements": "В этом опросе нет вопросов",
"survey_has_no_questions": "В этом опросе нет вопросов",
"survey_import_line": "{surveyName}: {responseCount} ответов × {questionCount} вопросов = {total} записей обратной связи",
"total_feedback_records": "Всего: выбрано {checked} из {total} записей обратной связи в {surveyCount} опросах",
"unify_feedback": "Обратная связь Unify",
"update_mapping_description": "Обнови настройки сопоставления для этого источника.",
"updated_at": "Обновлено",
"upload_csv_data_description": "Загрузите CSV-файл, чтобы импортировать данные обратной связи в Hub.",
"upload_csv_file": "Загрузить CSV-файл",
"yes_delete": "Да, удалить"
"upload_csv_file": "Загрузить CSV-файл"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "Lägg till feedbackkälla",
"add_source": "Lägg till källa",
"allowed_values": "Tillåtna värden: {values}",
"are_you_sure": "Är du säker?",
"change_file": "Byt fil",
"click_load_sample_csv": "Klicka på 'Ladda exempel-CSV' för att se kolumner",
"click_to_upload": "Klicka för att ladda upp",
@@ -2069,42 +2068,38 @@
"csv_max_records": "Maximalt {max} poster tillåtna.",
"default_connector_name_csv": "CSV-import",
"default_connector_name_formbricks": "Formbricks Survey-anslutning",
"delete_source": "Ta bort källa",
"deselect_all": "Avmarkera alla",
"drop_a_field_here": "Släpp ett fält här",
"drop_field_or": "Släpp fält eller",
"edit_source_connection": "Redigera källans anslutning",
"element_selected": "<strong>{count}</strong> element vald. Varje svar på dessa element skapar en FeedbackRecord i Hubben.",
"elements_selected": "<strong>{count}</strong> element valda. Varje svar på dessa element skapar en FeedbackRecord i Hubben.",
"enter_name_for_source": "Ange ett namn för denna källa",
"enter_value": "Ange värde...",
"enum": "enum",
"existing_responses_info": "{responseCount} befintliga svar × {elementCount} element = {total} feedbackposter",
"feedback_date": "Feedbackdatum",
"formbricks_surveys": "Formbricks Surveys",
"historical_import_complete": "Importen klar: {successes} lyckades, {failures} misslyckades, {skipped} hoppades över (ingen data)",
"hub_feedback_record_fields": "Fält för Hub Feedback Record",
"import_csv_data": "Importera CSV-data",
"import_existing_responses": "Importera befintliga svar",
"import_rows": "Importera {count} rader",
"importing_data": "Importerar data...",
"importing_historical_data": "Importerar historiska data...",
"invalid_enum_values": "Ogiltiga värden i kolumnen som är kopplad till {field}",
"invalid_values_found": "Hittade: {values} (rader: {rows}) {extra}",
"load_sample_csv": "Ladda exempel-CSV",
"n_supported_elements": "{count} stödda element",
"n_supported_questions": "{count} stödda frågor",
"no_source_fields_loaded": "Inga källfält har laddats än",
"no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.",
"no_surveys_found": "Inga enkäter hittades i denna miljö",
"optional": "Valfritt",
"or_drag_and_drop": "eller dra och släpp",
"question_selected": "<strong>{count}</strong> fråga vald. Varje svar på dessa frågor skapar en ny feedbackpost.",
"question_type_not_supported": "Den här frågetypen stöds inte",
"questions_selected": "<strong>{count}</strong> frågor valda. Varje svar på dessa frågor skapar en ny feedbackpost.",
"required": "Obligatoriskt",
"save_changes": "Spara ändringar",
"select_a_survey_to_see_elements": "Välj en enkät för att se dess element",
"select_a_survey_to_see_questions": "Välj en enkät för att se dess frågor",
"select_a_value": "Välj ett värde...",
"select_all": "Välj alla",
"select_elements": "Välj element",
"select_questions": "Välj frågor",
"select_source_type_description": "Välj vilken typ av feedbackkälla du vill ansluta.",
"select_source_type_prompt": "Välj vilken typ av feedbackkälla du vill ansluta:",
"select_survey": "Välj enkät",
@@ -2120,18 +2115,19 @@
"source_name": "Källnamn",
"source_type_cannot_be_changed": "Källtyp kan inte ändras",
"sources": "Källor",
"status_active": "Aktiv",
"status_active": "Pågående",
"status_completed": "Slutförd",
"status_draft": "Utkast",
"status_error": "Fel",
"status_paused": "Pausad",
"survey_has_no_elements": "Den här enkäten har inga frågeelement",
"survey_has_no_questions": "Den här enkäten har inga frågor",
"survey_import_line": "{surveyName}: {responseCount} svar × {questionCount} frågor = {total} feedbackposter",
"total_feedback_records": "Totalt: {checked} av {total} feedbackposter valda i {surveyCount} enkäter",
"unify_feedback": "Samla feedback",
"update_mapping_description": "Uppdatera mappningskonfigurationen för den här källan.",
"updated_at": "Uppdaterad",
"upload_csv_data_description": "Ladda upp en CSV-fil för att importera feedbackdata till Hub.",
"upload_csv_file": "Ladda upp CSV-fil",
"yes_delete": "Ja, ta bort"
"upload_csv_file": "Ladda upp CSV-fil"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "添加反馈来源",
"add_source": "添加来源",
"allowed_values": "允许的值:{values}",
"are_you_sure": "你确定吗?",
"change_file": "更换文件",
"click_load_sample_csv": "点击“加载示例 CSV”查看列",
"click_to_upload": "点击上传",
@@ -2069,42 +2068,38 @@
"csv_max_records": "最多允许 {max} 条记录。",
"default_connector_name_csv": "CSV 导入",
"default_connector_name_formbricks": "Formbricks 调查连接",
"delete_source": "删除来源",
"deselect_all": "取消全选",
"drop_a_field_here": "将字段拖到这里",
"drop_field_or": "拖放字段或",
"edit_source_connection": "编辑源连接",
"element_selected": "已选择 <strong>{count}</strong> 个元素。每个元素的反馈都会在 Hub 中创建一个 FeedbackRecord。",
"elements_selected": "已选择 <strong>{count}</strong> 个元素。每个元素的反馈都会在 Hub 中创建一个 FeedbackRecord。",
"enter_name_for_source": "为此来源输入名称",
"enter_value": "请输入值...",
"enum": "枚举",
"existing_responses_info": "{responseCount} 条现有回复 × {elementCount} 个元素 = {total} 条反馈记录",
"feedback_date": "反馈日期",
"formbricks_surveys": "Formbricks Surveys",
"historical_import_complete": "导入完成:{successes} 个成功,{failures} 个失败,{skipped} 个跳过(无数据)",
"hub_feedback_record_fields": "Hub 反馈记录字段",
"import_csv_data": "导入 CSV 数据",
"import_existing_responses": "导入现有回复",
"import_rows": "导入 {count} 行",
"importing_data": "正在导入数据...",
"importing_historical_data": "正在导入历史数据...",
"import_rows": "导入{count}行数据",
"importing_data": "正在导入数据…",
"invalid_enum_values": "映射到 {field} 的列中存在无效值",
"invalid_values_found": "发现:{values}(行:{rows}{extra}",
"load_sample_csv": "加载示例 CSV",
"n_supported_elements": "{count} 个支持的元素",
"n_supported_questions": "{count} 个支持的问题",
"no_source_fields_loaded": "尚未加载源字段",
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
"no_surveys_found": "此环境下未找到调查",
"optional": "可选",
"or_drag_and_drop": "或拖放",
"question_selected": "<strong>{count}</strong> 个问题已选。每个问题的回答都会创建一条新的反馈记录。",
"question_type_not_supported": "不支持此问题类型",
"questions_selected": "<strong>{count}</strong> 个问题已选。每个问题的回答都会创建一条新的反馈记录。",
"required": "必填",
"save_changes": "保存更改",
"select_a_survey_to_see_elements": "选择一个调查以查看其元素",
"select_a_survey_to_see_questions": "选择一个调查以查看其问题",
"select_a_value": "选择一个值...",
"select_all": "全选",
"select_elements": "选择元素",
"select_questions": "选择问题",
"select_source_type_description": "请选择你想要连接的反馈来源类型。",
"select_source_type_prompt": "请选择你想要连接的反馈来源类型:",
"select_survey": "选择调查",
@@ -2120,18 +2115,19 @@
"source_name": "来源名称",
"source_type_cannot_be_changed": "来源类型无法更改",
"sources": "来源",
"status_active": "已激活",
"status_active": "进行中",
"status_completed": "已完成",
"status_draft": "草稿",
"status_error": "错误",
"status_paused": "已暂停",
"survey_has_no_elements": "调查没有问题元素",
"survey_has_no_questions": "调查没有任何问题",
"survey_import_line": "{surveyName}{responseCount} 份答卷 × {questionCount} 个问题 = {total} 条反馈记录",
"total_feedback_records": "总计:{surveyCount} 个调查中已选 {checked} / {total} 条反馈记录",
"unify_feedback": "统一反馈",
"update_mapping_description": "更新此来源的映射配置。",
"updated_at": "更新于",
"upload_csv_data_description": "上传 CSV 文件,将反馈数据导入 Hub。",
"upload_csv_file": "上传 CSV 文件",
"yes_delete": "是的,删除"
"upload_csv_file": "上传 CSV 文件"
},
"workspace": {
"api_keys": {

View File

@@ -2043,7 +2043,6 @@
"add_feedback_source": "新增回饋來源",
"add_source": "新增來源",
"allowed_values": "允許的值:{values}",
"are_you_sure": "您確定嗎?",
"change_file": "更換檔案",
"click_load_sample_csv": "點擊「載入範例 CSV」以查看欄位",
"click_to_upload": "點擊以上傳",
@@ -2069,42 +2068,38 @@
"csv_max_records": "最多允許 {max} 筆紀錄。",
"default_connector_name_csv": "CSV 匯入",
"default_connector_name_formbricks": "Formbricks 問卷連線",
"delete_source": "刪除來源",
"deselect_all": "取消全選",
"drop_a_field_here": "請將欄位拖曳到這裡",
"drop_field_or": "拖曳欄位或",
"edit_source_connection": "編輯來源連線",
"element_selected": "已選取 <strong>{count}</strong> 個元素。每個對這些元素的回應都會在 Hub 中建立一個 FeedbackRecord。",
"elements_selected": "已選取 <strong>{count}</strong> 個元素。每個對這些元素的回應都會在 Hub 中建立一個 FeedbackRecord。",
"enter_name_for_source": "請輸入此來源的名稱",
"enter_value": "請輸入值……",
"enum": "enum",
"existing_responses_info": "{responseCount} 答覆 × {elementCount} 元素 = {total} 筆回饋紀錄",
"feedback_date": "回饋日期",
"formbricks_surveys": "Formbricks 問卷",
"historical_import_complete": "匯入完成:{successes} 筆成功,{failures} 筆失敗,{skipped} 筆略過(無資料)",
"hub_feedback_record_fields": "Hub 回饋紀錄欄位",
"import_csv_data": "匯入 CSV 資料",
"import_existing_responses": "匯入現有答覆",
"import_rows": "匯入 {count} 筆資料",
"importing_data": "正在匯入資料⋯⋯",
"importing_historical_data": "正在匯入歷史資料⋯⋯",
"importing_data": "正在匯入資料",
"invalid_enum_values": "對應到 {field} 欄位的值無效",
"invalid_values_found": "發現:{values}(列:{rows}{extra}",
"load_sample_csv": "載入範例 CSV",
"n_supported_elements": "支援 {count} 個元素",
"n_supported_questions": "{count} 個支援的問題",
"no_source_fields_loaded": "尚未載入來源欄位",
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
"no_surveys_found": "此環境中找不到問卷",
"optional": "選填",
"or_drag_and_drop": "或拖曳檔案",
"question_selected": "已選擇 <strong>{count}</strong> 題。每份這些題目的回應都會建立一筆新的意見紀錄。",
"question_type_not_supported": "不支援此題型",
"questions_selected": "已選擇 <strong>{count}</strong> 題。每份這些題目的回應都會建立一筆新的意見紀錄。",
"required": "必填",
"save_changes": "儲存變更",
"select_a_survey_to_see_elements": "請選擇問卷以查看其元素",
"select_a_survey_to_see_questions": "請選擇問卷以查看其問題",
"select_a_value": "請選擇一個值...",
"select_all": "全選",
"select_elements": "選取元素",
"select_questions": "選擇問題",
"select_source_type_description": "請選擇你想要連接的回饋來源類型。",
"select_source_type_prompt": "請選擇你想要連接的回饋來源類型:",
"select_survey": "選擇問卷",
@@ -2120,18 +2115,19 @@
"source_name": "來源名稱",
"source_type_cannot_be_changed": "來源類型無法變更",
"sources": "來源",
"status_active": "啟用中",
"status_active": "進行中",
"status_completed": "已完成",
"status_draft": "草稿",
"status_error": "錯誤",
"status_paused": "已暫停",
"survey_has_no_elements": "此問卷沒有任何題",
"survey_has_no_questions": "此問卷沒有任何題",
"survey_import_line": "{surveyName}{responseCount} 份回應 × {questionCount} 題 = {total} 筆意見紀錄",
"total_feedback_records": "總計:{surveyCount} 份問卷中已選擇 {checked} / {total} 筆意見紀錄",
"unify_feedback": "整合回饋",
"update_mapping_description": "更新此來源的對應設定。",
"updated_at": "更新時間",
"upload_csv_data_description": "上傳 CSV 檔案,將回饋資料匯入 Hub。",
"upload_csv_file": "上傳 CSV 檔案",
"yes_delete": "確定刪除"
"upload_csv_file": "上傳 CSV 檔案"
},
"workspace": {
"api_keys": {

View File

@@ -30,16 +30,16 @@ export const ContactsSecondaryNavigation = async ({
label: t("common.contacts"),
href: `/environments/${environmentId}/contacts`,
},
{
id: "segments",
label: t("common.segments"),
href: `/environments/${environmentId}/segments`,
},
{
id: "attributes",
label: t("common.attributes"),
href: `/environments/${environmentId}/attributes`,
},
{
id: "segments",
label: t("common.segments"),
href: `/environments/${environmentId}/segments`,
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import FormbricksHub from "@formbricks/hub";
vi.mock("@formbricks/hub", () => {
const MockFormbricksHub = vi.fn();
return { default: MockFormbricksHub };
});
vi.mock("@/lib/env", () => ({
env: {
HUB_API_KEY: "",
HUB_API_URL: "https://hub.test",
},
}));
const { env } = await import("@/lib/env");
const mutableEnv = env as unknown as Record<string, string>;
const globalForHub = globalThis as unknown as {
formbricksHubClient: FormbricksHub | undefined;
};
describe("getHubClient", () => {
beforeEach(() => {
vi.clearAllMocks();
globalForHub.formbricksHubClient = undefined;
});
test("returns null when HUB_API_KEY is not set", async () => {
mutableEnv.HUB_API_KEY = "";
const { getHubClient } = await import("./hub-client");
const client = getHubClient();
expect(client).toBeNull();
expect(FormbricksHub).not.toHaveBeenCalled();
});
test("creates and caches a new client when HUB_API_KEY is set", async () => {
mutableEnv.HUB_API_KEY = "test-key";
const mockInstance = { feedbackRecords: {} } as unknown as FormbricksHub;
vi.mocked(FormbricksHub).mockReturnValue(mockInstance);
const { getHubClient } = await import("./hub-client");
const client = getHubClient();
expect(FormbricksHub).toHaveBeenCalledWith({ apiKey: "test-key", baseURL: "https://hub.test" });
expect(client).toBe(mockInstance);
expect(globalForHub.formbricksHubClient).toBe(mockInstance);
});
test("returns cached client on subsequent calls", async () => {
const cachedInstance = { feedbackRecords: {} } as unknown as FormbricksHub;
globalForHub.formbricksHubClient = cachedInstance;
const { getHubClient } = await import("./hub-client");
const client = getHubClient();
expect(client).toBe(cachedInstance);
expect(FormbricksHub).not.toHaveBeenCalled();
});
test("does not cache null result so a later call with the key set can create the client", async () => {
mutableEnv.HUB_API_KEY = "";
const { getHubClient } = await import("./hub-client");
const first = getHubClient();
expect(first).toBeNull();
expect(globalForHub.formbricksHubClient).toBeUndefined();
mutableEnv.HUB_API_KEY = "now-set";
const mockInstance = { feedbackRecords: {} } as unknown as FormbricksHub;
vi.mocked(FormbricksHub).mockReturnValue(mockInstance);
const second = getHubClient();
expect(second).toBe(mockInstance);
expect(globalForHub.formbricksHubClient).toBe(mockInstance);
});
});

View File

@@ -0,0 +1,25 @@
import "server-only";
import FormbricksHub from "@formbricks/hub";
import { env } from "@/lib/env";
const globalForHub = globalThis as unknown as {
formbricksHubClient: FormbricksHub | undefined;
};
/**
* Returns a shared Formbricks Hub API client when HUB_API_KEY is set.
* Uses a global singleton so the same instance is reused across the process
* (and across Next.js HMR in development). When the key is not set, returns
* null and does not cache that result so a later call with the key set
* can create the client.
*/
export const getHubClient = (): FormbricksHub | null => {
if (globalForHub.formbricksHubClient) {
return globalForHub.formbricksHubClient;
}
const apiKey = env.HUB_API_KEY;
if (!apiKey) return null;
const client = new FormbricksHub({ apiKey, baseURL: env.HUB_API_URL });
globalForHub.formbricksHubClient = client;
return client;
};

View File

@@ -0,0 +1,3 @@
export { getHubClient } from "./hub-client";
export { createFeedbackRecord, createFeedbackRecordsBatch, type CreateFeedbackRecordResult } from "./service";
export type { FeedbackRecordCreateParams, FeedbackRecordData } from "./types";

View File

@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createFeedbackRecord, createFeedbackRecordsBatch } from "./service";
import type { FeedbackRecordCreateParams } from "./types";
vi.mock("@formbricks/logger", () => ({
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("./hub-client", () => ({
getHubClient: vi.fn(),
}));
const { getHubClient } = await import("./hub-client");
const sampleInput: FeedbackRecordCreateParams = {
field_id: "el-1",
field_type: "rating",
source_type: "formbricks",
source_id: "survey-1",
source_name: "Test Survey",
field_label: "Question?",
value_number: 5,
collected_at: "2026-02-24T10:00:00.000Z",
};
describe("hub service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createFeedbackRecord", () => {
test("returns error result when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await createFeedbackRecord(sampleInput);
expect(result.data).toBeNull();
expect(result.error).toMatchObject({
status: 0,
message: "HUB_API_KEY is not set; Hub integration is disabled.",
});
});
test("returns data when client.create succeeds", async () => {
const created = { id: "hub-1", ...sampleInput };
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { create: vi.fn().mockResolvedValue(created) },
} as any);
const result = await createFeedbackRecord(sampleInput);
expect(result.error).toBeNull();
expect(result.data).toEqual(created);
});
test("returns error result when client.create throws", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { create: vi.fn().mockRejectedValue(new Error("Network error")) },
} as any);
const result = await createFeedbackRecord(sampleInput);
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ message: "Network error" });
});
});
describe("createFeedbackRecordsBatch", () => {
test("returns all errors when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await createFeedbackRecordsBatch([sampleInput, { ...sampleInput, field_id: "el-2" }]);
expect(result.results).toHaveLength(2);
result.results.forEach((r) => {
expect(r.data).toBeNull();
expect(r.error?.message).toContain("HUB_API_KEY is not set");
});
});
test("returns results per input when client creates succeed", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: {
create: vi
.fn()
.mockImplementation((input: FeedbackRecordCreateParams) =>
Promise.resolve({ id: `hub-${input.field_id}`, ...input })
),
},
} as any);
const inputs = [sampleInput, { ...sampleInput, field_id: "el-2" }];
const result = await createFeedbackRecordsBatch(inputs);
expect(result.results).toHaveLength(2);
expect(result.results[0].data).toMatchObject({ field_id: "el-1" });
expect(result.results[0].error).toBeNull();
expect(result.results[1].data).toMatchObject({ field_id: "el-2" });
expect(result.results[1].error).toBeNull();
});
test("returns mixed results when some creates fail", async () => {
const create = vi
.fn()
.mockResolvedValueOnce({ id: "hub-1", ...sampleInput })
.mockRejectedValueOnce(new Error("Rate limited"));
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { create },
} as any);
const inputs = [sampleInput, { ...sampleInput, field_id: "el-2" }];
const result = await createFeedbackRecordsBatch(inputs);
expect(result.results).toHaveLength(2);
expect(result.results[0].data).not.toBeNull();
expect(result.results[0].error).toBeNull();
expect(result.results[1].data).toBeNull();
expect(result.results[1].error).toMatchObject({ message: "Rate limited" });
});
});
});

View File

@@ -0,0 +1,70 @@
import "server-only";
import FormbricksHub from "@formbricks/hub";
import { logger } from "@formbricks/logger";
import { getHubClient } from "./hub-client";
import type { FeedbackRecordCreateParams, FeedbackRecordData } from "./types";
export type CreateFeedbackRecordResult = {
data: FeedbackRecordData | null;
error: { status: number; message: string; detail: string } | null;
};
const NO_CONFIG_ERROR = {
status: 0,
message: "HUB_API_KEY is not set; Hub integration is disabled.",
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
} as const;
const createResultFromError = (err: unknown): CreateFeedbackRecordResult => {
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
const message = err instanceof Error ? err.message : String(err);
return { data: null, error: { status, message, detail: message } };
};
/**
* Create a single feedback record in the Hub.
* Returns a result shape with data or error; logs failures.
*/
export const createFeedbackRecord = async (
input: FeedbackRecordCreateParams
): Promise<CreateFeedbackRecordResult> => {
const client = getHubClient();
if (!client) {
return { data: null, error: { ...NO_CONFIG_ERROR } };
}
try {
const data = await client.feedbackRecords.create(input);
return { data, error: null };
} catch (err) {
logger.warn({ err, fieldId: input.field_id }, "Hub: createFeedbackRecord failed");
return createResultFromError(err);
}
};
/**
* Create multiple feedback records in the Hub in parallel.
* Returns an array of results (data or error) per input; logs failures.
*/
export const createFeedbackRecordsBatch = async (
inputs: FeedbackRecordCreateParams[]
): Promise<{ results: CreateFeedbackRecordResult[] }> => {
const client = getHubClient();
if (!client) {
return {
results: inputs.map(() => ({ data: null, error: { ...NO_CONFIG_ERROR } })),
};
}
const results = await Promise.all(
inputs.map(async (input) => {
try {
const data = await client.feedbackRecords.create(input);
return { data, error: null as CreateFeedbackRecordResult["error"] };
} catch (err) {
logger.warn({ err, fieldId: input.field_id }, "Hub: createFeedbackRecord failed");
return createResultFromError(err);
}
})
);
return { results };
};

View File

@@ -0,0 +1,4 @@
import type FormbricksHub from "@formbricks/hub";
export type FeedbackRecordCreateParams = FormbricksHub.FeedbackRecordCreateParams;
export type FeedbackRecordData = FormbricksHub.FeedbackRecordData;

View File

@@ -30,6 +30,7 @@
"@formbricks/cache": "workspace:*",
"@formbricks/database": "workspace:*",
"@formbricks/email": "workspace:*",
"@formbricks/hub": "0.3.0",
"@formbricks/i18n-utils": "workspace:*",
"@formbricks/js-core": "workspace:*",
"@formbricks/logger": "workspace:*",

View File

@@ -124,7 +124,7 @@ export const ZConnectorUpdateInput = z.object({
export type TConnectorUpdateInput = z.infer<typeof ZConnectorUpdateInput>;
// Element types that cannot be mapped to Hub fields
export const UNSUPPORTED_CONNECTOR_ELEMENT_TYPES = [
export const UNSUPPORTED_CONNECTOR_ELEMENT_TYPES: readonly TSurveyElementTypeEnum[] = [
TSurveyElementTypeEnum.ContactInfo,
TSurveyElementTypeEnum.Address,
TSurveyElementTypeEnum.Cal,

8
pnpm-lock.yaml generated
View File

@@ -160,6 +160,9 @@ importers:
'@formbricks/email':
specifier: workspace:*
version: link:../../packages/email
'@formbricks/hub':
specifier: 0.3.0
version: 0.3.0
'@formbricks/i18n-utils':
specifier: workspace:*
version: link:../../packages/i18n-utils
@@ -2337,6 +2340,9 @@ packages:
'@formatjs/intl-localematcher@0.6.2':
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
'@formbricks/hub@0.3.0':
resolution: {integrity: sha512-SfLQghsLILSiN/53mUHgkUbUcCar5l2bGC8DDSV9Y9NWdau+r+zFIYFEoSxhlMzlwhI+uvt/gkbVKShERBeoRQ==}
'@formkit/auto-animate@0.8.2':
resolution: {integrity: sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==}
@@ -13885,6 +13891,8 @@ snapshots:
dependencies:
tslib: 2.8.1
'@formbricks/hub@0.3.0': {}
'@formkit/auto-animate@0.8.2': {}
'@gar/promisify@1.1.3':