Compare commits

...

3 Commits

Author SHA1 Message Date
Johannes aed47b94a8 feat: refactor feedback sources UI and routing
Rework the feedback sources flow with new source form helpers, question selection components, and a canonical feedback-sources route while retiring the legacy survey selector.

Made-with: Cursor
2026-04-24 10:37:26 +02:00
Johannes ecaa2887b7 refactor: align connector enum with formbricks_survey
Rename connector type usage from formbricks to formbricks_survey across Prisma schema, shared types, and connector service logic to keep enum contracts consistent.

Made-with: Cursor
2026-04-24 10:36:16 +02:00
Johannes ad094b2d4c fix setup + tweak chart & dashboards creation UX 2026-04-22 09:04:42 +02:00
43 changed files with 1050 additions and 933 deletions
+6 -7
View File
@@ -287,15 +287,14 @@ REDIS_URL=redis://localhost:6379
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
#
# Cube connects to the Hub DB. When using docker-compose.dev.yml with the hub network,
# use the container name and internal port. Hub credentials: formbricks/formbricks_dev, db: hub
# CUBEJS_DB_HOST=formbricks_hub_postgres
# Cube connects to the Hub DB. With docker-compose.dev.yml defaults, use the local postgres service.
# CUBEJS_DB_HOST=postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=hub
# CUBEJS_DB_USER=formbricks
# CUBEJS_DB_PASS=formbricks_dev
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
#
# Alternative (when not on same Docker network): host.docker.internal and port 5433
# Alternative (external Hub/Postgres on the hub network): formbricks_hub_postgres, db: hub, user/pass: formbricks/formbricks_dev
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
@@ -0,0 +1 @@
export { WorkspaceSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
@@ -167,7 +167,7 @@ export const MainNavigation = ({
disabled: isMembershipPending || isBilling,
},
{
name: t("common.analysis"),
name: t("common.dashboards"),
href: `/workspaces/${workspace.id}/dashboards`,
icon: BarChart3Icon,
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
@@ -171,15 +171,6 @@ export const OrganizationBreadcrumb = ({
href: `${workspaceBasePath}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.title"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
{
id: "enterprise",
label: t("common.enterprise_license"),
@@ -0,0 +1,26 @@
"use client";
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
import { TConnectorType } from "@formbricks/types/connector";
export const getConnectorIcon = (type: TConnectorType, className: string) => {
switch (type) {
case "formbricks_survey":
return <FormIcon className={className} />;
case "csv":
return <FileSpreadsheetIcon className={className} />;
default:
return <FormIcon className={className} />;
}
};
export const getConnectorTypeLabelKey = (type: TConnectorType): string => {
switch (type) {
case "formbricks_survey":
return "workspace.unify.formbricks_surveys";
case "csv":
return "workspace.unify.csv_import";
default:
return type;
}
};
@@ -0,0 +1,21 @@
import { FEEDBACK_RECORD_FIELDS, TFieldMapping } from "../types";
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
const requiredFieldIds = new Set(
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
);
for (const mapping of mappings) {
if (!requiredFieldIds.has(mapping.targetFieldId)) {
continue;
}
if (mapping.sourceFieldId || mapping.staticValue) {
requiredFieldIds.delete(mapping.targetFieldId);
}
}
return requiredFieldIds.size === 0;
};
@@ -2,6 +2,7 @@
import {
CopyIcon,
EyeIcon,
FileSpreadsheetIcon,
MoreVertical,
PauseIcon,
@@ -9,6 +10,7 @@ import {
SquarePenIcon,
TrashIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
@@ -39,12 +41,15 @@ export function ConnectorRowDropdown({
onToggleStatus,
onDelete,
}: ConnectorRowDropdownProps) {
const router = useRouter();
const { t } = useTranslation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isActive = connector.status === "active";
const linkedSurveyId =
connector.type === "formbricks_survey" ? connector.formbricksMappings[0]?.surveyId : undefined;
const handleDelete = async () => {
setIsDeleting(true);
@@ -89,6 +94,25 @@ export function ConnectorRowDropdown({
</>
)}
{linkedSurveyId && (
<>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
router.push(`/workspaces/${connector.workspaceId}/surveys/${linkedSurveyId}/summary`);
}}>
<EyeIcon className="mr-2 h-4 w-4" />
{`${t("common.view")} ${t("common.survey")}`}
</button>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem>
<button
type="button"
@@ -1,56 +1,82 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TConnectorType } from "@formbricks/types/connector";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { getConnectorOptions } from "../utils";
import { TConnectorOptionId, getConnectorOptions } from "../utils";
interface ConnectorTypeSelectorProps {
selectedType: TConnectorType | null;
onSelectType: (type: TConnectorType) => void;
selectedType: TConnectorOptionId | null;
onSelectType: (type: TConnectorOptionId) => void;
}
export function ConnectorTypeSelector({ selectedType, onSelectType }: ConnectorTypeSelectorProps) {
const getOptionClassName = (
selectedType: TConnectorOptionId | null,
optionId: TConnectorOptionId,
disabled: boolean
): string => {
if (selectedType === optionId) {
return "border-brand-dark bg-slate-50";
}
if (disabled) {
return "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60";
}
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
};
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
const { t } = useTranslation();
const connectorOptions = getConnectorOptions(t);
return (
<div className="space-y-3">
<p className="text-sm text-slate-600">{t("workspace.unify.select_source_type_prompt")}</p>
<div className="space-y-2">
{connectorOptions.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id as TConnectorType)}
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
selectedType === option.id
? "border-brand-dark bg-slate-50"
: option.disabled
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
}`}>
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
selectedType,
option.id,
option.disabled
)}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{option.name}</span>
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
</div>
<div
className={`ml-4 h-5 w-5 rounded-full border-2 ${
className={`ml-3 h-4 w-4 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-2 w-2 rounded-full bg-white" />
<div className="h-1.5 w-1.5 rounded-full bg-white" />
</div>
)}
</div>
</button>
))}
</div>
<Alert variant="outbound" size="small">
<AlertTitle>{t("workspace.unify.missing_feedback_source_title")}</AlertTitle>
<AlertButton asChild>
<Link
href="https://app.formbricks.com/s/cmob8tub9s2ndu5010ei4it0g"
target="_blank"
rel="noopener noreferrer"
className="text-slate-900 hover:underline">
{t("workspace.unify.request_feedback_source")}
</Link>
</AlertButton>
</Alert>
</div>
);
}
@@ -1,10 +1,12 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TConnectorType, TConnectorWithMappings, THubTargetField } from "@formbricks/types/connector";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import {
createConnectorWithMappingsAction,
deleteConnectorAction,
@@ -12,9 +14,10 @@ import {
updateConnectorWithMappingsAction,
} from "@/lib/connector/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
import { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
import { TFieldMapping, TUnifySurvey } from "../types";
import { ConnectorsTable } from "./connectors-table";
import { CreateConnectorModal } from "./create-connector-modal";
@@ -33,12 +36,21 @@ export function ConnectorsSection({
initialConnectors,
initialSurveys,
directories,
}: ConnectorsSectionProps) {
}: Readonly<ConnectorsSectionProps>) {
const { t } = useTranslation();
const router = useRouter();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingConnector, setEditingConnector] = useState<TConnectorWithMappings | null>(null);
const [csvImportConnector, setCsvImportConnector] = useState<TConnectorWithMappings | null>(null);
const directoryNames = directories.map((directory) => directory.name).join(", ");
const feedbackDirectoryAccessText =
directories.length === 1
? t("workspace.unify.feedback_sources_directory_access_single", {
directoryNames,
})
: t("workspace.unify.feedback_sources_directory_access_multiple", {
directoryNames,
});
const handleCreateConnector = async (data: {
name: string;
@@ -55,9 +67,9 @@ export function ConnectorsSection({
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
},
formbricksMappings:
data.type === "formbricks" && data.surveyMappings?.length ? data.surveyMappings : undefined,
data.type === "formbricks_survey" && data.surveyMappings?.length ? data.surveyMappings : undefined,
fieldMappings:
data.type !== "formbricks" && data.fieldMappings?.length
data.type !== "formbricks_survey" && data.fieldMappings?.length
? data.fieldMappings.map((m) => ({
sourceFieldId: m.sourceFieldId || "",
targetFieldId: m.targetFieldId as THubTargetField,
@@ -154,8 +166,13 @@ export function ConnectorsSection({
return (
<PageContentWrapper>
<PageHeader
pageTitle={t("workspace.unify.unify_feedback")}
<PageHeader pageTitle={t("common.workspace_configuration")}>
<WorkspaceConfigNavigation activeId="feedback-sources" />
</PageHeader>
<SettingsCard
title={t("workspace.unify.feedback_sources")}
description={t("workspace.unify.feedback_sources_settings_description")}
cta={
<CreateConnectorModal
open={isCreateModalOpen}
@@ -166,10 +183,6 @@ export function ConnectorsSection({
directories={directories}
/>
}>
<UnifyConfigNavigation workspaceId={workspaceId} activeId="sources" />
</PageHeader>
<div className="space-y-6">
<ConnectorsTable
connectors={initialConnectors}
onConnectorClick={setEditingConnector}
@@ -179,7 +192,17 @@ export function ConnectorsSection({
onDelete={handleDeleteConnector}
isLoading={false}
/>
</div>
{directories.length > 0 && (
<Alert size="small" className="mt-4">
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
<AlertButton asChild>
<Link href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.unify.manage_directories")}
</Link>
</AlertButton>
</Alert>
)}
</SettingsCard>
<EditConnectorModal
connector={editingConnector}
@@ -187,7 +210,6 @@ export function ConnectorsSection({
onOpenChange={(open) => !open && setEditingConnector(null)}
onUpdateConnector={handleUpdateConnector}
surveys={initialSurveys}
directories={directories}
onOpenCsvImport={() => {
if (editingConnector) {
setCsvImportConnector(editingConnector);
@@ -1,9 +1,9 @@
"use client";
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TConnectorStatus, TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
import { Badge } from "@/modules/ui/components/badge";
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
import { ConnectorRowDropdown } from "./connector-row-dropdown";
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
@@ -39,17 +39,6 @@ interface ConnectorsTableDataRowProps {
onDelete: () => Promise<void>;
}
function getConnectorIcon(type: TConnectorType) {
switch (type) {
case "formbricks":
return <FormIcon className="h-4 w-4 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
default:
return <FormIcon className="h-4 w-4 text-slate-500" />;
}
}
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
active: "success",
paused: "warning",
@@ -63,13 +52,24 @@ export function ConnectorsTableDataRow({
onDuplicate,
onToggleStatus,
onDelete,
}: ConnectorsTableDataRowProps) {
}: Readonly<ConnectorsTableDataRowProps>) {
const { t, i18n } = useTranslation();
const handleRowClick = () => {
if (connector.type === "csv" && onCsvImport) {
onCsvImport();
return;
}
const getStatusLabel = (s: TConnectorStatus) => {
onEdit();
};
const getStatusLabel = (s: TConnectorStatus, connectorType: TConnectorType) => {
switch (s) {
case "active":
return t("workspace.unify.status_active");
if (connectorType === "csv") {
return t("workspace.unify.status_ready");
}
return t("workspace.unify.status_live_sync");
case "paused":
return t("workspace.unify.status_paused");
case "error":
@@ -77,44 +77,32 @@ export function ConnectorsTableDataRow({
}
};
const getConnectorTypeLabel = (connectorType: TConnectorType) => {
switch (connectorType) {
case "formbricks":
return t("workspace.unify.formbricks_surveys");
case "csv":
return t("workspace.unify.csv_import");
default:
return connectorType;
}
};
return (
<div
role="button"
tabIndex={0}
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
onClick={onEdit}
onClick={handleRowClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onEdit();
handleRowClick();
}
}}>
<div className="col-span-1 flex items-center gap-2 pl-4" title={getConnectorTypeLabel(connector.type)}>
{getConnectorIcon(connector.type)}
<div
className="col-span-1 flex items-center gap-2 pl-4"
title={t(getConnectorTypeLabelKey(connector.type))}>
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
</div>
<div className="col-span-3 flex items-center">
<div className="col-span-5 flex items-center">
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
</div>
<div className="col-span-1 hidden items-center justify-center sm:flex">
<Badge
text={getStatusLabel(connector.status)}
text={getStatusLabel(connector.status, connector.type)}
type={STATUS_BADGE_TYPE[connector.status]}
size="tiny"
/>
</div>
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{getRelativeTime(connector.createdAt, i18n.language)}
</div>
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{getRelativeTime(connector.updatedAt, i18n.language)}
</div>
@@ -23,16 +23,15 @@ export function ConnectorsTable({
onToggleStatus,
onDelete,
isLoading = false,
}: ConnectorsTableProps) {
}: Readonly<ConnectorsTableProps>) {
const { t } = useTranslation();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">{t("common.type")}</div>
<div className="col-span-3">{t("common.name")}</div>
<div className="col-span-5">{t("common.name")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.created")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
<div className="col-span-1" />
@@ -1,9 +1,13 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2Icon, PlusIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TConnectorType, UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
import {
getResponseCountAction,
@@ -21,8 +25,15 @@ import {
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
@@ -30,17 +41,18 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Switch } from "@/modules/ui/components/switch";
import { TCreateConnectorStep, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
import {
FEEDBACK_RECORD_FIELDS,
TCreateConnectorStep,
TFieldMapping,
TSourceField,
TUnifySurvey,
} from "../types";
import { TEnumValidationError, parseCSVColumnsToFields, validateEnumMappings } from "../utils";
TConnectorOptionId,
TEnumValidationError,
parseCSVColumnsToFields,
validateEnumMappings,
} from "../utils";
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
import { ConnectorTypeSelector } from "./connector-type-selector";
import { CsvConnectorUI } from "./csv-connector-ui";
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
import { FormbricksQuestionList } from "./formbricks-question-list";
interface CreateConnectorModalProps {
open: boolean;
@@ -59,101 +71,47 @@ interface CreateConnectorModalProps {
const getDialogTitle = (
step: TCreateConnectorStep,
type: TConnectorType | null,
type: TConnectorOptionId | null,
t: (key: string) => string
): string => {
if (step === "selectType") return t("workspace.unify.add_feedback_source");
if (type === "formbricks") return t("workspace.unify.select_survey_and_questions");
if (type === "formbricks_survey") return t("workspace.unify.select_survey_and_questions");
if (type === "csv") return t("workspace.unify.import_csv_data");
return t("workspace.unify.configure_mapping");
};
const getDialogDescription = (
step: TCreateConnectorStep,
type: TConnectorType | null,
type: TConnectorOptionId | null,
t: (key: string) => string
): string => {
if (step === "selectType") return t("workspace.unify.select_source_type_description");
if (type === "formbricks") return t("workspace.unify.select_survey_questions_description");
if (type === "formbricks_survey") return t("workspace.unify.select_survey_questions_description");
if (type === "csv") return t("workspace.unify.upload_csv_data_description");
return t("workspace.unify.configure_mapping");
};
const getNextStepButtonLabel = (type: TConnectorType | null, t: (key: string) => string): string => {
if (type === "formbricks") return t("workspace.unify.select_questions");
const getNextStepButtonLabel = (type: TConnectorOptionId | null, t: (key: string) => string): string => {
if (type === "formbricks_survey") return t("workspace.unify.select_questions");
if (type === "csv") return t("workspace.unify.configure_import");
if (type === "api_ingestion") return t("workspace.unify.api_ingestion_manage_api_keys");
if (type === "feedback_record_mcp") return t("common.learn_more");
return t("workspace.unify.create_mapping");
};
const getCreateDisabled = (
type: TConnectorType | null,
isFormbricksValid: boolean,
isCsvValid: boolean,
allRequiredMapped: boolean
): boolean => {
if (type === "formbricks") return !isFormbricksValid;
if (type === "csv") return !isCsvValid || !allRequiredMapped;
return !allRequiredMapped;
};
const ZFormbricksConnectorForm = z.object({
sourceName: z.string().trim().min(1),
surveyId: z.string().min(1),
selectedQuestionIds: z.array(z.string()).min(1),
importHistorical: z.boolean(),
});
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;
}
type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
const AggregateImportSection = ({
surveyEntries,
onImportHistoricalChange,
t,
}: 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("workspace.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("workspace.unify.total_feedback_records", {
checked: checkedTotal,
total: totalRecords,
surveyCount: checkedCount,
})}
</p>
)}
</div>
);
};
const getSelectableQuestionIds = (survey: TUnifySurvey): string[] =>
survey.elements
.filter((element) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(element.type))
.map((element) => element.id);
export const CreateConnectorModal = ({
open,
@@ -164,34 +122,53 @@ export const CreateConnectorModal = ({
directories,
}: CreateConnectorModalProps) => {
const { t } = useTranslation();
const router = useRouter();
const defaultConnectorName = useMemo<Record<TConnectorType, string>>(
() => ({
formbricks_survey: t("workspace.unify.default_connector_name_formbricks"),
csv: t("workspace.unify.default_connector_name_csv"),
}),
[t]
);
const formbricksForm = useForm<TFormbricksConnectorForm>({
resolver: zodResolver(ZFormbricksConnectorForm),
defaultValues: {
sourceName: defaultConnectorName.formbricks_survey,
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
},
mode: "onChange",
});
const defaultConnectorName: Record<TConnectorType, string> = {
formbricks: t("workspace.unify.default_connector_name_formbricks"),
csv: t("workspace.unify.default_connector_name_csv"),
};
const [currentStep, setCurrentStep] = useState<TCreateConnectorStep>("selectType");
const [selectedType, setSelectedType] = useState<TConnectorType | null>(null);
const [connectorName, setConnectorName] = useState("");
const [selectedType, setSelectedType] = useState<TConnectorOptionId | null>(null);
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
const [csvParsedData, setCsvParsedData] = useState<Record<string, string>[]>([]);
const [enumValidationErrors, setEnumValidationErrors] = useState<TEnumValidationError[]>([]);
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
const [csvConnectorName, setCsvConnectorName] = useState("");
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 [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(
directories.length === 1 ? directories[0].id : null
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories[0]?.id ?? null);
const formbricksValues = formbricksForm.watch();
const selectedSurveyId = formbricksValues.surveyId;
const selectedQuestionIds = formbricksValues.selectedQuestionIds ?? [];
const selectedSurvey = useMemo(
() => surveys.find((survey) => survey.id === selectedSurveyId) ?? null,
[surveys, selectedSurveyId]
);
const selectedSurveyResponseCount =
selectedSurveyId && responseCountBySurvey[selectedSurveyId] !== undefined
? responseCountBySurvey[selectedSurveyId]
: null;
const fetchResponseCount = useCallback(
async (surveyId: string) => {
if (responseCountBySurvey[surveyId] !== undefined) return;
@@ -204,30 +181,50 @@ export const CreateConnectorModal = ({
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: null }));
}
},
[workspaceId, responseCountBySurvey]
[responseCountBySurvey, workspaceId]
);
useEffect(() => {
if (selectedSurveyId && selectedType === "formbricks") {
if (selectedSurveyId && currentStep === "mapping" && selectedType === "formbricks_survey") {
fetchResponseCount(selectedSurveyId);
}
}, [selectedSurveyId, selectedType, fetchResponseCount]);
}, [currentStep, fetchResponseCount, selectedSurveyId, selectedType]);
useEffect(() => {
if (currentStep !== "mapping" || selectedType !== "formbricks_survey" || !selectedSurveyId) {
return;
}
const survey = surveys.find((item) => item.id === selectedSurveyId);
const supportedElementIds = survey ? getSelectableQuestionIds(survey) : [];
formbricksForm.setValue("selectedQuestionIds", supportedElementIds, {
shouldDirty: true,
shouldValidate: true,
});
formbricksForm.setValue("importHistorical", true, {
shouldDirty: true,
});
}, [currentStep, formbricksForm, selectedSurveyId, selectedType, surveys]);
const resetForm = () => {
setCurrentStep("selectType");
setSelectedType(null);
setConnectorName("");
formbricksForm.reset({
sourceName: defaultConnectorName.formbricks_survey,
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
setMappings([]);
setSourceFields([]);
setCsvParsedData([]);
setEnumValidationErrors([]);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
setResponseCountBySurvey({});
setImportHistoricalBySurvey({});
setCsvConnectorName("");
setIsImporting(false);
setIsCreating(false);
setSelectedDirectoryId(directories.length === 1 ? directories[0].id : null);
setSelectedDirectoryId(directories[0]?.id ?? null);
};
const handleOpenChange = (newOpen: boolean) => {
@@ -239,50 +236,31 @@ export const CreateConnectorModal = ({
const handleNextStep = () => {
if (currentStep !== "selectType" || !selectedType) return;
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
setConnectorName(
selectedType === "formbricks" && selectedSurvey
? `${selectedSurvey.name} ${t("workspace.unify.connection")}`
: defaultConnectorName[selectedType]
);
setCurrentStep("mapping");
};
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
};
const handleElementToggle = (elementId: string) => {
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) {
setElementIdsBySurvey((prev) => ({
...prev,
[surveyId]: survey.elements
.filter((e) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(e.type))
.map((e) => e.id),
}));
if (selectedType === "api_ingestion") {
handleOpenChange(false);
router.push(`/workspaces/${workspaceId}/settings/api-keys`);
return;
}
};
const handleDeselectAllElements = () => {
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => ({
...prev,
[selectedSurveyId]: [],
}));
if (selectedType === "feedback_record_mcp") {
window.open("https://formbricks.com/docs", "_blank", "noopener,noreferrer");
return;
}
if (selectedType === "formbricks_survey") {
formbricksForm.reset({
sourceName: defaultConnectorName.formbricks_survey,
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
}
if (selectedType === "csv") {
setCsvConnectorName(defaultConnectorName.csv);
}
setCurrentStep("mapping");
};
const handleBack = () => {
@@ -290,52 +268,31 @@ export const CreateConnectorModal = ({
setCurrentStep("selectType");
setMappings([]);
setSourceFields([]);
setEnumValidationErrors([]);
}
};
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;
const handleHistoricalImport = async (connectorId: string, surveyId: string) => {
const responseCount = responseCountBySurvey[surveyId] ?? 0;
if (responseCount <= 0) return;
setIsImporting(true);
let totalSuccesses = 0;
let totalFailures = 0;
let totalSkipped = 0;
for (const surveyId of surveysToImport) {
const importResult = await importHistoricalResponsesAction({
connectorId,
workspaceId,
surveyId,
});
if (importResult?.data) {
totalSuccesses += importResult.data.successes;
totalFailures += importResult.data.failures;
totalSkipped += importResult.data.skipped;
} else {
toast.error(getFormattedErrorMessage(importResult));
}
}
const importResult = await importHistoricalResponsesAction({
connectorId,
workspaceId,
surveyId,
});
setIsImporting(false);
if (totalSuccesses > 0 || totalFailures > 0) {
if (importResult?.data) {
toast.success(
t("workspace.unify.historical_import_complete", {
successes: totalSuccesses,
failures: totalFailures,
skipped: totalSkipped,
successes: importResult.data.successes,
failures: importResult.data.failures,
skipped: importResult.data.skipped,
})
);
} else {
toast.error(getFormattedErrorMessage(importResult));
}
};
@@ -361,10 +318,41 @@ export const CreateConnectorModal = ({
}
};
const handleCreate = async () => {
if (!selectedType || !connectorName.trim() || !selectedDirectoryId) return;
const handleFormbricksQuestionToggle = (questionId: string) => {
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
const isSelected = currentSelection.includes(questionId);
const nextSelection = isSelected
? currentSelection.filter((id) => id !== questionId)
: [...currentSelection, questionId];
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
shouldDirty: true,
shouldValidate: true,
});
};
if (selectedType === "csv" && csvParsedData.length > 0) {
const handleCreateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
if (!selectedDirectoryId) return;
setIsCreating(true);
const connectorId = await onCreateConnector({
name: values.sourceName.trim(),
type: "formbricks_survey",
feedbackRecordDirectoryId: selectedDirectoryId,
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
});
if (connectorId && values.importHistorical) {
await handleHistoricalImport(connectorId, values.surveyId);
}
setIsCreating(false);
resetForm();
onOpenChange(false);
};
const handleCreateCsvConnector = async () => {
if (!selectedDirectoryId || !isConnectorNameValid(csvConnectorName)) return;
if (csvParsedData.length > 0) {
const errors = validateEnumMappings(mappings, csvParsedData);
if (errors.length > 0) {
setEnumValidationErrors(errors);
@@ -375,21 +363,14 @@ export const CreateConnectorModal = ({
setIsCreating(true);
const surveyMappings = getSurveyMappings();
const connectorId = await onCreateConnector({
name: connectorName.trim(),
type: selectedType,
name: csvConnectorName.trim(),
type: "csv",
feedbackRecordDirectoryId: selectedDirectoryId,
surveyMappings: selectedType === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
fieldMappings: selectedType !== "formbricks" && mappings.length > 0 ? mappings : undefined,
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
if (connectorId && selectedType === "formbricks") {
await handleHistoricalImports(connectorId);
}
if (connectorId && selectedType === "csv" && csvParsedData.length > 0) {
if (connectorId && csvParsedData.length > 0) {
await handleCsvImport(connectorId);
}
@@ -398,14 +379,8 @@ export const CreateConnectorModal = ({
onOpenChange(false);
};
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const allRequiredMapped = requiredFields.every((field) =>
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
);
const hasAnyElementSelections = Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
const isFormbricksValid = selectedType === "formbricks" && hasAnyElementSelections;
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
const areCsvRequiredFieldsMapped = areAllRequiredFieldsMapped(mappings);
const handleLoadSourceFields = () => {
if (selectedType === "csv") {
@@ -444,86 +419,118 @@ export const CreateConnectorModal = ({
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
)}
{currentStep === "mapping" && selectedType === "formbricks" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
<Input
id="connectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
<Input
value={field.value}
onChange={field.onChange}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<FrdPicker
directories={directories}
selectedDirectoryId={selectedDirectoryId}
onChange={setSelectedDirectoryId}
workspaceId={workspaceId}
t={t}
/>
{directories.length === 0 && (
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
)}
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<FormbricksSurveySelector
surveys={surveys}
selectedSurveyId={selectedSurveyId}
selectedElementIds={selectedElementIds}
onSurveySelect={handleSurveySelect}
onElementToggle={handleElementToggle}
onSelectAllElements={handleSelectAllElements}
onDeselectAllElements={handleDeselectAllElements}
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t("workspace.unify.select_survey")} />
</SelectTrigger>
<SelectContent>
{surveys.map((survey) => (
<SelectItem key={survey.id} value={survey.id}>
{survey.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
{(() => {
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);
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
<div>
<FormbricksQuestionList
survey={selectedSurvey}
selectedQuestionIds={selectedQuestionIds}
onQuestionToggle={handleFormbricksQuestionToggle}
/>
</div>
</FormControl>
<FormError />
</FormItem>
)}
/>
if (entries.length === 0) return null;
return (
<AggregateImportSection
surveyEntries={entries}
onImportHistoricalChange={(surveyId, checked) => {
setImportHistoricalBySurvey((prev) => ({ ...prev, [surveyId]: checked }));
}}
t={t}
{selectedSurveyResponseCount !== null && selectedSurveyResponseCount > 0 && (
<FormField
control={formbricksForm.control}
name="importHistorical"
render={({ field }) => (
<FormItem className="rounded-md border border-slate-200 p-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<FormLabel>{t("workspace.unify.import_historical_responses")}</FormLabel>
<p className="text-sm text-slate-500">
{t("workspace.unify.import_historical_responses_description")}
</p>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormItem>
)}
/>
);
})()}
</div>
)}
</form>
</FormProvider>
)}
{currentStep === "mapping" && selectedType === "csv" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
<label htmlFor="connectorName" className="text-sm font-medium text-slate-700">
{t("workspace.unify.source_name")}
</label>
<Input
id="connectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
value={csvConnectorName}
onChange={(event) => setCsvConnectorName(event.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</div>
<FrdPicker
directories={directories}
selectedDirectoryId={selectedDirectoryId}
onChange={setSelectedDirectoryId}
workspaceId={workspaceId}
t={t}
/>
{directories.length === 0 && (
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
)}
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
<CsvConnectorUI
sourceFields={sourceFields}
mappings={mappings}
@@ -582,13 +589,20 @@ export const CreateConnectorModal = ({
</Button>
) : (
<Button
onClick={handleCreate}
onClick={
selectedType === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleCreateFormbricksConnector)()
: handleCreateCsvConnector
}
disabled={
isCreating ||
isImporting ||
!connectorName.trim() ||
!selectedDirectoryId ||
getCreateDisabled(selectedType, !!isFormbricksValid, isCsvValid, allRequiredMapped)
(selectedType === "formbricks_survey"
? !isConnectorNameValid(formbricksValues.sourceName ?? "") ||
!formbricksValues.surveyId ||
!formbricksValues.selectedQuestionIds?.length
: !isConnectorNameValid(csvConnectorName) || !isCsvValid || !areCsvRequiredFieldsMapped)
}>
{isCreating && <Loader2Icon className="mr-2 h-4 w-4 animate-spin" />}
{t("workspace.unify.setup_connection")}
@@ -601,52 +615,22 @@ export const CreateConnectorModal = ({
);
};
interface FrdPickerProps {
directories: { id: string; name: string }[];
selectedDirectoryId: string | null;
onChange: (id: string) => void;
interface NoFeedbackRecordDirectoryAlertProps {
workspaceId: string;
t: (key: string) => string;
}
const FrdPicker = ({ directories, selectedDirectoryId, onChange, workspaceId, t }: FrdPickerProps) => {
if (directories.length === 0) {
return (
<Alert variant="error" size="small">
<div>
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
<a
className="mt-1 inline-block font-medium underline"
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.unify.go_to_feedback_record_directories")}
</a>
</div>
</Alert>
);
}
if (directories.length === 1) {
return (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
{t("workspace.unify.records_will_go_to")}{" "}
<span className="font-medium text-slate-900">{directories[0].name}</span>
</div>
);
}
const NoFeedbackRecordDirectoryAlert = ({ workspaceId, t }: NoFeedbackRecordDirectoryAlertProps) => {
return (
<div className="space-y-2">
<Label htmlFor="feedbackRecordDirectory">{t("workspace.unify.feedback_record_directory")}</Label>
<Select value={selectedDirectoryId ?? ""} onValueChange={onChange}>
<SelectTrigger id="feedbackRecordDirectory">
<SelectValue placeholder={t("workspace.unify.select_feedback_record_directory")} />
</SelectTrigger>
<SelectContent>
{directories.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Alert variant="error" size="small">
<div>
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
<a
className="mt-1 inline-block font-medium underline"
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.unify.go_to_feedback_record_directories")}
</a>
</div>
</Alert>
);
};
@@ -1,9 +1,11 @@
"use client";
import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
import { z } from "zod";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -13,17 +15,27 @@ import {
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
FEEDBACK_RECORD_FIELDS,
SAMPLE_CSV_COLUMNS,
TFieldMapping,
TSourceField,
TUnifySurvey,
} from "../types";
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
import { parseCSVColumnsToFields } from "../utils";
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
import { FormbricksQuestionList } from "./formbricks-question-list";
import { MappingUI } from "./mapping-ui";
interface EditConnectorModalProps {
@@ -38,42 +50,17 @@ interface EditConnectorModalProps {
fieldMappings?: TFieldMapping[];
}) => Promise<void>;
surveys: TUnifySurvey[];
directories: { id: string; name: string }[];
onOpenCsvImport?: () => void;
}
const getConnectorIcon = (type: TConnectorType) => {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-5 w-5 text-slate-500" />;
default:
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
}
};
const ZFormbricksEditConnectorForm = z.object({
sourceName: z.string().trim().min(1),
surveyId: z.string().min(1),
selectedQuestionIds: z.array(z.string()).min(1),
importHistorical: z.boolean(),
});
const getConnectorTypeLabelKey = (type: TConnectorType): string => {
switch (type) {
case "formbricks":
return "workspace.unify.formbricks_surveys";
case "csv":
return "workspace.unify.csv_import";
default:
return type;
}
};
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;
};
type TFormbricksEditConnectorForm = z.infer<typeof ZFormbricksEditConnectorForm>;
export const EditConnectorModal = ({
connector,
@@ -81,35 +68,52 @@ export const EditConnectorModal = ({
onOpenChange,
onUpdateConnector,
surveys,
directories,
onOpenCsvImport,
}: EditConnectorModalProps) => {
const { t } = useTranslation();
const [connectorName, setConnectorName] = useState("");
const [csvConnectorName, setCsvConnectorName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [isUpdating, setIsUpdating] = useState(false);
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
const formbricksForm = useForm<TFormbricksEditConnectorForm>({
resolver: zodResolver(ZFormbricksEditConnectorForm),
defaultValues: {
sourceName: "",
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
},
mode: "onChange",
});
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const allRequiredMapped = requiredFields.every((field) =>
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
const formbricksValues = formbricksForm.watch();
const selectedSurveyId = formbricksValues.surveyId;
const selectedQuestionIds = formbricksValues.selectedQuestionIds ?? [];
const selectedSurvey = useMemo(
() => surveys.find((survey) => survey.id === selectedSurveyId) ?? null,
[surveys, selectedSurveyId]
);
useEffect(() => {
if (connector) {
setConnectorName(connector.name);
if (connector.type === "formbricks_survey") {
const mappedSurveyId = connector.formbricksMappings[0]?.surveyId ?? "";
const mappedQuestionIds = connector.formbricksMappings
.filter((mapping) => mapping.surveyId === mappedSurveyId)
.map((mapping) => mapping.elementId);
if (connector.type === "formbricks") {
const fbMappings = connector.formbricksMappings;
setSelectedSurveyId(fbMappings.length > 0 ? fbMappings[0].surveyId : null);
setElementIdsBySurvey(groupMappingsBySurvey(fbMappings));
formbricksForm.reset({
sourceName: connector.name,
surveyId: mappedSurveyId,
selectedQuestionIds: mappedQuestionIds,
importHistorical: true,
});
setCsvConnectorName("");
setSourceFields([]);
setMappings([]);
} else if (connector.type === "csv") {
setCsvConnectorName(connector.name);
const columnsFromMappings = [
...new Set(connector.fieldMappings.map((m) => m.sourceFieldId).filter(Boolean)),
];
@@ -125,23 +129,37 @@ export const EditConnectorModal = ({
staticValue: m.staticValue ?? undefined,
}))
);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
formbricksForm.reset({
sourceName: "",
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
} else {
setCsvConnectorName("");
setSourceFields([]);
setMappings([]);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
formbricksForm.reset({
sourceName: "",
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
}
}
}, [connector]);
}, [connector, formbricksForm]);
const resetForm = () => {
setConnectorName("");
setCsvConnectorName("");
setMappings([]);
setSourceFields([]);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
formbricksForm.reset({
sourceName: "",
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
setIsUpdating(false);
};
const handleOpenChange = (newOpen: boolean) => {
@@ -151,76 +169,64 @@ export const EditConnectorModal = ({
onOpenChange(newOpen);
};
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
};
const handleElementToggle = (elementId: string) => {
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) {
setElementIdsBySurvey((prev) => ({
...prev,
[surveyId]: survey.elements.map((e) => e.id),
}));
}
};
const handleDeselectAllElements = () => {
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 }));
const handleUpdateFormbricksConnector = async (values: TFormbricksEditConnectorForm) => {
if (connector?.type !== "formbricks_survey") return;
setIsUpdating(true);
await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: connectorName.trim(),
surveyMappings:
connector.type === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
fieldMappings: connector.type !== "formbricks" && mappings.length > 0 ? mappings : undefined,
name: values.sourceName.trim(),
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
fieldMappings: undefined,
});
setIsUpdating(false);
handleOpenChange(false);
};
const assignedDirectoryName =
directories.find((d) => d.id === connector?.feedbackRecordDirectoryId)?.name ??
connector?.feedbackRecordDirectoryId ??
"—";
const handleUpdateCsvConnector = async () => {
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
setIsUpdating(true);
await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: csvConnectorName.trim(),
surveyMappings: undefined,
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
setIsUpdating(false);
handleOpenChange(false);
};
const saveChangesDisbaled = useMemo(() => {
const handleFormbricksQuestionToggle = (questionId: string) => {
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
const isSelected = currentSelection.includes(questionId);
const nextSelection = isSelected
? currentSelection.filter((id) => id !== questionId)
: [...currentSelection, questionId];
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
shouldDirty: true,
shouldValidate: true,
});
};
const saveChangesDisabled = useMemo(() => {
if (!connector) return true;
if (!connectorName.trim()) return true;
if (isUpdating) return true;
if (connector.type === "formbricks") {
return !Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
if (connector.type === "formbricks_survey") {
return (
!isConnectorNameValid(formbricksValues.sourceName ?? "") ||
!formbricksValues.surveyId ||
!formbricksValues.selectedQuestionIds?.length
);
}
if (connector.type === "csv") {
return !allRequiredMapped;
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredFieldsMapped(mappings);
}
}, [allRequiredMapped, connector, connectorName, elementIdsBySurvey]);
return true;
}, [connector, csvConnectorName, formbricksValues, isUpdating, mappings]);
if (!connector) return null;
@@ -233,53 +239,111 @@ export const EditConnectorModal = ({
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
{getConnectorIcon(connector.type)}
<div>
<p className="text-sm font-medium text-slate-900">
{t(getConnectorTypeLabelKey(connector.type))}
</p>
<p className="text-xs text-slate-500">{t("workspace.unify.source_type_cannot_be_changed")}</p>
</div>
</div>
{connector.type === "formbricks_survey" ? (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
<Input
value={field.value}
onChange={field.onChange}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<div className="space-y-2">
<Label htmlFor="editConnectorName">{t("workspace.unify.source_name")}</Label>
<Input
id="editConnectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</div>
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange} disabled>
<SelectTrigger>
<SelectValue placeholder={t("workspace.unify.select_survey")} />
</SelectTrigger>
<SelectContent>
{selectedSurvey && (
<SelectItem key={selectedSurvey.id} value={selectedSurvey.id}>
{selectedSurvey.name}
</SelectItem>
)}
{!selectedSurvey && field.value && (
<SelectItem value={field.value}>{field.value}</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormError />
</FormItem>
)}
/>
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
{t("workspace.unify.records_will_go_to")}{" "}
<span className="font-medium text-slate-900">{assignedDirectoryName}</span>
<p className="mt-1 text-xs text-slate-400">{t("workspace.unify.frd_cannot_be_changed")}</p>
</div>
{connector.type === "formbricks" ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<FormbricksSurveySelector
surveys={surveys}
selectedSurveyId={selectedSurveyId}
selectedElementIds={selectedElementIds}
onSurveySelect={handleSurveySelect}
onElementToggle={handleElementToggle}
onSelectAllElements={handleSelectAllElements}
onDeselectAllElements={handleDeselectAllElements}
/>
</div>
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
<div>
<FormbricksQuestionList
survey={selectedSurvey}
selectedQuestionIds={selectedQuestionIds}
onQuestionToggle={handleFormbricksQuestionToggle}
/>
</div>
</FormControl>
<FormError />
</FormItem>
)}
/>
</form>
</FormProvider>
) : (
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
connectorType={connector.type}
/>
</div>
<>
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
{getConnectorIcon(connector.type, "h-5 w-5 text-slate-500")}
<div>
<p className="text-sm font-medium text-slate-900">
{t(getConnectorTypeLabelKey(connector.type))}
</p>
<p className="text-xs text-slate-500">
{t("workspace.unify.source_type_cannot_be_changed")}
</p>
</div>
</div>
<div className="space-y-2">
<label htmlFor="editConnectorName" className="text-sm font-medium text-slate-700">
{t("workspace.unify.source_name")}
</label>
<Input
id="editConnectorName"
value={csvConnectorName}
onChange={(event) => setCsvConnectorName(event.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</div>
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
connectorType={connector.type}
/>
</div>
</>
)}
</div>
@@ -294,7 +358,13 @@ export const EditConnectorModal = ({
{t("workspace.unify.import_feedback")}
</Button>
)}
<Button onClick={handleUpdate} disabled={saveChangesDisbaled}>
<Button
onClick={
connector.type === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
: handleUpdateCsvConnector
}
disabled={saveChangesDisabled}>
{t("workspace.unify.save_changes")}
</Button>
</DialogFooter>
@@ -0,0 +1,80 @@
"use client";
import { useTranslation } from "react-i18next";
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { Label } from "@/modules/ui/components/label";
import { TUnifySurvey } from "../types";
interface FormbricksQuestionListProps {
survey: TUnifySurvey | null;
selectedQuestionIds: string[];
onQuestionToggle: (questionId: string) => void;
}
const isUnsupportedElementType = (type: string): boolean =>
(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(type);
export const FormbricksQuestionList = ({
survey,
selectedQuestionIds,
onQuestionToggle,
}: Readonly<FormbricksQuestionListProps>) => {
const { t } = useTranslation();
if (!survey) {
return (
<div className="rounded-md border border-dashed border-slate-300 p-3">
<p className="text-sm text-slate-500">{t("workspace.unify.select_a_survey_to_see_questions")}</p>
</div>
);
}
if (survey.elements.length === 0) {
return (
<div className="rounded-md border border-dashed border-slate-300 p-3">
<p className="text-sm text-slate-500">{t("workspace.unify.survey_has_no_questions")}</p>
</div>
);
}
return (
<div className="max-h-64 space-y-2 overflow-y-auto rounded-md border border-slate-200 p-3">
{survey.elements.map((element) => {
const unsupported = isUnsupportedElementType(element.type);
const isChecked = selectedQuestionIds.includes(element.id);
const elementTypeLabel = getTSurveyElementTypeEnumName(element.type, t) ?? element.type;
const inputId = `connector-question-${element.id}`;
return (
<div
key={element.id}
className={`flex items-start gap-3 rounded-md border border-slate-100 p-2 ${
unsupported ? "opacity-60" : ""
}`}>
<Checkbox
id={inputId}
checked={!unsupported && isChecked}
disabled={unsupported}
onCheckedChange={() => {
if (!unsupported) {
onQuestionToggle(element.id);
}
}}
/>
<div className="space-y-0.5">
<Label htmlFor={inputId} className={unsupported ? "cursor-not-allowed" : "cursor-pointer"}>
{element.headline}
</Label>
<p className="text-xs text-slate-500">{elementTypeLabel}</p>
{unsupported && (
<p className="text-xs text-slate-500">{t("workspace.unify.question_type_not_supported")}</p>
)}
</div>
</div>
);
})}
</div>
);
};
@@ -1,233 +0,0 @@
"use client";
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";
import { TUnifySurvey } from "../types";
interface FormbricksSurveySelectorProps {
surveys: TUnifySurvey[];
selectedSurveyId: string | null;
selectedElementIds: string[];
onSurveySelect: (surveyId: string | null) => void;
onElementToggle: (elementId: string) => void;
onSelectAllElements: (surveyId: string) => void;
onDeselectAllElements: () => void;
}
const getElementIcon = (type: TSurveyElementTypeEnum) => {
switch (type) {
case "openText":
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
case "rating":
case "nps":
return <StarIcon className="h-4 w-4 text-amber-500" />;
default:
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
}
};
const isUnsupportedType = (type: TSurveyElementTypeEnum): boolean => {
return UNSUPPORTED_CONNECTOR_ELEMENT_TYPES.includes(type);
};
export const FormbricksSurveySelector = ({
surveys,
selectedSurveyId,
selectedElementIds,
onSurveySelect,
onElementToggle,
onSelectAllElements,
onDeselectAllElements,
}: FormbricksSurveySelectorProps) => {
const { t } = useTranslation();
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
const supportedElements = selectedSurvey?.elements.filter((e) => !isUnsupportedType(e.type)) ?? [];
const allSupportedSelected =
supportedElements.length > 0 && supportedElements.every((e) => selectedElementIds.includes(e.id));
const handleSurveyClick = (survey: TUnifySurvey) => {
if (selectedSurveyId !== survey.id) {
onSurveySelect(survey.id);
}
};
const handleSelectAllSupported = (surveyId: string) => {
onSelectAllElements(surveyId);
};
const getStatusBadge = (status: TUnifySurvey["status"]) => {
switch (status) {
case "active":
return <Badge text={t("workspace.unify.status_active")} type="success" size="tiny" />;
case "paused":
return <Badge text={t("workspace.unify.status_paused")} type="warning" size="tiny" />;
case "draft":
return <Badge text={t("workspace.unify.status_draft")} type="gray" size="tiny" />;
case "completed":
return <Badge text={t("workspace.unify.status_completed")} type="gray" size="tiny" />;
default:
return null;
}
};
const getSupportedElementCount = (survey: TUnifySurvey) =>
survey.elements.filter((e) => !isUnsupportedType(e.type)).length;
const getElementButtonClassName = (unsupported: boolean, isSelected: boolean): string => {
if (unsupported) return "cursor-not-allowed border-slate-100 bg-slate-50 opacity-50";
if (isSelected) return "border-green-300 bg-green-50";
return "border-slate-200 bg-white hover:border-slate-300";
};
const getCheckboxClassName = (unsupported: boolean, isSelected: boolean): string => {
if (unsupported) return "border border-slate-200 bg-slate-100";
if (isSelected) return "bg-green-500 text-white";
return "border border-slate-300 bg-white";
};
const renderElementPanel = () => {
if (!selectedSurvey) {
return (
<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("workspace.unify.select_a_survey_to_see_questions")}</p>
</div>
);
}
if (selectedSurvey.elements.length === 0) {
return (
<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("workspace.unify.survey_has_no_questions")}</p>
</div>
);
}
return (
<div className="space-y-2 overflow-y-auto pr-1">
<TooltipProvider delayDuration={200}>
{selectedSurvey.elements.map((element) => {
const isSelected = selectedElementIds.includes(element.id);
const unsupported = isUnsupportedType(element.type);
const button = (
<button
key={element.id}
type="button"
disabled={unsupported}
onClick={() => onElementToggle(element.id)}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${getElementButtonClassName(unsupported, isSelected)}`}>
<div
className={`flex h-5 w-5 items-center justify-center rounded ${getCheckboxClassName(unsupported, isSelected)}`}>
{isSelected && !unsupported && <CheckIcon className="h-3 w-3" />}
</div>
<div className="flex items-center gap-2">{getElementIcon(element.type)}</div>
<div className="flex-1">
<p className={`text-sm ${unsupported ? "text-slate-400" : "text-slate-900"}`}>
{element.headline}
</p>
<span className={`text-xs ${unsupported ? "text-slate-300" : "text-slate-500"}`}>
{getTSurveyElementTypeEnumName(element.type, t) ?? element.type}
</span>
</div>
</button>
);
if (unsupported) {
return (
<Tooltip key={element.id}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{t("workspace.unify.question_type_not_supported")}</TooltipContent>
</Tooltip>
);
}
return button;
})}
</TooltipProvider>
{selectedElementIds.length > 0 && (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
<p className="text-xs text-blue-700">
<Trans
i18nKey={
selectedElementIds.length === 1
? "workspace.unify.question_selected"
: "workspace.unify.questions_selected"
}
values={{ count: selectedElementIds.length }}
components={{ strong: <strong /> }}
/>
</p>
</div>
)}
</div>
);
};
return (
<div className="grid h-[50vh] grid-cols-2 gap-6">
{/* Left: Survey List */}
<div className="flex flex-col gap-3 overflow-hidden">
<h4 className="shrink-0 text-sm font-medium text-slate-700">{t("workspace.unify.select_survey")}</h4>
<div className="space-y-2 overflow-y-auto pr-1">
{surveys.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("workspace.unify.no_surveys_found")}</p>
</div>
) : (
surveys.map((survey) => {
const isSelected = selectedSurveyId === survey.id;
return (
<div key={survey.id}>
<button
type="button"
onClick={() => handleSurveyClick(survey)}
className={`flex w-full items-center gap-3 rounded-lg border bg-white p-3 text-left transition-colors ${
isSelected ? "border-brand-dark bg-slate-50" : "border-slate-200 hover:border-slate-300"
}`}>
<div className="min-w-0 flex-1 space-y-1">
<div>{getStatusBadge(survey.status)}</div>
<span className="block truncate text-sm font-medium text-slate-900">{survey.name}</span>
<p className="text-xs text-slate-500">
{t("workspace.unify.n_supported_questions", {
count: getSupportedElementCount(survey),
})}
</p>
</div>
{isSelected && <ChevronRightIcon className="h-5 w-5 shrink-0 text-brand-dark" />}
</button>
</div>
);
})
)}
</div>
</div>
{/* 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("workspace.unify.select_questions")}</h4>
{selectedSurvey && supportedElements.length > 0 && (
<button
type="button"
onClick={() =>
allSupportedSelected ? onDeselectAllElements() : handleSelectAllSupported(selectedSurvey.id)
}
className="text-xs text-slate-500 hover:text-slate-700">
{allSupportedSelected ? t("workspace.unify.deselect_all") : t("workspace.unify.select_all")}
</button>
)}
</div>
{renderElementPanel()}
</div>
</div>
);
};
@@ -1,42 +1,6 @@
import { notFound } from "next/navigation";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getSurveys } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { ConnectorsSection } from "./components/connectors-page-client";
import { transformToUnifySurvey } from "./lib";
import { redirect } from "next/navigation";
export default async function UnifySourcesPage(props: { params: Promise<{ workspaceId: string }> }) {
const t = await getTranslate();
const params = await props.params;
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
await getWorkspaceAuth(params.workspaceId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
if (!hasAccess) {
return notFound();
}
const [connectors, surveys, directories] = await Promise.all([
getConnectorsWithMappings(params.workspaceId),
getSurveys(params.workspaceId),
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
]);
const unifySurveys = surveys.map(transformToUnifySurvey);
return (
<ConnectorsSection
workspaceId={params.workspaceId}
initialConnectors={connectors}
initialSurveys={unifySurveys}
directories={directories}
/>
);
redirect(`/workspaces/${params.workspaceId}/feedback-sources`);
}
@@ -5,11 +5,13 @@ import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } from ".
const mockT = (key: string) => key;
describe("getConnectorOptions", () => {
test("returns formbricks and csv options", () => {
test("returns formbricks, csv, api ingestion, and mcp options", () => {
const options = getConnectorOptions(mockT as never);
expect(options).toHaveLength(2);
expect(options[0].id).toBe("formbricks");
expect(options).toHaveLength(4);
expect(options[0].id).toBe("formbricks_survey");
expect(options[1].id).toBe("csv");
expect(options[2].id).toBe("api_ingestion");
expect(options[3].id).toBe("feedback_record_mcp");
});
test("both options are enabled by default", () => {
@@ -23,6 +25,10 @@ describe("getConnectorOptions", () => {
expect(options[0].description).toBe("workspace.unify.source_connect_formbricks_description");
expect(options[1].name).toBe("workspace.unify.csv_import");
expect(options[1].description).toBe("workspace.unify.source_connect_csv_description");
expect(options[2].name).toBe("workspace.unify.api_ingestion");
expect(options[2].description).toBe("workspace.unify.api_ingestion_settings_description");
expect(options[3].name).toBe("workspace.unify.feedback_record_mcp");
expect(options[3].description).toBe("workspace.unify.source_connect_feedback_record_mcp_description");
});
});
@@ -1,9 +1,11 @@
import { TFunction } from "i18next";
import { THubFieldType } from "@formbricks/types/connector";
import { TConnectorType, THubFieldType } from "@formbricks/types/connector";
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
export type TConnectorOptionId = TConnectorType | "api_ingestion" | "feedback_record_mcp";
export interface TConnectorOption {
id: string;
id: TConnectorOptionId;
name: string;
description: string;
disabled: boolean;
@@ -12,7 +14,7 @@ export interface TConnectorOption {
export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
{
id: "formbricks",
id: "formbricks_survey",
name: t("workspace.unify.formbricks_surveys"),
description: t("workspace.unify.source_connect_formbricks_description"),
disabled: false,
@@ -23,6 +25,18 @@ export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
description: t("workspace.unify.source_connect_csv_description"),
disabled: false,
},
{
id: "api_ingestion",
name: t("workspace.unify.api_ingestion"),
description: t("workspace.unify.api_ingestion_settings_description"),
disabled: false,
},
{
id: "feedback_record_mcp",
name: t("workspace.unify.feedback_record_mcp"),
description: t("workspace.unify.source_connect_feedback_record_mcp_description"),
disabled: false,
},
];
export const parseCSVColumnsToFields = (columns: string): TSourceField[] => {
+4 -4
View File
@@ -108,7 +108,7 @@ const resolveFormbricksMappingsInput = async (
const allMappings = await Promise.all(
entries.map(({ surveyId, elementIds }) => resolveSurveyMappings(surveyId, elementIds))
);
return { type: "formbricks", mappings: allMappings.flat() };
return { type: "formbricks_survey", mappings: allMappings.flat() };
};
const ZFormbricksSurveyMapping = z.object({
@@ -124,7 +124,7 @@ const ZCreateConnectorWithMappingsAction = z
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
})
.superRefine((data, ctx) => {
if (data.connectorInput.type === "formbricks") {
if (data.connectorInput.type === "formbricks_survey") {
if (!data.formbricksMappings?.length) {
ctx.addIssue({
code: "custom",
@@ -298,9 +298,9 @@ export const duplicateConnectorAction = authenticatedActionClient
let mappingsInput: TMappingsInput | undefined;
if (source.type === "formbricks" && source.formbricksMappings.length > 0) {
if (source.type === "formbricks_survey" && source.formbricksMappings.length > 0) {
mappingsInput = {
type: "formbricks",
type: "formbricks_survey",
mappings: source.formbricksMappings.map((m) => ({
surveyId: m.surveyId,
elementId: m.elementId,
+1 -1
View File
@@ -57,7 +57,7 @@ describe("importCsvData", () => {
});
test("throws InvalidInputError for non-csv connector", async () => {
const connector = makeConnector({ type: "formbricks" });
const connector = makeConnector({ type: "formbricks_survey" });
await expect(importCsvData(connector, [])).rejects.toThrow(InvalidInputError);
});
+1 -1
View File
@@ -30,7 +30,7 @@ const mockConnector: TConnectorWithMappings = {
createdAt: NOW,
updatedAt: NOW,
name: "Test Connector",
type: "formbricks",
type: "formbricks_survey",
status: "active",
workspaceId: ENV_ID,
lastSyncAt: null,
+1 -1
View File
@@ -37,7 +37,7 @@ export const importHistoricalResponses = async (
connector: TConnectorWithMappings,
survey: TSurvey
): Promise<TImportResult> => {
if (connector.type !== "formbricks") {
if (connector.type !== "formbricks_survey") {
throw new InvalidInputError("Historical import is only supported for Formbricks connectors");
}
@@ -53,7 +53,7 @@ function createConnector(
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Connector",
type: "formbricks",
type: "formbricks_survey",
status: "active",
workspaceId: "env-1",
feedbackRecordDirectoryId: "frd-1",
@@ -79,7 +79,7 @@ const oneFeedbackRecord = [
{
field_id: "el-1",
field_type: "rating" as const,
source_type: "formbricks",
source_type: "formbricks_survey",
source_id: "survey-1",
source_name: "Test Survey",
field_label: "Question?",
+13 -8
View File
@@ -47,7 +47,7 @@ const mockConnector = {
createdAt: NOW,
updatedAt: NOW,
name: "Test Connector",
type: "formbricks" as const,
type: "formbricks_survey" as const,
status: "active" as const,
workspaceId: ENV_ID,
lastSyncAt: null,
@@ -144,7 +144,7 @@ describe("getConnectorsBySurveyId", () => {
expect(prisma.connector.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
type: "formbricks",
type: "formbricks_survey",
status: "active",
formbricksMappings: { some: { surveyId: SURVEY_ID } },
},
@@ -303,13 +303,18 @@ describe("createConnectorWithMappings", () => {
const result = await createConnectorWithMappings(ENV_ID, {
name: "New",
type: "formbricks",
type: "formbricks_survey",
feedbackRecordDirectoryId: FRD_ID,
});
expect(tx.connector.create).toHaveBeenCalledWith(
expect.objectContaining({
data: { name: "New", type: "formbricks", workspaceId: ENV_ID, feedbackRecordDirectoryId: FRD_ID },
data: {
name: "New",
type: "formbricks_survey",
workspaceId: ENV_ID,
feedbackRecordDirectoryId: FRD_ID,
},
})
);
expect(tx.connectorFormbricksMapping.create).not.toHaveBeenCalled();
@@ -325,9 +330,9 @@ describe("createConnectorWithMappings", () => {
await createConnectorWithMappings(
ENV_ID,
{ name: "FB", type: "formbricks", feedbackRecordDirectoryId: FRD_ID },
{ name: "FB", type: "formbricks_survey", feedbackRecordDirectoryId: FRD_ID },
{
type: "formbricks",
type: "formbricks_survey",
mappings: [
{ surveyId: SURVEY_ID, elementId: "el-1", hubFieldType: "text" },
{ surveyId: SURVEY_ID, elementId: "el-2", hubFieldType: "nps" },
@@ -392,7 +397,7 @@ describe("createConnectorWithMappings", () => {
await expect(
createConnectorWithMappings(ENV_ID, {
name: "Dup",
type: "formbricks",
type: "formbricks_survey",
feedbackRecordDirectoryId: FRD_ID,
})
).rejects.toThrow(InvalidInputError);
@@ -470,7 +475,7 @@ describe("updateConnectorWithMappings", () => {
ENV_ID,
{ name: "Updated" },
{
type: "formbricks",
type: "formbricks_survey",
mappings: [{ surveyId: SURVEY_ID, elementId: "el-new", hubFieldType: "nps" }],
}
);
+4 -4
View File
@@ -132,7 +132,7 @@ export const getConnectorsBySurveyId = reactCache(
try {
const connectors = await prisma.connector.findMany({
where: {
type: "formbricks",
type: "formbricks_survey",
status: "active",
formbricksMappings: {
some: {
@@ -213,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
export type TFormbricksMappingsInput = {
type: "formbricks";
type: "formbricks_survey";
mappings: TConnectorFormbricksMappingCreateInput[];
};
@@ -243,7 +243,7 @@ export const createConnectorWithMappings = async (
},
});
if (mappingsInput?.type === "formbricks") {
if (mappingsInput?.type === "formbricks_survey") {
await Promise.all(
mappingsInput.mappings.map((mapping) =>
tx.connectorFormbricksMapping.create({
@@ -311,7 +311,7 @@ export const updateConnectorWithMappings = async (
},
});
if (mappingsInput?.type === "formbricks") {
if (mappingsInput?.type === "formbricks_survey") {
await tx.connectorFormbricksMapping.deleteMany({
where: { connectorId, workspaceId },
});
+19 -1
View File
@@ -123,7 +123,7 @@ describe("transformResponseToFeedbackRecords", () => {
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
source_type: "formbricks",
source_type: "formbricks_survey",
field_id: "el-text",
field_type: "text",
field_label: "How can we improve?",
@@ -185,6 +185,24 @@ describe("transformResponseToFeedbackRecords", () => {
expect(result[0].collected_at).toBe(NOW.toISOString());
});
test("falls back to updatedAt when createdAt is missing", () => {
const updatedAt = new Date("2026-02-25T10:00:00.000Z");
const response = { ...mockResponse, createdAt: undefined, updatedAt } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].collected_at).toBe(updatedAt.toISOString());
});
test("parses string createdAt values for collected_at", () => {
const response = {
...mockResponse,
createdAt: "2026-02-26T10:00:00.000Z",
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].collected_at).toBe("2026-02-26T10:00:00.000Z");
});
test("includes tenant_id when provided", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, "tenant-abc");
+19 -3
View File
@@ -14,6 +14,23 @@ const getHeadlineFromElement = (element?: TSurveyElement): string => {
return getTextContent(raw) || "Untitled";
};
const toIsoTimestamp = (value: unknown): string | null => {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "string" || typeof value === "number") {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
return null;
};
const getCollectedAt = (response: TResponse): string => {
return toIsoTimestamp(response.createdAt) ?? toIsoTimestamp(response.updatedAt) ?? new Date().toISOString();
};
function extractResponseValue(responseData: TResponseData, elementId: string): TResponseDataValue {
if (!responseData || typeof responseData !== "object") return undefined;
return responseData[elementId];
@@ -99,9 +116,8 @@ export function transformResponseToFeedbackRecords(
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
const feedbackRecord = {
collected_at:
response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt),
source_type: "formbricks",
collected_at: getCollectedAt(response),
source_type: "formbricks_survey",
submission_id: response.id,
tenant_id: tenantId,
field_id: mapping.elementId,
+3 -1
View File
@@ -125,6 +125,7 @@
"activity": "Activity",
"add": "Add",
"add_action": "Add action",
"add_chart": "Add chart",
"add_charts": "Add charts",
"add_existing_chart_description": "Search and select charts to add to this dashboard.",
"add_filter": "Add filter",
@@ -1786,6 +1787,7 @@
"create_dashboard": "Create dashboard",
"create_dashboard_description": "Enter a name for your new dashboard.",
"create_failed": "Failed to create dashboard",
"create_new_chart": "Create new chart",
"create_success": "Dashboard created successfully!",
"dashboard": "Dashboard",
"dashboard_delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
@@ -1800,7 +1802,7 @@
"duplicate_failed": "Failed to duplicate dashboard",
"duplicate_success": "Dashboard duplicated successfully!",
"failed_to_load_chart_data": "Failed to load chart data",
"no_charts_available_description": "There are no charts that can be added to this dashboard. Either no charts exist yet, or all existing charts have already been added. Go to the Charts page to create new charts.",
"no_charts_available_description": "No more charts available to add. Create a new one.",
"no_charts_to_add_message": "No charts to add to this dashboard.",
"no_dashboards_found": "No dashboards found.",
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
@@ -40,7 +40,7 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
pageTitle={t("common.dashboards")}
workspaceId={workspaceId}
cta={
isReadOnly ? undefined : <CreateChartButton workspaceId={workspaceId} directories={directories} />
@@ -4,28 +4,40 @@ import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import { Button } from "@/modules/ui/components/button";
import { Button, type ButtonProps } from "@/modules/ui/components/button";
interface CreateChartButtonProps {
workspaceId: string;
directories: { id: string; name: string }[];
label?: string;
onSuccess?: () => void;
showIcon?: boolean;
buttonProps?: Omit<ButtonProps, "onClick" | "children">;
}
export function CreateChartButton({ workspaceId, directories }: Readonly<CreateChartButtonProps>) {
export function CreateChartButton({
workspaceId,
directories,
label,
onSuccess,
showIcon = true,
buttonProps,
}: Readonly<CreateChartButtonProps>) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { t } = useTranslation();
return (
<>
<Button size="sm" onClick={() => setIsDialogOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.charts.create_chart")}
<Button size="sm" onClick={() => setIsDialogOpen(true)} {...buttonProps}>
{showIcon && <PlusIcon className="mr-2 h-4 w-4" />}
{label ?? t("workspace.analysis.charts.create_chart")}
</Button>
<CreateChartDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
workspaceId={workspaceId}
directories={directories}
onSuccess={onSuccess}
/>
</>
);
@@ -1,11 +1,12 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getChartsAction } from "@/modules/ee/analysis/charts/actions";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import { addChartToDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -18,6 +19,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Label } from "@/modules/ui/components/label";
import { MultiSelect } from "@/modules/ui/components/multi-select";
interface AddExistingChartsDialogProps {
@@ -25,6 +27,7 @@ interface AddExistingChartsDialogProps {
onOpenChange: (open: boolean) => void;
workspaceId: string;
dashboardId: string;
directories: { id: string; name: string }[];
existingChartIds: string[];
onSuccess: () => void;
}
@@ -39,6 +42,7 @@ export function AddExistingChartsDialog({
onOpenChange,
workspaceId,
dashboardId,
directories,
existingChartIds,
onSuccess,
}: Readonly<AddExistingChartsDialogProps>) {
@@ -48,30 +52,29 @@ export function AddExistingChartsDialog({
const [isLoading, setIsLoading] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const loadCharts = useCallback(async () => {
setIsLoading(true);
setSelectedChartIds([]);
try {
const result = await getChartsAction({ workspaceId });
if (result?.data) {
const availableCharts = result.data.filter((chart) => !existingChartIds.includes(chart.id));
setChartOptions(availableCharts.map((chart) => ({ value: chart.id, label: chart.name })));
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch {
toast.error(t("workspace.analysis.dashboards.charts_load_failed"));
} finally {
setIsLoading(false);
}
}, [workspaceId, existingChartIds, t]);
useEffect(() => {
if (!open) return;
const loadCharts = async () => {
setIsLoading(true);
setSelectedChartIds([]);
try {
const result = await getChartsAction({ workspaceId });
if (result?.data) {
const availableCharts = result.data.filter((chart) => !existingChartIds.includes(chart.id));
setChartOptions(availableCharts.map((chart) => ({ value: chart.id, label: chart.name })));
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch {
toast.error(t("workspace.analysis.dashboards.charts_load_failed"));
} finally {
setIsLoading(false);
}
};
loadCharts();
}, [open, workspaceId, existingChartIds, t]);
}, [open, loadCharts]);
const handleAdd = async () => {
if (selectedChartIds.length === 0) return;
@@ -136,25 +139,40 @@ export function AddExistingChartsDialog({
</AlertDescription>
</Alert>
)}
<MultiSelect
options={chartOptions}
value={selectedChartIds}
onChange={setSelectedChartIds}
placeholder={t("common.search_charts")}
disabled={chartOptions.length === 0}
/>
<div className="space-y-2">
<Label>{t("common.add_chart")}</Label>
<MultiSelect
options={chartOptions}
value={selectedChartIds}
onChange={setSelectedChartIds}
placeholder={t("common.search_charts")}
disabled={chartOptions.length === 0}
/>
</div>
</>
)}
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
{t("common.cancel")}
</Button>
<Button onClick={handleAdd} loading={isAdding} disabled={selectedChartIds.length === 0 || isAdding}>
{selectedChartIds.length > 0
? t("workspace.analysis.dashboards.add_count_charts", { count: selectedChartIds.length })
: t("common.add")}
</Button>
<DialogFooter className="sm:justify-between">
<CreateChartButton
workspaceId={workspaceId}
directories={directories}
label={t("workspace.analysis.dashboards.create_new_chart")}
onSuccess={loadCharts}
buttonProps={{ variant: "outline", size: "default", disabled: isAdding }}
/>
<div className="flex flex-col-reverse gap-2 sm:flex-row">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
{t("common.cancel")}
</Button>
<Button
onClick={handleAdd}
loading={isAdding}
disabled={selectedChartIds.length === 0 || isAdding}>
{selectedChartIds.length > 0
? t("workspace.analysis.dashboards.add_count_charts", { count: selectedChartIds.length })
: t("common.add")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,6 +1,6 @@
"use client";
import { CheckIcon, PencilIcon, PlusIcon, RefreshCwIcon, TrashIcon, XIcon } from "lucide-react";
import { CheckIcon, PencilIcon, RefreshCwIcon, TrashIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
@@ -8,12 +8,14 @@ import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { AddExistingChartsDialog } from "@/modules/ee/analysis/dashboards/components/add-existing-charts-dialog";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { IconBar } from "@/modules/ui/components/iconbar";
interface DashboardControlBarProps {
workspaceId: string;
dashboardId: string;
directories: { id: string; name: string }[];
existingChartIds: string[];
isEditing: boolean;
isSaving: boolean;
@@ -28,6 +30,7 @@ interface DashboardControlBarProps {
export const DashboardControlBar = ({
workspaceId,
dashboardId,
directories,
existingChartIds,
isEditing,
isSaving,
@@ -82,12 +85,6 @@ export const DashboardControlBar = ({
];
const viewModeActions = [
{
icon: PlusIcon,
tooltip: t("common.add_charts"),
onClick: () => setIsAddExistingDialogOpen(true),
isVisible: !isReadOnly,
},
{
icon: RefreshCwIcon,
tooltip: t("common.refresh"),
@@ -110,7 +107,12 @@ export const DashboardControlBar = ({
return (
<>
<IconBar actions={isEditing ? editModeActions : viewModeActions} />
<div className="flex items-center gap-2">
<IconBar actions={isEditing ? editModeActions : viewModeActions} />
{!isEditing && !isReadOnly && (
<Button onClick={() => setIsAddExistingDialogOpen(true)}>{t("common.add_chart")}</Button>
)}
</div>
<DeleteDialog
deleteWhat={t("workspace.analysis.dashboards.dashboard")}
open={isDeleteDialogOpen}
@@ -124,6 +126,7 @@ export const DashboardControlBar = ({
onOpenChange={setIsAddExistingDialogOpen}
workspaceId={workspaceId}
dashboardId={dashboardId}
directories={directories}
existingChartIds={existingChartIds}
onSuccess={() => {
setIsAddExistingDialogOpen(false);
@@ -27,6 +27,7 @@ interface DashboardDetailClientProps {
workspaceId: string;
dashboard: TDashboardDetail;
widgetDataPromises: Map<string, Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>>;
directories: { id: string; name: string }[];
isReadOnly: boolean;
}
@@ -134,6 +135,7 @@ export function DashboardDetailClient({
workspaceId,
dashboard,
widgetDataPromises,
directories,
isReadOnly,
}: Readonly<DashboardDetailClientProps>) {
const router = useRouter();
@@ -248,6 +250,7 @@ export function DashboardDetailClient({
<DashboardControlBar
workspaceId={workspaceId}
dashboardId={dashboard.id}
directories={directories}
existingChartIds={widgets.map((w) => w.chartId)}
isEditing={isEditing}
isSaving={isSaving}
@@ -26,7 +26,7 @@ export function DashboardPageHeader({
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
className="w-full rounded-md border border-dashed border-slate-300 bg-transparent px-2 py-1 text-3xl font-bold text-slate-800 focus:border-brand-dark focus:outline-none focus:ring-0"
className="w-full rounded-md border border-dashed border-slate-300 bg-white px-2 py-1 text-3xl font-bold text-slate-800 focus:border-brand-dark focus:outline-none focus:ring-0"
aria-label={t("workspace.analysis.dashboards.dashboard_name_placeholder")}
placeholder={t("workspace.analysis.dashboards.dashboard_name_placeholder")}
/>
@@ -4,6 +4,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
import { injectTenantFilter } from "@/modules/ee/analysis/charts/lib/chart-utils";
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { DashboardDetailClient } from "../components/dashboard-detail-client";
import { getDashboard } from "../lib/dashboards";
@@ -59,12 +60,14 @@ export async function DashboardDetailPage({
widgetDataPromises.set(widgetId, Promise.resolve(result));
}
});
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
return (
<DashboardDetailClient
workspaceId={workspaceId}
dashboard={dashboard}
widgetDataPromises={widgetDataPromises}
directories={directories}
isReadOnly={isReadOnly}
/>
);
@@ -35,7 +35,7 @@ export const DashboardsListPage = async ({ workspaceId }: Readonly<DashboardsLis
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
pageTitle={t("common.dashboards")}
workspaceId={workspaceId}
cta={isReadOnly ? undefined : <CreateDashboardButton workspaceId={workspaceId} />}>
<DashboardsListContent
+1 -1
View File
@@ -14,7 +14,7 @@ export const AnalysisListLoading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.analysis")}>
<PageHeader pageTitle={t("common.dashboards")}>
{workspaceId ? <AnalysisSecondaryNavigation workspaceId={workspaceId} /> : null}
</PageHeader>
<DashboardsListSkeleton
+14 -4
View File
@@ -44,7 +44,7 @@ const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4 bg-w
default:
"py-3 px-4 text-sm grid grid-cols-[2fr_auto] grid-rows-[auto_auto] gap-y-0.5 gap-x-3 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
small:
"px-4 py-2 text-xs flex items-center gap-2 [&>svg]:flex-shrink-0 [&_button]:bg-transparent [&_button:hover]:bg-transparent [&>svg~*]:pl-0",
"px-4 py-2 text-xs flex items-center gap-2 [&>svg]:flex-shrink-0 [&_button]:bg-transparent [&_button:hover]:bg-transparent [&_a]:bg-transparent [&_a:hover]:bg-transparent [&>svg~*]:pl-0",
},
},
defaultVariants: {
@@ -94,8 +94,8 @@ const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<H
<h5
ref={ref}
className={cn(
"col-start-1 row-start-1 font-medium tracking-tight",
size === "small" ? "flex-shrink truncate" : "col-start-1 row-start-1",
"col-start-1 row-start-1 tracking-tight",
size === "small" ? "flex-shrink truncate font-normal" : "col-start-1 row-start-1 font-medium",
className
)}
{...props}>
@@ -133,6 +133,7 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
// Determine button styling based on alert context
const buttonVariant = variant ?? (alertSize === "small" ? "link" : "secondary");
const buttonSize = size ?? (alertSize === "small" ? "sm" : "default");
const isSmallLinkButton = alertSize === "small" && buttonVariant === "link";
return (
<div
@@ -142,7 +143,16 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
? "-my-2 -mr-4 ml-auto flex-shrink-0"
: "col-start-2 row-span-2 row-start-1 flex items-center justify-center"
)}>
<Button ref={ref} variant={buttonVariant} size={buttonSize} className={className} {...props}>
<Button
ref={ref}
variant={buttonVariant}
size={buttonSize}
className={cn(
isSmallLinkButton &&
"bg-transparent font-normal underline-offset-4 hover:bg-transparent hover:underline",
className
)}
{...props}>
{children}
</Button>
</div>
@@ -0,0 +1,42 @@
import { notFound } from "next/navigation";
import { ConnectorsSection } from "@/app/(app)/workspaces/[workspaceId]/unify/sources/components/connectors-page-client";
import { transformToUnifySurvey } from "@/app/(app)/workspaces/[workspaceId]/unify/sources/lib";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getSurveys } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export const WorkspaceSourcesPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const t = await getTranslate();
const params = await props.params;
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
await getWorkspaceAuth(params.workspaceId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
if (!hasAccess) {
return notFound();
}
const [connectors, surveys, directories] = await Promise.all([
getConnectorsWithMappings(params.workspaceId),
getSurveys(params.workspaceId),
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
]);
const unifySurveys = surveys.map(transformToUnifySurvey);
return (
<ConnectorsSection
workspaceId={params.workspaceId}
initialConnectors={connectors}
initialSurveys={unifySurveys}
directories={directories}
/>
);
};
+6 -4
View File
@@ -89,10 +89,12 @@ services:
- 4001:4001 # Cube Playground UI (dev only)
environment:
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: ${CUBEJS_DB_HOST:-formbricks_hub_postgres}
CUBEJS_DB_NAME: ${CUBEJS_DB_NAME:-hub}
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-formbricks}
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-formbricks_dev}
# Default to the local postgres service in this compose stack.
# Override CUBEJS_DB_* when using an external Hub/Postgres network.
CUBEJS_DB_HOST: ${CUBEJS_DB_HOST:-postgres}
CUBEJS_DB_NAME: ${CUBEJS_DB_NAME:-postgres}
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-postgres}
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres}
CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432}
CUBEJS_DEV_MODE: "true"
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET}
@@ -0,0 +1 @@
ALTER TYPE "public"."ConnectorType" RENAME VALUE 'formbricks' TO 'formbricks_survey';
+2 -2
View File
@@ -1144,7 +1144,7 @@ model DashboardWidget {
}
enum ConnectorType {
formbricks
formbricks_survey
csv
}
@@ -1171,7 +1171,7 @@ enum HubFieldType {
///
/// @property id - Unique identifier for the connector
/// @property name - Display name for the connector
/// @property type - Type of connector (formbricks, webhook, csv, email, slack)
/// @property type - Type of connector (formbricks_survey, webhook, csv, email, slack)
/// @property status - Current state of the connector (active, paused)
/// @property environment - The environment this connector belongs to
/// @property config - Type-specific configuration (e.g., webhook secret, S3 config)
+1 -1
View File
@@ -2,7 +2,7 @@ import { z } from "zod";
import { TSurveyElementTypeEnum } from "./surveys/constants";
// Connector type enum
export const ZConnectorType = z.enum(["formbricks", "csv"]);
export const ZConnectorType = z.enum(["formbricks_survey", "csv"]);
export type TConnectorType = z.infer<typeof ZConnectorType>;
// Connector status enum