mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-09 11:10:36 -05:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52c820a275 | |||
| 1f6042432a | |||
| 547dca4860 | |||
| 963f89c524 | |||
| 1ffb5d607f | |||
| 5f30618de5 | |||
| ce4d9350e2 | |||
| 3e6f81268d | |||
| 8137de3c80 | |||
| bf0ad45697 | |||
| b1a4277ca8 | |||
| 1876c13f52 | |||
| 0623bb9ff5 | |||
| d37cddaa7e | |||
| 24f632f9ce | |||
| 4b9f48895b | |||
| b041e3da86 | |||
| e8307d0981 | |||
| 8d91a3db62 | |||
| a7fe45cafe | |||
| c05e3f192d | |||
| 7f1565b9ae | |||
| 2e22020331 | |||
| 5b61e00560 |
+7
-7
@@ -167,16 +167,16 @@ AZUREAD_TENANT_ID=
|
||||
|
||||
# Configure Formbricks AI at the instance level
|
||||
# Set the provider used for AI features on this instance.
|
||||
# Accepted values for AI_PROVIDER: aws, gcp, azure
|
||||
# Accepted values for AI_PROVIDER: aws, google, azure
|
||||
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
|
||||
# AI_PROVIDER=gcp
|
||||
# AI_PROVIDER=google
|
||||
# AI_MODEL=gemini-2.5-flash
|
||||
|
||||
# Google Vertex AI credentials
|
||||
# AI_GCP_PROJECT=
|
||||
# AI_GCP_LOCATION=
|
||||
# AI_GCP_CREDENTIALS_JSON=
|
||||
# AI_GCP_APPLICATION_CREDENTIALS=
|
||||
# Google Cloud credentials for Gemini models
|
||||
# AI_GOOGLE_CLOUD_PROJECT=
|
||||
# AI_GOOGLE_CLOUD_LOCATION=
|
||||
# AI_GOOGLE_CLOUD_CREDENTIALS_JSON=
|
||||
# AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS=
|
||||
|
||||
# Amazon Bedrock credentials
|
||||
# AI_AWS_REGION=
|
||||
|
||||
+2
-2
@@ -60,8 +60,8 @@ const mockTemplate: TXMTemplate = {
|
||||
],
|
||||
styling: {
|
||||
brandColor: { light: "#0000FF" },
|
||||
questionColor: { light: "#00FF00" },
|
||||
inputColor: { light: "#FF0000" },
|
||||
elementHeadlineColor: { light: "#00FF00" },
|
||||
inputBgColor: { light: "#FF0000" },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { WorkspaceFeedbackSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
|
||||
@@ -23,9 +23,13 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||
import { getOrganizationsByUserId } from "./lib/organization";
|
||||
import { getWorkspacesByUserId } from "./lib/workspace";
|
||||
|
||||
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
|
||||
feedbackRecordDirectoryId: ZId.optional(),
|
||||
});
|
||||
|
||||
const ZCreateWorkspaceAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZWorkspaceUpdateInput,
|
||||
data: ZCreateWorkspaceInput,
|
||||
});
|
||||
|
||||
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
|
||||
@@ -40,7 +44,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZWorkspaceUpdateInput,
|
||||
schema: ZCreateWorkspaceInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
Loader2,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
MessageSquareTextIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
SettingsIcon,
|
||||
Shapes,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
@@ -146,58 +146,77 @@ export const MainNavigation = ({
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const mainNavigation = useMemo(
|
||||
const mainNavigationSections = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: t("common.surveys"),
|
||||
href: `/workspaces/${workspace.id}/surveys`,
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
href: `/workspaces/${workspace.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
icon: UserIcon,
|
||||
isActive:
|
||||
pathname?.includes("/contacts") ||
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
name: t("common.analysis"),
|
||||
href: `/workspaces/${workspace.id}/dashboards`,
|
||||
icon: BarChart3Icon,
|
||||
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
id: "ask",
|
||||
name: "Ask",
|
||||
items: [
|
||||
{
|
||||
name: t("common.surveys"),
|
||||
href: `/workspaces/${workspace.id}/surveys`,
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
href: `/workspaces/${workspace.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
icon: UserIcon,
|
||||
isActive:
|
||||
pathname?.includes("/contacts") ||
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "unify-feedback",
|
||||
name: t("workspace.unify.unify_feedback"),
|
||||
href: `/workspaces/${workspace.id}/unify/sources`,
|
||||
icon: Shapes,
|
||||
isActive: pathname?.includes("/unify"),
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/workspaces/${workspace.id}/general`,
|
||||
icon: Cog,
|
||||
isActive:
|
||||
pathname?.includes("/general") ||
|
||||
pathname?.includes("/look") ||
|
||||
pathname?.includes("/app-connection") ||
|
||||
pathname?.includes("/integrations") ||
|
||||
pathname?.includes("/teams") ||
|
||||
pathname?.includes("/languages") ||
|
||||
pathname?.includes("/tags"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
items: [
|
||||
{
|
||||
name: t("workspace.unify.feedback_records"),
|
||||
href: `/workspaces/${workspace.id}/unify/feedback-records`,
|
||||
icon: MessageSquareTextIcon,
|
||||
isActive: pathname?.includes("/unify/feedback-records"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
name: t("common.dashboards"),
|
||||
href: `/workspaces/${workspace.id}/dashboards`,
|
||||
icon: BarChart3Icon,
|
||||
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t, workspace.id, pathname, isMembershipPending, isBilling]
|
||||
);
|
||||
|
||||
const configurationNavigationItem = useMemo(
|
||||
() => ({
|
||||
name: t("common.configuration"),
|
||||
href: `/workspaces/${workspace.id}/general`,
|
||||
icon: Cog,
|
||||
isActive:
|
||||
pathname?.includes("/general") ||
|
||||
pathname?.includes("/look") ||
|
||||
pathname?.includes("/app-connection") ||
|
||||
pathname?.includes("/feedback-sources") ||
|
||||
pathname?.includes("/integrations") ||
|
||||
pathname?.includes("/teams") ||
|
||||
pathname?.includes("/languages") ||
|
||||
pathname?.includes("/tags"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
}),
|
||||
[t, workspace.id, pathname, isMembershipPending, isBilling]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
{
|
||||
label: t("common.account"),
|
||||
@@ -256,6 +275,11 @@ export const MainNavigation = ({
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `/workspaces/${workspace.id}/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "feedback-sources",
|
||||
label: t("workspace.unify.feedback_sources"),
|
||||
href: `/workspaces/${workspace.id}/feedback-sources`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
@@ -552,23 +576,50 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
|
||||
{/* Main Nav Switch */}
|
||||
<ul>
|
||||
{mainNavigation.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{mainNavigationSections.map((section) => (
|
||||
<li key={section.id}>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<p className="px-4 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
{section.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul>
|
||||
{section.items.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
|
||||
<NavigationLink
|
||||
href={configurationNavigationItem.href}
|
||||
isActive={configurationNavigationItem.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={configurationNavigationItem.disabled}
|
||||
disabledMessage={
|
||||
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
|
||||
}
|
||||
linkText={configurationNavigationItem.name}>
|
||||
<configurationNavigationItem.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -118,6 +118,11 @@ export const WorkspaceBreadcrumb = ({
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `${workspaceBasePath}/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "feedback-sources",
|
||||
label: t("workspace.unify.feedback_sources"),
|
||||
href: `${workspaceBasePath}/feedback-sources`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
|
||||
@@ -21,6 +21,7 @@ export const SettingsCard = ({
|
||||
beta,
|
||||
className,
|
||||
buttonInfo,
|
||||
cta,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -30,6 +31,7 @@ export const SettingsCard = ({
|
||||
beta?: boolean;
|
||||
className?: string;
|
||||
buttonInfo?: ButtonInfo;
|
||||
cta?: React.ReactNode;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -52,11 +54,12 @@ export const SettingsCard = ({
|
||||
{description}
|
||||
</Small>
|
||||
</div>
|
||||
{buttonInfo && (
|
||||
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
|
||||
{buttonInfo?.text}
|
||||
</Button>
|
||||
)}
|
||||
{cta ??
|
||||
(buttonInfo && (
|
||||
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
|
||||
{buttonInfo?.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
|
||||
</div>
|
||||
|
||||
+12
-2
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface UnifyConfigNavigationProps {
|
||||
@@ -17,15 +18,24 @@ export const UnifyConfigNavigation = ({
|
||||
const { t } = useTranslation();
|
||||
const baseHref = `/workspaces/${workspaceId}/unify`;
|
||||
|
||||
const activeId = activeIdProp ?? "sources";
|
||||
const activeId = activeIdProp ?? "feedback-records";
|
||||
|
||||
const navigation = [
|
||||
{ id: "sources", label: t("workspace.unify.sources"), href: `${baseHref}/sources` },
|
||||
{
|
||||
id: "feedback-records",
|
||||
label: t("workspace.unify.feedback_records"),
|
||||
href: `${baseHref}/feedback-records`,
|
||||
},
|
||||
{
|
||||
id: "topics-subtopics",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{t("workspace.unify.topics_and_subtopics")}
|
||||
<Badge text={t("common.soon")} type="gray" size="tiny" />
|
||||
</span>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { createFeedbackRecord, retrieveFeedbackRecord, updateFeedbackRecord } from "@/modules/hub/service";
|
||||
import type { FeedbackRecordCreateParams, FeedbackRecordUpdateParams } from "@/modules/hub/types";
|
||||
|
||||
const ZFeedbackRecordId = z.uuid();
|
||||
|
||||
const ZFeedbackRecordFieldType = z.enum([
|
||||
"text",
|
||||
"categorical",
|
||||
"nps",
|
||||
"csat",
|
||||
"ces",
|
||||
"rating",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
]);
|
||||
|
||||
const ZFeedbackRecordMetadata = z.record(z.string(), z.unknown());
|
||||
|
||||
const ZFeedbackRecordCreateInput = z.object({
|
||||
submission_id: z.string().min(1),
|
||||
tenant_id: ZId,
|
||||
source_type: z.string().min(1),
|
||||
field_id: z.string().min(1),
|
||||
field_type: ZFeedbackRecordFieldType,
|
||||
collected_at: z.iso.datetime().optional(),
|
||||
source_id: z.string().optional().nullable(),
|
||||
source_name: z.string().optional().nullable(),
|
||||
field_label: z.string().optional().nullable(),
|
||||
field_group_id: z.string().optional(),
|
||||
field_group_label: z.string().optional().nullable(),
|
||||
value_text: z.string().optional().nullable(),
|
||||
value_number: z.number().optional(),
|
||||
value_boolean: z.boolean().optional(),
|
||||
value_date: z.iso.datetime().optional(),
|
||||
metadata: ZFeedbackRecordMetadata.optional(),
|
||||
language: z.string().optional(),
|
||||
user_identifier: z.string().optional(),
|
||||
});
|
||||
|
||||
const ZFeedbackRecordUpdateInput = z
|
||||
.object({
|
||||
value_text: z.string().optional().nullable(),
|
||||
value_number: z.number().optional().nullable(),
|
||||
value_boolean: z.boolean().optional().nullable(),
|
||||
value_date: z.iso.datetime().optional().nullable(),
|
||||
language: z.string().optional().nullable(),
|
||||
metadata: ZFeedbackRecordMetadata.optional(),
|
||||
user_identifier: z.string().optional().nullable(),
|
||||
})
|
||||
.refine(
|
||||
(value) => Object.values(value).some((entry) => entry !== undefined),
|
||||
"At least one field must be provided for update"
|
||||
);
|
||||
|
||||
const ZRetrieveFeedbackRecordAction = z.object({
|
||||
workspaceId: ZId,
|
||||
recordId: ZFeedbackRecordId,
|
||||
});
|
||||
|
||||
const ZCreateFeedbackRecordAction = z.object({
|
||||
workspaceId: ZId,
|
||||
recordInput: ZFeedbackRecordCreateInput,
|
||||
});
|
||||
|
||||
const ZUpdateFeedbackRecordAction = z.object({
|
||||
workspaceId: ZId,
|
||||
recordId: ZFeedbackRecordId,
|
||||
updateInput: ZFeedbackRecordUpdateInput,
|
||||
});
|
||||
|
||||
const ensureAccess = async (
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
minPermission: "read" | "readWrite"
|
||||
): Promise<void> => {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission,
|
||||
workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const getWorkspaceDirectoryIds = async (workspaceId: string): Promise<Set<string>> => {
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
return new Set(directories.map((directory) => directory.id));
|
||||
};
|
||||
|
||||
const assertRecordBelongsToWorkspace = (directoryIds: Set<string>, tenantId: string): void => {
|
||||
if (!directoryIds.has(tenantId)) {
|
||||
// Throw a generic error indistinguishable from "not found" to prevent IDOR
|
||||
throw new Error("Feedback record not found");
|
||||
}
|
||||
};
|
||||
|
||||
export const retrieveFeedbackRecordAction = authenticatedActionClient
|
||||
.inputSchema(ZRetrieveFeedbackRecordAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZRetrieveFeedbackRecordAction>;
|
||||
}) => {
|
||||
const [, workspaceDirectoryIds] = await Promise.all([
|
||||
ensureAccess(ctx.user.id, parsedInput.workspaceId, "read"),
|
||||
getWorkspaceDirectoryIds(parsedInput.workspaceId),
|
||||
]);
|
||||
|
||||
const recordResult = await retrieveFeedbackRecord(parsedInput.recordId);
|
||||
if (!recordResult.data || recordResult.error) {
|
||||
throw new Error("Feedback record not found");
|
||||
}
|
||||
|
||||
assertRecordBelongsToWorkspace(workspaceDirectoryIds, recordResult.data.tenant_id);
|
||||
|
||||
return recordResult.data;
|
||||
}
|
||||
);
|
||||
|
||||
export const createFeedbackRecordAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateFeedbackRecordAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateFeedbackRecordAction>;
|
||||
}) => {
|
||||
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
|
||||
|
||||
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
|
||||
assertRecordBelongsToWorkspace(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
|
||||
|
||||
const { recordInput } = parsedInput;
|
||||
const createParams: FeedbackRecordCreateParams = {
|
||||
submission_id: recordInput.submission_id,
|
||||
tenant_id: recordInput.tenant_id,
|
||||
source_type: recordInput.source_type,
|
||||
field_id: recordInput.field_id,
|
||||
field_type: recordInput.field_type,
|
||||
collected_at: recordInput.collected_at,
|
||||
source_id: recordInput.source_id,
|
||||
source_name: recordInput.source_name,
|
||||
field_label: recordInput.field_label,
|
||||
field_group_id: recordInput.field_group_id,
|
||||
field_group_label: recordInput.field_group_label,
|
||||
value_text: recordInput.value_text,
|
||||
value_number: recordInput.value_number,
|
||||
value_boolean: recordInput.value_boolean,
|
||||
value_date: recordInput.value_date,
|
||||
metadata: recordInput.metadata,
|
||||
language: recordInput.language,
|
||||
user_identifier: recordInput.user_identifier,
|
||||
};
|
||||
|
||||
const createResult = await createFeedbackRecord(createParams);
|
||||
if (!createResult.data || createResult.error) {
|
||||
throw new Error(createResult.error?.message || "Failed to create feedback record");
|
||||
}
|
||||
|
||||
return createResult.data;
|
||||
}
|
||||
);
|
||||
|
||||
export const updateFeedbackRecordAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateFeedbackRecordAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateFeedbackRecordAction>;
|
||||
}) => {
|
||||
const [, workspaceDirectoryIds] = await Promise.all([
|
||||
ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite"),
|
||||
getWorkspaceDirectoryIds(parsedInput.workspaceId),
|
||||
]);
|
||||
|
||||
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
|
||||
if (!currentRecordResult.data || currentRecordResult.error) {
|
||||
throw new Error("Feedback record not found");
|
||||
}
|
||||
|
||||
assertRecordBelongsToWorkspace(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
|
||||
|
||||
const { updateInput } = parsedInput;
|
||||
const updateParams: FeedbackRecordUpdateParams = {
|
||||
...(updateInput.value_text !== undefined && { value_text: updateInput.value_text ?? undefined }),
|
||||
...(updateInput.value_number !== undefined && {
|
||||
value_number: updateInput.value_number ?? undefined,
|
||||
}),
|
||||
...(updateInput.value_boolean !== undefined && {
|
||||
value_boolean: updateInput.value_boolean ?? undefined,
|
||||
}),
|
||||
...(updateInput.value_date !== undefined && { value_date: updateInput.value_date ?? undefined }),
|
||||
...(updateInput.language !== undefined && { language: updateInput.language ?? undefined }),
|
||||
...(updateInput.metadata !== undefined && { metadata: updateInput.metadata }),
|
||||
...(updateInput.user_identifier !== undefined && {
|
||||
user_identifier: updateInput.user_identifier ?? undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
const updateResult = await updateFeedbackRecord(parsedInput.recordId, updateParams);
|
||||
if (!updateResult.data || updateResult.error) {
|
||||
throw new Error(updateResult.error?.message || "Failed to update feedback record");
|
||||
}
|
||||
|
||||
return updateResult.data;
|
||||
}
|
||||
);
|
||||
+814
@@ -0,0 +1,814 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
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 {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/modules/ui/components/sheet";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import {
|
||||
createFeedbackRecordAction,
|
||||
retrieveFeedbackRecordAction,
|
||||
updateFeedbackRecordAction,
|
||||
} from "../actions";
|
||||
import {
|
||||
FIELD_TYPE_OPTIONS,
|
||||
SOURCE_TYPE_CUSTOM_VALUE,
|
||||
SOURCE_TYPE_PRESET_OPTIONS,
|
||||
type TFeedbackRecordFormValues,
|
||||
ZFeedbackRecordFormValues,
|
||||
} from "../lib/types";
|
||||
import {
|
||||
formatSourceType,
|
||||
getCreateDefaults,
|
||||
getReadOnlyMetadataEntries,
|
||||
getValueFieldByType,
|
||||
isPresetSourceType,
|
||||
mapRecordToValues,
|
||||
parseNumberValue,
|
||||
toISOOrUndefined,
|
||||
} from "../lib/utils";
|
||||
|
||||
type FeedbackRecordDrawerMode = "create" | "edit";
|
||||
|
||||
interface FeedbackRecordFormDrawerProps {
|
||||
mode: FeedbackRecordDrawerMode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
recordId?: string;
|
||||
onSuccess: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const FeedbackRecordFormDrawer = ({
|
||||
mode,
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
directories,
|
||||
canWrite,
|
||||
recordId,
|
||||
onSuccess,
|
||||
}: Readonly<FeedbackRecordFormDrawerProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [record, setRecord] = useState<FeedbackRecordData | null>(null);
|
||||
const [isLoadingRecord, setIsLoadingRecord] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
||||
|
||||
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
|
||||
|
||||
const form = useForm<TFeedbackRecordFormValues>({
|
||||
resolver: zodResolver(ZFeedbackRecordFormValues),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "metadataEntries",
|
||||
});
|
||||
|
||||
const fieldType = form.watch("field_type");
|
||||
const selectedValueField = getValueFieldByType(fieldType);
|
||||
const isEditMode = mode === "edit";
|
||||
const isReadOnly = isEditMode && !canWrite;
|
||||
|
||||
const [sourceTypeMode, setSourceTypeMode] = useState<string>("survey");
|
||||
const [customSourceType, setCustomSourceType] = useState("");
|
||||
|
||||
const readOnlyMetadataEntries = useMemo(() => (record ? getReadOnlyMetadataEntries(record) : []), [record]);
|
||||
|
||||
const resetForCreate = useCallback(() => {
|
||||
const nextDefaults = getCreateDefaults(directories);
|
||||
form.reset(nextDefaults);
|
||||
setRecord(null);
|
||||
setSourceTypeMode(nextDefaults.source_type);
|
||||
setCustomSourceType("");
|
||||
}, [directories, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (mode === "create") {
|
||||
resetForCreate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recordId) return;
|
||||
|
||||
const loadRecord = async () => {
|
||||
setIsLoadingRecord(true);
|
||||
const result = await retrieveFeedbackRecordAction({ workspaceId, recordId });
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result) || t("workspace.unify.failed_to_load_feedback_records"));
|
||||
setIsLoadingRecord(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecord(result.data);
|
||||
form.reset(mapRecordToValues(result.data));
|
||||
const isPreset = isPresetSourceType(result.data.source_type);
|
||||
setSourceTypeMode(isPreset ? result.data.source_type : SOURCE_TYPE_CUSTOM_VALUE);
|
||||
setCustomSourceType(isPreset ? "" : result.data.source_type);
|
||||
setIsLoadingRecord(false);
|
||||
};
|
||||
|
||||
void loadRecord();
|
||||
}, [form, mode, open, recordId, resetForCreate, t, workspaceId]);
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
if (form.formState.isDirty && !isSubmitting) {
|
||||
setIsDiscardDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
}, [form.formState.isDirty, isSubmitting, onOpenChange]);
|
||||
|
||||
const handleDrawerOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
requestClose();
|
||||
},
|
||||
[onOpenChange, requestClose]
|
||||
);
|
||||
|
||||
const handleDiscardChanges = () => {
|
||||
setIsDiscardDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const setStrictValueValidationError = (message: string) => {
|
||||
form.setError(selectedValueField, { type: "manual", message });
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
form.clearErrors();
|
||||
|
||||
if (mode === "create") {
|
||||
const requiredValueError = t("workspace.unify.feedback_record_value_required");
|
||||
if (selectedValueField === "value_text" && !values.value_text?.trim()) {
|
||||
setStrictValueValidationError(requiredValueError);
|
||||
return;
|
||||
}
|
||||
if (selectedValueField === "value_number" && parseNumberValue(values.value_number ?? "") == null) {
|
||||
setStrictValueValidationError(requiredValueError);
|
||||
return;
|
||||
}
|
||||
if (selectedValueField === "value_boolean" && values.value_boolean === undefined) {
|
||||
setStrictValueValidationError(requiredValueError);
|
||||
return;
|
||||
}
|
||||
if (selectedValueField === "value_date" && !toISOOrUndefined(values.value_date)) {
|
||||
setStrictValueValidationError(requiredValueError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = Object.fromEntries(
|
||||
values.metadataEntries
|
||||
.map((entry) => ({
|
||||
key: entry.key.trim(),
|
||||
value: entry.value,
|
||||
}))
|
||||
.filter((entry) => entry.key.length > 0)
|
||||
.map((entry) => [entry.key, entry.value])
|
||||
);
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const sourceTypeValue =
|
||||
sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE ? customSourceType.trim() : values.source_type;
|
||||
|
||||
const createResult = await createFeedbackRecordAction({
|
||||
workspaceId,
|
||||
recordInput: {
|
||||
submission_id: values.submission_id.trim(),
|
||||
tenant_id: values.tenant_id,
|
||||
source_type: sourceTypeValue,
|
||||
source_id: values.source_id?.trim() ? values.source_id.trim() : null,
|
||||
source_name: values.source_name?.trim() ? values.source_name.trim() : null,
|
||||
field_id: values.field_id.trim(),
|
||||
field_label: values.field_label?.trim() ? values.field_label.trim() : null,
|
||||
field_type: values.field_type,
|
||||
field_group_id: values.field_group_id?.trim() || undefined,
|
||||
field_group_label: values.field_group_label?.trim() ? values.field_group_label.trim() : null,
|
||||
collected_at: toISOOrUndefined(values.collected_at),
|
||||
value_text: selectedValueField === "value_text" ? (values.value_text ?? "") : null,
|
||||
value_number:
|
||||
selectedValueField === "value_number"
|
||||
? (parseNumberValue(values.value_number ?? "") ?? undefined)
|
||||
: undefined,
|
||||
value_boolean: selectedValueField === "value_boolean" ? values.value_boolean : undefined,
|
||||
value_date: selectedValueField === "value_date" ? toISOOrUndefined(values.value_date) : undefined,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||
language: values.language?.trim() || undefined,
|
||||
user_identifier: values.user_identifier?.trim() || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!createResult?.data) {
|
||||
toast.error(getFormattedErrorMessage(createResult));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!recordId) {
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const preservedMetadata = Object.fromEntries(
|
||||
Object.entries(record?.metadata ?? {}).filter(([, value]) => typeof value !== "string")
|
||||
);
|
||||
|
||||
const updatePayload: {
|
||||
language: string | null;
|
||||
user_identifier: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
value_text?: string;
|
||||
value_number?: number | null;
|
||||
value_boolean?: boolean | null;
|
||||
value_date?: string | null;
|
||||
} = {
|
||||
language: values.language?.trim() || null,
|
||||
user_identifier: values.user_identifier?.trim() || null,
|
||||
metadata: { ...preservedMetadata, ...metadata },
|
||||
};
|
||||
|
||||
if (selectedValueField === "value_text") {
|
||||
updatePayload.value_text = values.value_text?.trim() ?? "";
|
||||
} else if (selectedValueField === "value_number") {
|
||||
updatePayload.value_number = parseNumberValue(values.value_number ?? "");
|
||||
} else if (selectedValueField === "value_boolean") {
|
||||
updatePayload.value_boolean = values.value_boolean ?? null;
|
||||
} else if (selectedValueField === "value_date") {
|
||||
updatePayload.value_date = toISOOrUndefined(values.value_date) ?? null;
|
||||
}
|
||||
|
||||
const updateResult = await updateFeedbackRecordAction({
|
||||
workspaceId,
|
||||
recordId,
|
||||
updateInput: updatePayload,
|
||||
});
|
||||
|
||||
if (!updateResult?.data) {
|
||||
toast.error(getFormattedErrorMessage(updateResult));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(
|
||||
mode === "create"
|
||||
? t("workspace.unify.feedback_record_created_successfully")
|
||||
: t("workspace.unify.feedback_record_updated_successfully")
|
||||
);
|
||||
await onSuccess();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const drawerTitle =
|
||||
mode === "create"
|
||||
? t("workspace.unify.add_feedback_record")
|
||||
: t("workspace.unify.feedback_record_details");
|
||||
|
||||
const drawerDescription =
|
||||
mode === "create"
|
||||
? t("workspace.unify.add_feedback_record_description")
|
||||
: t("workspace.unify.feedback_record_details_description");
|
||||
|
||||
const valueBooleanStatus = form.watch("value_boolean");
|
||||
let valueBooleanLabel = t("common.not_set");
|
||||
if (valueBooleanStatus === true) {
|
||||
valueBooleanLabel = t("common.yes");
|
||||
} else if (valueBooleanStatus === false) {
|
||||
valueBooleanLabel = t("common.no");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={handleDrawerOpenChange}>
|
||||
<SheetContent className="w-full overflow-y-auto bg-white px-5 sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{drawerTitle}</SheetTitle>
|
||||
<SheetDescription>{drawerDescription}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{isLoadingRecord ? (
|
||||
<div className="py-8 text-sm text-slate-500">{t("common.loading")}</div>
|
||||
) : (
|
||||
<FormProvider {...form}>
|
||||
<form className="space-y-4 py-4" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tenant_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("workspace.unify.select_feedback_record_directory")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{directories.map((directory) => (
|
||||
<SelectItem key={directory.id} value={directory.id}>
|
||||
{directory.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="submission_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.submission_id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="collected_at"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.collected_at")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="datetime-local" disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="created_at"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.created_at")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="updated_at"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.updated_at")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditMode ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source_type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_type")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={formatSourceType(field.value, t)} disabled />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("workspace.unify.source_type")}</FormLabel>
|
||||
<Select
|
||||
value={sourceTypeMode}
|
||||
onValueChange={(value) => {
|
||||
setSourceTypeMode(value);
|
||||
if (value !== SOURCE_TYPE_CUSTOM_VALUE) {
|
||||
form.setValue("source_type", value, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
disabled={!canWrite}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("workspace.unify.select_feedback_record_source_type")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOURCE_TYPE_PRESET_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={SOURCE_TYPE_CUSTOM_VALUE}>
|
||||
{t("workspace.unify.custom_source_type")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE && (
|
||||
<Input
|
||||
value={customSourceType}
|
||||
onChange={(event) => {
|
||||
setCustomSourceType(event.target.value);
|
||||
form.setValue("source_type", event.target.value, { shouldDirty: true });
|
||||
}}
|
||||
placeholder={t("workspace.unify.custom_source_type_placeholder")}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
)}
|
||||
<FormError>{form.formState.errors.source_type?.message}</FormError>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_label")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_type")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value as TFeedbackRecordFormValues["field_type"])
|
||||
}
|
||||
disabled={isEditMode || !canWrite}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_group_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_group_id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_group_label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_group_label")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value_text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.value_text")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
disabled={selectedValueField !== "value_text" || isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value_number"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.value_number")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type="number"
|
||||
step="any"
|
||||
disabled={selectedValueField !== "value_number" || isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value_date"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.value_date")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type="datetime-local"
|
||||
disabled={selectedValueField !== "value_date" || isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value_boolean"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.value_boolean")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3 rounded-md border border-slate-200 px-3 py-2">
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={(checked) => field.onChange(checked)}
|
||||
disabled={selectedValueField !== "value_boolean" || isReadOnly || !canWrite}
|
||||
/>
|
||||
<span className="text-sm text-slate-600">{valueBooleanLabel}</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormError>{form.formState.errors[selectedValueField]?.message}</FormError>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={!canWrite || isReadOnly} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user_identifier"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.user_identifier")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={!canWrite || isReadOnly} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>{t("workspace.unify.metadata")}</FormLabel>
|
||||
{canWrite && !isReadOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => append({ key: "", value: "" })}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadataEntries.${index}.key`}
|
||||
render={({ field: entryField }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...entryField}
|
||||
placeholder={t("workspace.unify.metadata_key")}
|
||||
disabled={isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadataEntries.${index}.value`}
|
||||
render={({ field: entryField }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...entryField}
|
||||
placeholder={t("workspace.unify.metadata_value")}
|
||||
disabled={isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{canWrite && !isReadOnly && (
|
||||
<Button type="button" variant="outline" onClick={() => remove(index)}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{readOnlyMetadataEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("workspace.unify.metadata_read_only_entries")}
|
||||
</p>
|
||||
{readOnlyMetadataEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="grid grid-cols-2 gap-2 rounded-md bg-slate-50 p-2 text-xs">
|
||||
<span className="font-medium text-slate-700">{entry.key}</span>
|
||||
<span className="truncate text-slate-600" title={entry.value}>
|
||||
{entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
|
||||
<SheetFooter className="mt-2">
|
||||
<Button variant="outline" onClick={requestClose} disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button onClick={handleSubmit} loading={isSubmitting} disabled={isLoadingRecord}>
|
||||
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog
|
||||
open={isDiscardDialogOpen}
|
||||
setOpen={setIsDiscardDialogOpen}
|
||||
headerText={t("workspace.unify.discard_feedback_record_changes_title")}
|
||||
mainText={t("workspace.unify.discard_feedback_record_changes_description")}
|
||||
confirmBtnLabel={t("common.discard")}
|
||||
declineBtnLabel={t("common.cancel")}
|
||||
declineBtnVariant="outline"
|
||||
onDecline={() => setIsDiscardDialogOpen(false)}
|
||||
onConfirm={handleDiscardChanges}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+12
-12
@@ -4,38 +4,38 @@ import { useTranslation } from "react-i18next";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UnifyConfigNavigation } from "../components/UnifyConfigNavigation";
|
||||
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||
import { FeedbackRecordsTable } from "./feedback-records-table";
|
||||
|
||||
interface FeedbackRecordsPageClientProps {
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
initialFrdId: string | null;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
initialNextCursor?: string;
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export function FeedbackRecordsPageClient({
|
||||
workspaceId,
|
||||
directories,
|
||||
initialFrdId,
|
||||
initialRecords,
|
||||
initialNextCursor,
|
||||
}: FeedbackRecordsPageClientProps) {
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsPageClientProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("workspace.unify.unify_feedback")}>
|
||||
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
|
||||
<UnifyConfigNavigation workspaceId={workspaceId} activeId="feedback-records" />
|
||||
</PageHeader>
|
||||
|
||||
<FeedbackRecordsTable
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
initialFrdId={initialFrdId}
|
||||
initialRecords={initialRecords}
|
||||
initialNextCursor={initialNextCursor}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
HashIcon,
|
||||
MessageSquareTextIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ToggleLeftIcon,
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { CsvImportModal } from "../../sources/components/csv-import-modal";
|
||||
import { formatSourceType } from "../lib/utils";
|
||||
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
|
||||
|
||||
const RECORDS_PER_PAGE = 50;
|
||||
|
||||
const FIELD_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
text: <TypeIcon className="h-3.5 w-3.5" />,
|
||||
categorical: <HashIcon className="h-3.5 w-3.5" />,
|
||||
nps: <HashIcon className="h-3.5 w-3.5" />,
|
||||
csat: <HashIcon className="h-3.5 w-3.5" />,
|
||||
ces: <HashIcon className="h-3.5 w-3.5" />,
|
||||
rating: <HashIcon className="h-3.5 w-3.5" />,
|
||||
number: <HashIcon className="h-3.5 w-3.5" />,
|
||||
boolean: <ToggleLeftIcon className="h-3.5 w-3.5" />,
|
||||
date: <CalendarIcon className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string): string => {
|
||||
if (record.value_text != null) return record.value_text;
|
||||
if (record.value_number != null) return String(record.value_number);
|
||||
if (record.value_boolean != null) return record.value_boolean ? t("common.yes") : t("common.no");
|
||||
if (record.value_date != null) return formatDateForDisplay(new Date(record.value_date), locale);
|
||||
return "—";
|
||||
};
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "…";
|
||||
}
|
||||
|
||||
interface FeedbackRecordsTableProps {
|
||||
workspaceId: string;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export const FeedbackRecordsTable = ({
|
||||
workspaceId,
|
||||
initialRecords,
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsTableProps>) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit");
|
||||
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const directories = useMemo(
|
||||
() =>
|
||||
Object.entries(frdMap)
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[frdMap]
|
||||
);
|
||||
const handleRefresh = async () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
|
||||
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
|
||||
const directoryIds = Object.keys(frdMap);
|
||||
const results = await Promise.all(
|
||||
directoryIds.map((frdId) =>
|
||||
listFeedbackRecordsAction({
|
||||
workspaceId,
|
||||
frdId,
|
||||
limit: RECORDS_PER_PAGE,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const successfulRecords = results.flatMap((result) => result?.data?.data ?? []);
|
||||
|
||||
if (directoryIds.length > 0 && successfulRecords.length === 0) {
|
||||
const firstErrorResult = results.find((result) => !result?.data);
|
||||
const errorMessage = firstErrorResult ? getFormattedErrorMessage(firstErrorResult) : undefined;
|
||||
toast.error(errorMessage ?? t("workspace.unify.failed_to_load_feedback_records"), {
|
||||
id: toastId,
|
||||
});
|
||||
setIsRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedRecords = successfulRecords
|
||||
.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
|
||||
.slice(0, RECORDS_PER_PAGE);
|
||||
setRecords(mergedRecords);
|
||||
setIsRefreshing(false);
|
||||
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex h-48 flex-col items-center justify-center gap-3 px-4 text-center">
|
||||
<MessageSquareTextIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="text-sm text-slate-500">{error}</p>
|
||||
<Button variant="secondary" size="sm" onClick={handleRefresh}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = records.length === 0 && !isRefreshing;
|
||||
|
||||
const openEditDrawer = (recordId: string) => {
|
||||
setDrawerMode("edit");
|
||||
setDrawerRecordId(recordId);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
setDrawerMode("create");
|
||||
setDrawerRecordId(undefined);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const hasCsvSources = csvSources.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{isEmpty ? (
|
||||
<span />
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.showing_count_loaded", {
|
||||
count: records.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{canWrite &&
|
||||
(hasCsvSources ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="secondary">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={openCreateDrawer}>
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{csvSources.map((source) => (
|
||||
<DropdownMenuItem
|
||||
key={source.id}
|
||||
onClick={() => {
|
||||
setCsvImportSource(source);
|
||||
}}>
|
||||
{t("workspace.unify.import_via_source_name", { sourceName: source.name })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={openCreateDrawer}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
</Button>
|
||||
))}
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
|
||||
{t("workspace.unify.manage_feedback_sources")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t("workspace.unify.refresh_feedback_records")}>
|
||||
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{records.map((record) => (
|
||||
<FeedbackRecordRow
|
||||
key={record.id}
|
||||
record={record}
|
||||
workspaceId={workspaceId}
|
||||
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
|
||||
t={t}
|
||||
onClick={() => openEditDrawer(record.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FeedbackRecordFormDrawer
|
||||
mode={drawerMode}
|
||||
open={isDrawerOpen}
|
||||
onOpenChange={setIsDrawerOpen}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
canWrite={canWrite}
|
||||
recordId={drawerMode === "edit" ? drawerRecordId : undefined}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
|
||||
{csvImportSource && (
|
||||
<CsvImportModal
|
||||
open={csvImportSource !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCsvImportSource(null);
|
||||
}
|
||||
}}
|
||||
connectorId={csvImportSource.id}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FeedbackRecordRow = ({
|
||||
record,
|
||||
workspaceId,
|
||||
locale,
|
||||
t,
|
||||
onClick,
|
||||
}: {
|
||||
record: FeedbackRecordData;
|
||||
workspaceId: string;
|
||||
locale: string;
|
||||
t: TFunction;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const value = formatValue(record, t, locale);
|
||||
const isLongValue = value.length > 60;
|
||||
const isFormbricksSurveySource =
|
||||
(record.source_type === "formbricks" || record.source_type === "formbricks_survey") && !!record.source_id;
|
||||
const surveySummaryHref = `/workspaces/${workspaceId}/surveys/${record.source_id}/summary`;
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="cursor-pointer text-sm text-slate-700 transition-colors hover:bg-slate-50"
|
||||
onClick={onClick}>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
|
||||
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<Badge text={formatSourceType(record.source_type, t)} type="gray" size="tiny" />
|
||||
</td>
|
||||
<td className="max-w-[150px] truncate px-4 py-3" title={record.source_name ?? undefined}>
|
||||
{isFormbricksSurveySource ? (
|
||||
<Link
|
||||
href={surveySummaryHref}
|
||||
className="text-slate-700 underline underline-offset-2 hover:text-slate-900"
|
||||
onClick={(event) => event.stopPropagation()}>
|
||||
{record.source_name ?? "—"}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{record.source_name ?? "—"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate px-4 py-3" title={record.field_label ?? undefined}>
|
||||
{record.field_label ?? record.field_id}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1 text-slate-600">
|
||||
{FIELD_TYPE_ICONS[record.field_type] ?? <HashIcon className="h-3.5 w-3.5" />}
|
||||
{record.field_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-[250px] px-4 py-3">
|
||||
{isLongValue ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{truncate(value, 60)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm whitespace-pre-wrap">
|
||||
{value}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span>{value}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[120px] truncate px-4 py-3 text-slate-500" title={record.user_identifier}>
|
||||
{record.user_identifier ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
-303
@@ -1,303 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
CalendarIcon,
|
||||
HashIcon,
|
||||
MessageSquareTextIcon,
|
||||
RefreshCwIcon,
|
||||
ToggleLeftIcon,
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
const RECORDS_PER_PAGE = 50;
|
||||
|
||||
const FIELD_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
text: <TypeIcon className="h-3.5 w-3.5" />,
|
||||
categorical: <HashIcon className="h-3.5 w-3.5" />,
|
||||
nps: <HashIcon className="h-3.5 w-3.5" />,
|
||||
csat: <HashIcon className="h-3.5 w-3.5" />,
|
||||
ces: <HashIcon className="h-3.5 w-3.5" />,
|
||||
rating: <HashIcon className="h-3.5 w-3.5" />,
|
||||
number: <HashIcon className="h-3.5 w-3.5" />,
|
||||
boolean: <ToggleLeftIcon className="h-3.5 w-3.5" />,
|
||||
date: <CalendarIcon className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string): string => {
|
||||
if (record.value_text != null) return record.value_text;
|
||||
if (record.value_number != null) return String(record.value_number);
|
||||
if (record.value_boolean != null) return record.value_boolean ? t("common.yes") : t("common.no");
|
||||
if (record.value_date != null) return formatDateForDisplay(new Date(record.value_date), locale);
|
||||
return "—";
|
||||
};
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "…";
|
||||
}
|
||||
|
||||
interface FeedbackRecordsTableProps {
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
initialFrdId: string | null;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
initialNextCursor?: string;
|
||||
}
|
||||
|
||||
export const FeedbackRecordsTable = ({
|
||||
workspaceId,
|
||||
directories,
|
||||
initialFrdId,
|
||||
initialRecords,
|
||||
initialNextCursor,
|
||||
}: FeedbackRecordsTableProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const [selectedFrdId, setSelectedFrdId] = useState<string | null>(initialFrdId);
|
||||
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(initialNextCursor);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchRecords = useCallback(
|
||||
async (frdId: string, cursor: string | undefined, append: boolean): Promise<string | null> => {
|
||||
const setLoading = append ? setIsLoadingMore : setIsRefreshing;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await listFeedbackRecordsAction({
|
||||
workspaceId,
|
||||
frdId,
|
||||
limit: RECORDS_PER_PAGE,
|
||||
cursor,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
const message =
|
||||
getFormattedErrorMessage(result) ?? t("workspace.unify.failed_to_load_feedback_records");
|
||||
setError(message);
|
||||
setLoading(false);
|
||||
return message;
|
||||
}
|
||||
|
||||
const response = result.data;
|
||||
setRecords((prev) => (append ? [...prev, ...response.data] : response.data));
|
||||
setNextCursor(response.next_cursor);
|
||||
setLoading(false);
|
||||
return null;
|
||||
},
|
||||
[workspaceId, t]
|
||||
);
|
||||
|
||||
const handleFrdChange = (frdId: string) => {
|
||||
setSelectedFrdId(frdId);
|
||||
setRecords([]);
|
||||
setNextCursor(undefined);
|
||||
fetchRecords(frdId, undefined, false);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!selectedFrdId) return;
|
||||
fetchRecords(selectedFrdId, nextCursor, true);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!selectedFrdId || isRefreshing) return;
|
||||
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
|
||||
const errorMessage = await fetchRecords(selectedFrdId, undefined, false);
|
||||
if (errorMessage) {
|
||||
toast.error(errorMessage, { id: toastId });
|
||||
return;
|
||||
}
|
||||
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
|
||||
};
|
||||
|
||||
const hasMore = !!nextCursor;
|
||||
const isEmpty = records.length === 0 && !isRefreshing;
|
||||
const currentFrdName = directories.find((d) => d.id === selectedFrdId)?.name ?? "—";
|
||||
|
||||
if (directories.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 p-8 text-center">
|
||||
<MessageSquareTextIcon className="mx-auto h-8 w-8 text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{t("workspace.unify.no_feedback_record_directory_available")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex h-48 flex-col items-center justify-center gap-3 px-4 text-center">
|
||||
<MessageSquareTextIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="text-sm text-slate-500">{error}</p>
|
||||
<Button variant="secondary" size="sm" onClick={handleRefresh}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>{t("workspace.unify.feedback_record_directory")}</Label>
|
||||
{directories.length === 1 ? (
|
||||
<p className="text-sm font-medium text-slate-900">{currentFrdName}</p>
|
||||
) : (
|
||||
<Select value={selectedFrdId ?? ""} onValueChange={handleFrdChange}>
|
||||
<SelectTrigger className="min-w-[220px]">
|
||||
<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>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{!isEmpty && (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.showing_count_loaded", { count: records.length })}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t("workspace.unify.refresh_feedback_records")}>
|
||||
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{records.map((record) => (
|
||||
<FeedbackRecordRow key={record.id} record={record} locale={locale} t={t} />
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="secondary" size="sm" onClick={handleLoadMore} loading={isLoadingMore}>
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeedbackRecordRow = ({
|
||||
record,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
record: FeedbackRecordData;
|
||||
locale: string;
|
||||
t: TFunction;
|
||||
}) => {
|
||||
const value = formatValue(record, t, locale);
|
||||
const isLongValue = value.length > 60;
|
||||
|
||||
return (
|
||||
<tr className="text-sm text-slate-700 transition-colors hover:bg-slate-50">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
|
||||
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<Badge text={record.source_type} type="gray" size="tiny" />
|
||||
</td>
|
||||
<td className="max-w-[150px] truncate px-4 py-3" title={record.source_name ?? undefined}>
|
||||
{record.source_name ?? "—"}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate px-4 py-3" title={record.field_label ?? undefined}>
|
||||
{record.field_label ?? record.field_id}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1 text-slate-600">
|
||||
{FIELD_TYPE_ICONS[record.field_type] ?? <HashIcon className="h-3.5 w-3.5" />}
|
||||
{record.field_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-[250px] px-4 py-3">
|
||||
{isLongValue ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{truncate(value, 60)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm whitespace-pre-wrap">
|
||||
{value}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span>{value}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[120px] truncate px-4 py-3 text-slate-500" title={record.user_identifier}>
|
||||
{record.user_identifier ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const FIELD_TYPE_OPTIONS = [
|
||||
"text",
|
||||
"categorical",
|
||||
"nps",
|
||||
"csat",
|
||||
"ces",
|
||||
"rating",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
] as const;
|
||||
|
||||
export const SOURCE_TYPE_PRESET_OPTIONS = [
|
||||
"survey",
|
||||
"review",
|
||||
"feedback_form",
|
||||
"support",
|
||||
"social",
|
||||
"interview",
|
||||
"usability_test",
|
||||
"nps_campaign",
|
||||
] as const;
|
||||
|
||||
export const SOURCE_TYPE_CUSTOM_VALUE = "__custom__";
|
||||
|
||||
const ZMetadataEntry = z.object({
|
||||
key: z.string().trim().min(1),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const ZFeedbackRecordFormValues = z.object({
|
||||
id: z.string().optional(),
|
||||
tenant_id: z.string().min(1),
|
||||
submission_id: z.string().min(1),
|
||||
collected_at: z.string().min(1),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
source_type: z.string().min(1),
|
||||
source_id: z.string().optional(),
|
||||
source_name: z.string().optional(),
|
||||
field_id: z.string().min(1),
|
||||
field_label: z.string().optional(),
|
||||
field_type: z.enum(FIELD_TYPE_OPTIONS),
|
||||
field_group_id: z.string().optional(),
|
||||
field_group_label: z.string().optional(),
|
||||
value_text: z.string().optional(),
|
||||
value_number: z.string().optional(),
|
||||
value_boolean: z.boolean().optional(),
|
||||
value_date: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
user_identifier: z.string().optional(),
|
||||
metadataEntries: z.array(ZMetadataEntry),
|
||||
});
|
||||
|
||||
export type TFeedbackRecordFormValues = z.infer<typeof ZFeedbackRecordFormValues>;
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import {
|
||||
formatSourceType,
|
||||
getCreateDefaults,
|
||||
getReadOnlyMetadataEntries,
|
||||
getValueFieldByType,
|
||||
isPresetSourceType,
|
||||
mapRecordToValues,
|
||||
parseNumberValue,
|
||||
toISOOrUndefined,
|
||||
toLocalDateTimeInput,
|
||||
} from "./utils";
|
||||
|
||||
vi.mock("uuid", () => ({ v7: () => "mock-uuid-v7" }));
|
||||
|
||||
const makeRecord = (overrides: Partial<FeedbackRecordData> = {}): FeedbackRecordData => ({
|
||||
id: "rec-1",
|
||||
tenant_id: "tenant-1",
|
||||
submission_id: "sub-1",
|
||||
collected_at: "2026-03-15T12:00:00.000Z",
|
||||
created_at: "2026-03-15T12:00:00.000Z",
|
||||
updated_at: "2026-03-15T12:00:00.000Z",
|
||||
source_type: "survey",
|
||||
field_id: "f1",
|
||||
field_type: "text",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("getValueFieldByType", () => {
|
||||
test.each([
|
||||
["boolean", "value_boolean"],
|
||||
["date", "value_date"],
|
||||
["nps", "value_number"],
|
||||
["csat", "value_number"],
|
||||
["ces", "value_number"],
|
||||
["rating", "value_number"],
|
||||
["number", "value_number"],
|
||||
["text", "value_text"],
|
||||
["categorical", "value_text"],
|
||||
] as const)("returns %s → %s", (input, expected) => {
|
||||
expect(getValueFieldByType(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toLocalDateTimeInput", () => {
|
||||
test("formats valid ISO date", () => {
|
||||
const result = toLocalDateTimeInput("2026-03-15T14:30:00.000Z");
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
test("returns empty string for invalid date", () => {
|
||||
expect(toLocalDateTimeInput("not-a-date")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toISOOrUndefined", () => {
|
||||
test("returns ISO string for valid input", () => {
|
||||
expect(toISOOrUndefined("2026-03-15T14:30")).toMatch(/2026-03-15/);
|
||||
});
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(toISOOrUndefined("")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for undefined", () => {
|
||||
expect(toISOOrUndefined(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for invalid date", () => {
|
||||
expect(toISOOrUndefined("not-a-date")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCreateDefaults", () => {
|
||||
test("uses first directory as tenant_id", () => {
|
||||
const dirs = [{ id: "dir-1", name: "Dir 1" }];
|
||||
const result = getCreateDefaults(dirs);
|
||||
expect(result.tenant_id).toBe("dir-1");
|
||||
expect(result.submission_id).toBe("mock-uuid-v7");
|
||||
expect(result.field_type).toBe("text");
|
||||
expect(result.metadataEntries).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles empty directories", () => {
|
||||
const result = getCreateDefaults([]);
|
||||
expect(result.tenant_id).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRecordToValues", () => {
|
||||
test("maps a full record", () => {
|
||||
const record = makeRecord({
|
||||
value_text: "hello",
|
||||
value_number: 42,
|
||||
source_id: "s1",
|
||||
source_name: "Survey",
|
||||
metadata: { tag: "vip", nested: { a: 1 } },
|
||||
});
|
||||
const result = mapRecordToValues(record);
|
||||
expect(result.id).toBe("rec-1");
|
||||
expect(result.value_text).toBe("hello");
|
||||
expect(result.value_number).toBe("42");
|
||||
expect(result.source_id).toBe("s1");
|
||||
expect(result.metadataEntries).toEqual([{ key: "tag", value: "vip" }]);
|
||||
});
|
||||
|
||||
test("handles nullish optional fields", () => {
|
||||
const record = makeRecord({ value_number: undefined, source_id: undefined });
|
||||
const result = mapRecordToValues(record);
|
||||
expect(result.value_number).toBe("");
|
||||
expect(result.source_id).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReadOnlyMetadataEntries", () => {
|
||||
test("returns only non-string metadata values", () => {
|
||||
const record = makeRecord({ metadata: { tag: "vip", count: 5, nested: { a: 1 } } });
|
||||
const result = getReadOnlyMetadataEntries(record);
|
||||
expect(result).toEqual([
|
||||
{ key: "count", value: "5" },
|
||||
{ key: "nested", value: '{"a":1}' },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns empty array when no metadata", () => {
|
||||
expect(getReadOnlyMetadataEntries(makeRecord())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNumberValue", () => {
|
||||
test.each([
|
||||
["42", 42],
|
||||
["3.14", 3.14],
|
||||
["-1", -1],
|
||||
["", null],
|
||||
[" ", null],
|
||||
["abc", null],
|
||||
["Infinity", null],
|
||||
])("parseNumberValue(%s) → %s", (input, expected) => {
|
||||
expect(parseNumberValue(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPresetSourceType", () => {
|
||||
test("returns true for preset values", () => {
|
||||
expect(isPresetSourceType("survey")).toBe(true);
|
||||
expect(isPresetSourceType("nps_campaign")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for custom values", () => {
|
||||
expect(isPresetSourceType("custom_type")).toBe(false);
|
||||
expect(isPresetSourceType("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSourceType", () => {
|
||||
const t = ((key: string) => key) as any;
|
||||
|
||||
test("maps known source types", () => {
|
||||
expect(formatSourceType("formbricks", t)).toBe("workspace.unify.formbricks_surveys");
|
||||
expect(formatSourceType("formbricks_survey", t)).toBe("workspace.unify.formbricks_surveys");
|
||||
expect(formatSourceType("csv", t)).toBe("workspace.unify.csv_import");
|
||||
});
|
||||
|
||||
test("returns raw value for unknown types", () => {
|
||||
expect(formatSourceType("custom", t)).toBe("custom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { SOURCE_TYPE_PRESET_OPTIONS, type TFeedbackRecordFormValues } from "./types";
|
||||
|
||||
export const getValueFieldByType = (
|
||||
fieldType: TFeedbackRecordFormValues["field_type"]
|
||||
): "value_text" | "value_number" | "value_boolean" | "value_date" => {
|
||||
switch (fieldType) {
|
||||
case "boolean":
|
||||
return "value_boolean";
|
||||
case "date":
|
||||
return "value_date";
|
||||
case "nps":
|
||||
case "csat":
|
||||
case "ces":
|
||||
case "rating":
|
||||
case "number":
|
||||
return "value_number";
|
||||
default:
|
||||
return "value_text";
|
||||
}
|
||||
};
|
||||
|
||||
export const toLocalDateTimeInput = (isoDate: string): string => {
|
||||
const date = new Date(isoDate);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
export const toISOOrUndefined = (dateTimeValue: string | undefined): string | undefined => {
|
||||
if (!dateTimeValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = new Date(dateTimeValue);
|
||||
if (!Number.isFinite(parsed.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsed.toISOString();
|
||||
};
|
||||
|
||||
export const getCreateDefaults = (directories: { id: string; name: string }[]): TFeedbackRecordFormValues => {
|
||||
const now = new Date();
|
||||
const defaultDirectoryId = directories[0]?.id ?? "";
|
||||
|
||||
return {
|
||||
id: "",
|
||||
tenant_id: defaultDirectoryId,
|
||||
submission_id: uuidv7(),
|
||||
collected_at: toLocalDateTimeInput(now.toISOString()),
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
source_type: "survey",
|
||||
source_id: "",
|
||||
source_name: "",
|
||||
field_id: "",
|
||||
field_label: "",
|
||||
field_type: "text",
|
||||
field_group_id: "",
|
||||
field_group_label: "",
|
||||
value_text: "",
|
||||
value_number: "",
|
||||
value_boolean: undefined,
|
||||
value_date: "",
|
||||
language: "",
|
||||
user_identifier: "",
|
||||
metadataEntries: [],
|
||||
};
|
||||
};
|
||||
|
||||
export const mapRecordToValues = (record: FeedbackRecordData): TFeedbackRecordFormValues => {
|
||||
const metadataEntries = Object.entries(record.metadata ?? {})
|
||||
.filter(([, value]) => typeof value === "string")
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
tenant_id: record.tenant_id,
|
||||
submission_id: record.submission_id,
|
||||
collected_at: toLocalDateTimeInput(record.collected_at),
|
||||
created_at: record.created_at ? toLocalDateTimeInput(record.created_at) : "",
|
||||
updated_at: record.updated_at ? toLocalDateTimeInput(record.updated_at) : "",
|
||||
source_type: record.source_type,
|
||||
source_id: record.source_id ?? "",
|
||||
source_name: record.source_name ?? "",
|
||||
field_id: record.field_id,
|
||||
field_label: record.field_label ?? "",
|
||||
field_type: record.field_type,
|
||||
field_group_id: record.field_group_id ?? "",
|
||||
field_group_label: record.field_group_label ?? "",
|
||||
value_text: record.value_text ?? "",
|
||||
value_number: record.value_number == null ? "" : String(record.value_number),
|
||||
value_boolean: record.value_boolean,
|
||||
value_date: record.value_date ? toLocalDateTimeInput(record.value_date) : "",
|
||||
language: record.language ?? "",
|
||||
user_identifier: record.user_identifier ?? "",
|
||||
metadataEntries,
|
||||
};
|
||||
};
|
||||
|
||||
export const getReadOnlyMetadataEntries = (record: FeedbackRecordData): { key: string; value: string }[] => {
|
||||
return Object.entries(record.metadata ?? {})
|
||||
.filter(([, value]) => typeof value !== "string")
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
}));
|
||||
};
|
||||
|
||||
export const parseNumberValue = (value: string): number | null => {
|
||||
if (value.trim() === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export const isPresetSourceType = (value: string): value is (typeof SOURCE_TYPE_PRESET_OPTIONS)[number] =>
|
||||
(SOURCE_TYPE_PRESET_OPTIONS as readonly string[]).includes(value);
|
||||
|
||||
export const formatSourceType = (sourceType: string, t: TFunction): string => {
|
||||
switch (sourceType) {
|
||||
case "formbricks":
|
||||
case "formbricks_survey":
|
||||
return t("workspace.unify.formbricks_surveys");
|
||||
case "csv":
|
||||
return t("workspace.unify.csv_import");
|
||||
default:
|
||||
return sourceType;
|
||||
}
|
||||
};
|
||||
@@ -1,16 +1,16 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { FeedbackRecordListResponse } from "@/modules/hub";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { FeedbackRecordsPageClient } from "./feedback-records-page-client";
|
||||
import { FeedbackRecordsPageClient } from "./components/feedback-records-page-client";
|
||||
|
||||
const INITIAL_PAGE_SIZE = 10;
|
||||
const INITIAL_PAGE_SIZE = 50;
|
||||
|
||||
export default async function UnifyFeedbackRecordsPage(props: {
|
||||
readonly params: Promise<{ workspaceId: string }>;
|
||||
}) {
|
||||
export default async function UnifyFeedbackRecordsPage(
|
||||
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
|
||||
) {
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
@@ -22,31 +22,40 @@ export default async function UnifyFeedbackRecordsPage(props: {
|
||||
}
|
||||
|
||||
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
|
||||
const canWrite = isOwner || isManager || hasReadWriteAccess || hasManageAccess;
|
||||
if (!hasAccess) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId);
|
||||
const [frds, connectors] = await Promise.all([
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
]);
|
||||
|
||||
// Preload first FRD's records server-side for fast initial render
|
||||
const initialFrdId = frds[0]?.id;
|
||||
let initialRecords: FeedbackRecordListResponse | null = null;
|
||||
const results = await Promise.all(
|
||||
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
|
||||
);
|
||||
|
||||
if (initialFrdId) {
|
||||
const result = await listFeedbackRecords({ tenant_id: initialFrdId, limit: INITIAL_PAGE_SIZE });
|
||||
// Don't crash if Hub is down — show empty state
|
||||
if (!result.error) {
|
||||
initialRecords = result.data;
|
||||
}
|
||||
}
|
||||
// Don't crash if Hub is unreachable — show empty state
|
||||
const successfulResults = results.filter((r) => !r.error);
|
||||
|
||||
const merged = successfulResults
|
||||
.flatMap((r) => r.data?.data ?? [])
|
||||
.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
|
||||
.slice(0, INITIAL_PAGE_SIZE);
|
||||
|
||||
const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name]));
|
||||
const csvSources = connectors
|
||||
.filter((connector) => connector.type === "csv")
|
||||
.map((connector) => ({ id: connector.id, name: connector.name }));
|
||||
|
||||
return (
|
||||
<FeedbackRecordsPageClient
|
||||
workspaceId={params.workspaceId}
|
||||
directories={frds}
|
||||
initialFrdId={initialFrdId ?? null}
|
||||
initialRecords={initialRecords?.data ?? []}
|
||||
initialNextCursor={initialRecords?.next_cursor}
|
||||
initialRecords={merged}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UnifyPage(props: { params: Promise<{ workspaceId: string }> }) {
|
||||
const params = await props.params;
|
||||
redirect(`/workspaces/${params.workspaceId}/unify/sources`);
|
||||
redirect(`/workspaces/${params.workspaceId}/unify/feedback-records`);
|
||||
}
|
||||
|
||||
+26
@@ -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;
|
||||
}
|
||||
};
|
||||
+21
@@ -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;
|
||||
};
|
||||
+24
@@ -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"
|
||||
|
||||
+44
-18
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+47
-20
@@ -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,22 +166,18 @@ export function ConnectorsSection({
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
pageTitle={t("workspace.unify.unify_feedback")}
|
||||
cta={
|
||||
<CreateConnectorModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
onCreateConnector={handleCreateConnector}
|
||||
surveys={initialSurveys}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
/>
|
||||
}>
|
||||
<UnifyConfigNavigation workspaceId={workspaceId} activeId="sources" />
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<WorkspaceConfigNavigation activeId="feedback-sources" />
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<SettingsCard
|
||||
title={t("workspace.unify.feedback_sources")}
|
||||
description={t("workspace.unify.feedback_sources_settings_description")}
|
||||
buttonInfo={{
|
||||
text: t("workspace.unify.add_source"),
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
variant: "default",
|
||||
}}>
|
||||
<ConnectorsTable
|
||||
connectors={initialConnectors}
|
||||
onConnectorClick={setEditingConnector}
|
||||
@@ -179,7 +187,27 @@ 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>
|
||||
|
||||
<CreateConnectorModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
onCreateConnector={handleCreateConnector}
|
||||
surveys={initialSurveys}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
showTrigger={false}
|
||||
/>
|
||||
|
||||
<EditConnectorModal
|
||||
connector={editingConnector}
|
||||
@@ -187,7 +215,6 @@ export function ConnectorsSection({
|
||||
onOpenChange={(open) => !open && setEditingConnector(null)}
|
||||
onUpdateConnector={handleUpdateConnector}
|
||||
surveys={initialSurveys}
|
||||
directories={directories}
|
||||
onOpenCsvImport={() => {
|
||||
if (editingConnector) {
|
||||
setCsvImportConnector(editingConnector);
|
||||
|
||||
+27
-39
@@ -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,28 +52,28 @@ export function ConnectorsTableDataRow({
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: ConnectorsTableDataRowProps) {
|
||||
}: Readonly<ConnectorsTableDataRowProps>) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const getStatusLabel = (s: TConnectorStatus) => {
|
||||
switch (s) {
|
||||
case "active":
|
||||
return t("workspace.unify.status_active");
|
||||
case "paused":
|
||||
return t("workspace.unify.status_paused");
|
||||
case "error":
|
||||
return t("workspace.unify.status_error");
|
||||
const handleRowClick = () => {
|
||||
if (connector.type === "csv" && onCsvImport) {
|
||||
onCsvImport();
|
||||
return;
|
||||
}
|
||||
|
||||
onEdit();
|
||||
};
|
||||
|
||||
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;
|
||||
const getStatusLabel = (s: TConnectorStatus, connectorType: TConnectorType) => {
|
||||
switch (s) {
|
||||
case "active":
|
||||
if (connectorType === "csv") {
|
||||
return t("workspace.unify.status_ready");
|
||||
}
|
||||
return t("workspace.unify.status_live_sync");
|
||||
case "paused":
|
||||
return t("common.disabled");
|
||||
case "error":
|
||||
return t("workspace.unify.status_error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,28 +82,27 @@ export function ConnectorsTableDataRow({
|
||||
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>
|
||||
|
||||
+3
-4
@@ -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="overflow-hidden 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" />
|
||||
|
||||
+298
-314
@@ -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,21 +41,32 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import {
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
TCreateConnectorStep,
|
||||
TFieldMapping,
|
||||
TFormbricksConnectorForm,
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
ZFormbricksConnectorForm,
|
||||
} from "../types";
|
||||
import { TEnumValidationError, parseCSVColumnsToFields, validateEnumMappings } from "../utils";
|
||||
import {
|
||||
TConnectorOptionId,
|
||||
TEnumValidationError,
|
||||
areAllRequiredFieldsMapped,
|
||||
isConnectorNameValid,
|
||||
parseCSVColumnsToFields,
|
||||
toggleQuestionId,
|
||||
validateEnumMappings,
|
||||
} from "../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;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
showTrigger?: boolean;
|
||||
onCreateConnector: (data: {
|
||||
name: string;
|
||||
type: TConnectorType;
|
||||
@@ -59,139 +81,96 @@ 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;
|
||||
};
|
||||
|
||||
interface AggregateImportSectionProps {
|
||||
surveyEntries: {
|
||||
surveyId: string;
|
||||
surveyName: string;
|
||||
responseCount: number;
|
||||
elementCount: number;
|
||||
importHistorical: boolean;
|
||||
}[];
|
||||
onImportHistoricalChange: (surveyId: string, checked: boolean) => void;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
const 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,
|
||||
onOpenChange,
|
||||
showTrigger = true,
|
||||
onCreateConnector,
|
||||
surveys,
|
||||
workspaceId,
|
||||
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 +183,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 +238,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 +270,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 +320,37 @@ export const CreateConnectorModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!selectedType || !connectorName.trim() || !selectedDirectoryId) return;
|
||||
const handleFormbricksQuestionToggle = (questionId: string) => {
|
||||
const nextSelection = toggleQuestionId(formbricksForm.getValues("selectedQuestionIds"), 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 +361,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 +377,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") {
|
||||
@@ -416,10 +389,12 @@ export const CreateConnectorModal = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||
{t("workspace.unify.add_source")}
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
{showTrigger && (
|
||||
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||
{t("workspace.unify.add_source")}
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+228
-162
@@ -1,9 +1,10 @@
|
||||
"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 { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,17 +14,39 @@ 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 {
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import {
|
||||
SAMPLE_CSV_COLUMNS,
|
||||
TFieldMapping,
|
||||
TFormbricksConnectorForm,
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
ZFormbricksConnectorForm,
|
||||
} from "../types";
|
||||
import { parseCSVColumnsToFields } from "../utils";
|
||||
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
import {
|
||||
areAllRequiredFieldsMapped,
|
||||
isConnectorNameValid,
|
||||
parseCSVColumnsToFields,
|
||||
toggleQuestionId,
|
||||
} from "../utils";
|
||||
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
|
||||
import { FormbricksQuestionList } from "./formbricks-question-list";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
|
||||
interface EditConnectorModalProps {
|
||||
@@ -38,78 +61,61 @@ 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 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;
|
||||
};
|
||||
|
||||
export const EditConnectorModal = ({
|
||||
connector,
|
||||
open,
|
||||
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<TFormbricksConnectorForm>({
|
||||
resolver: zodResolver(ZFormbricksConnectorForm),
|
||||
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 +131,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 +171,60 @@ 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: TFormbricksConnectorForm) => {
|
||||
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 nextSelection = toggleQuestionId(formbricksForm.getValues("selectedQuestionIds"), 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 +237,109 @@ 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">{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 +354,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>
|
||||
|
||||
+80
@@ -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>
|
||||
);
|
||||
};
|
||||
-233
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,14 @@ export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
|
||||
required: false,
|
||||
description: "Tenant/organization identifier for multi-tenant deployments",
|
||||
},
|
||||
{
|
||||
id: "submission_id",
|
||||
name: "Submission ID",
|
||||
type: "string",
|
||||
required: false,
|
||||
description:
|
||||
"Optional. Map to a stable column (e.g. order_id, ticket_id) to enable idempotent re-imports. Auto-generated UUID per row if unmapped.",
|
||||
},
|
||||
{
|
||||
id: "source_id",
|
||||
name: "Source ID",
|
||||
@@ -210,3 +218,12 @@ export const createFeedbackCSVDataSchema = (t: TFunction) =>
|
||||
export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSchema>>;
|
||||
|
||||
export type TCreateConnectorStep = "selectType" | "mapping";
|
||||
|
||||
export 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(),
|
||||
});
|
||||
|
||||
export type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { MAX_CSV_VALUES, TSourceField } from "./types";
|
||||
import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } from "./utils";
|
||||
import { MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
|
||||
import {
|
||||
areAllRequiredFieldsMapped,
|
||||
getConnectorOptions,
|
||||
isConnectorNameValid,
|
||||
parseCSVColumnsToFields,
|
||||
toggleQuestionId,
|
||||
validateCsvFile,
|
||||
} from "./utils";
|
||||
|
||||
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 +32,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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,3 +122,111 @@ describe("validateCsvFile", () => {
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_files_only" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isConnectorNameValid", () => {
|
||||
test("returns true for non-empty name", () => {
|
||||
expect(isConnectorNameValid("My Connector")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isConnectorNameValid("")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for whitespace-only string", () => {
|
||||
expect(isConnectorNameValid(" ")).toBe(false);
|
||||
expect(isConnectorNameValid("\t\n ")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for name with surrounding whitespace", () => {
|
||||
expect(isConnectorNameValid(" name ")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for single character", () => {
|
||||
expect(isConnectorNameValid("a")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("areAllRequiredFieldsMapped", () => {
|
||||
const requiredMappings: TFieldMapping[] = [
|
||||
{ targetFieldId: "collected_at", sourceFieldId: "ts" },
|
||||
{ targetFieldId: "source_type", staticValue: "csv" },
|
||||
{ targetFieldId: "field_id", sourceFieldId: "qid" },
|
||||
{ targetFieldId: "field_type", staticValue: "text" },
|
||||
];
|
||||
|
||||
test("returns true when all required fields have a sourceFieldId or staticValue", () => {
|
||||
expect(areAllRequiredFieldsMapped(requiredMappings)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when a required field is missing entirely", () => {
|
||||
const missing = requiredMappings.slice(0, 3);
|
||||
expect(areAllRequiredFieldsMapped(missing)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when a required mapping has neither sourceFieldId nor staticValue", () => {
|
||||
const incomplete: TFieldMapping[] = [...requiredMappings.slice(0, 3), { targetFieldId: "field_type" }];
|
||||
expect(areAllRequiredFieldsMapped(incomplete)).toBe(false);
|
||||
});
|
||||
|
||||
test("ignores mappings for non-required target fields", () => {
|
||||
const withOptionals: TFieldMapping[] = [
|
||||
...requiredMappings,
|
||||
{ targetFieldId: "tenant_id", sourceFieldId: "tenant" },
|
||||
{ targetFieldId: "unknown_field", sourceFieldId: "anything" },
|
||||
];
|
||||
expect(areAllRequiredFieldsMapped(withOptionals)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for empty mappings array", () => {
|
||||
expect(areAllRequiredFieldsMapped([])).toBe(false);
|
||||
});
|
||||
|
||||
test("treats empty staticValue and missing sourceFieldId as unmapped", () => {
|
||||
const incomplete: TFieldMapping[] = [
|
||||
{ targetFieldId: "collected_at", sourceFieldId: "ts" },
|
||||
{ targetFieldId: "source_type", sourceFieldId: "", staticValue: "" },
|
||||
{ targetFieldId: "field_id", sourceFieldId: "qid" },
|
||||
{ targetFieldId: "field_type", staticValue: "text" },
|
||||
];
|
||||
expect(areAllRequiredFieldsMapped(incomplete)).toBe(false);
|
||||
});
|
||||
|
||||
test("counts required field as mapped when only staticValue is set", () => {
|
||||
const onlyStatic: TFieldMapping[] = [
|
||||
{ targetFieldId: "collected_at", staticValue: "2026-01-01" },
|
||||
{ targetFieldId: "source_type", staticValue: "csv" },
|
||||
{ targetFieldId: "field_id", staticValue: "id" },
|
||||
{ targetFieldId: "field_type", staticValue: "text" },
|
||||
];
|
||||
expect(areAllRequiredFieldsMapped(onlyStatic)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleQuestionId", () => {
|
||||
test("adds id when not present", () => {
|
||||
expect(toggleQuestionId(["a", "b"], "c")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("removes id when present", () => {
|
||||
expect(toggleQuestionId(["a", "b", "c"], "b")).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
test("adds to empty selection", () => {
|
||||
expect(toggleQuestionId([], "x")).toEqual(["x"]);
|
||||
});
|
||||
|
||||
test("returns empty when removing the only id", () => {
|
||||
expect(toggleQuestionId(["only"], "only")).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not mutate the input array", () => {
|
||||
const input = ["a", "b"];
|
||||
const result = toggleQuestionId(input, "c");
|
||||
expect(input).toEqual(["a", "b"]);
|
||||
expect(result).not.toBe(input);
|
||||
});
|
||||
|
||||
test("removes only the matching id when duplicates exist", () => {
|
||||
expect(toggleQuestionId(["a", "b", "a"], "a")).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[] => {
|
||||
@@ -76,6 +90,32 @@ export const validateEnumMappings = (
|
||||
return errors;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const toggleQuestionId = (currentSelection: string[], questionId: string): string[] => {
|
||||
return currentSelection.includes(questionId)
|
||||
? currentSelection.filter((id) => id !== questionId)
|
||||
: [...currentSelection, questionId];
|
||||
};
|
||||
|
||||
export const validateCsvFile = (
|
||||
file: File,
|
||||
t: TFunction
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { handleConnectorPipeline } from "@/lib/connector/pipeline-handler";
|
||||
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
import { handleIntegrations } from "@/modules/response-pipeline/lib/handle-integrations";
|
||||
import { captureSurveyResponsePostHogEvent } from "@/modules/response-pipeline/lib/posthog";
|
||||
import { sendTelemetryEvents } from "@/modules/response-pipeline/lib/telemetry";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
|
||||
export const POST = async (request: Request) => {
|
||||
const requestHeaders = await headers();
|
||||
// Check authentication
|
||||
if (requestHeaders.get("x-api-key") !== CRON_SECRET) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const jsonInput = await request.json();
|
||||
const convertedJsonInput = convertDatesInObject(
|
||||
jsonInput,
|
||||
new Set(["contactAttributes", "variables", "data", "meta"])
|
||||
);
|
||||
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
logger.error(
|
||||
{ error: inputValidation.error, url: request.url },
|
||||
"Error in POST /api/(internal)/pipeline"
|
||||
);
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceId, surveyId, event, response } = inputValidation.data;
|
||||
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
const organization = await getOrganization(organizationId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", "Organization not found");
|
||||
}
|
||||
|
||||
// Fetch survey for webhook payload
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
|
||||
|
||||
return responses.notFoundResponse("Survey", surveyId, true);
|
||||
}
|
||||
|
||||
if (survey.workspaceId !== workspaceId) {
|
||||
logger.error(
|
||||
{ url: request.url, surveyId, workspaceId, surveyWorkspaceId: survey.workspaceId },
|
||||
`Survey ${surveyId} does not belong to workspace ${workspaceId}`
|
||||
);
|
||||
return responses.badRequestResponse("Survey not found in this workspace");
|
||||
}
|
||||
|
||||
// Fetch webhooks
|
||||
const getWebhooksForPipeline = async (workspaceId: string, event: PipelineTriggers, surveyId: string) => {
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
triggers: { has: event },
|
||||
OR: [{ surveyIds: { has: surveyId } }, { surveyIds: { isEmpty: true } }],
|
||||
},
|
||||
});
|
||||
return webhooks;
|
||||
};
|
||||
|
||||
const webhooks: Webhook[] = await getWebhooksForPipeline(workspaceId, event, surveyId);
|
||||
// Prepare webhook and email promises
|
||||
|
||||
// Fetch with timeout of 5 seconds to prevent hanging
|
||||
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
|
||||
return Promise.race([
|
||||
fetch(url, options),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
|
||||
]);
|
||||
};
|
||||
|
||||
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
|
||||
|
||||
const webhookPromises = webhooks.map((webhook) => {
|
||||
const body = JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
data: resolvedResponseData,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
createdAt: survey.createdAt,
|
||||
updatedAt: survey.updatedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Generate Standard Webhooks headers
|
||||
const webhookMessageId = uuidv7();
|
||||
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"webhook-id": webhookMessageId,
|
||||
"webhook-timestamp": webhookTimestamp.toString(),
|
||||
};
|
||||
|
||||
// Add signature if webhook has a secret configured
|
||||
if (webhook.secret) {
|
||||
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
|
||||
webhookMessageId,
|
||||
webhookTimestamp,
|
||||
body,
|
||||
webhook.secret
|
||||
);
|
||||
}
|
||||
|
||||
return validateWebhookUrl(webhook.url)
|
||||
.then(() =>
|
||||
fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
});
|
||||
});
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Handle connector pipeline for Hub integration (only on responseFinished to avoid duplicates)
|
||||
// This sends response data to the Hub for configured connectors
|
||||
try {
|
||||
await handleConnectorPipeline(response, survey, workspaceId);
|
||||
} catch (error) {
|
||||
// Log but don't throw - connector failures shouldn't break the main pipeline
|
||||
logger.error({ error, surveyId, responseId: response.id }, "Connector pipeline failed");
|
||||
}
|
||||
// Fetch integrations and responseCount in parallel
|
||||
const [integrations, responseCount] = await Promise.all([
|
||||
getIntegrations(workspaceId),
|
||||
getResponseCountBySurveyId(surveyId),
|
||||
]);
|
||||
|
||||
if (integrations.length > 0) {
|
||||
await handleIntegrations(integrations, inputValidation.data, survey);
|
||||
}
|
||||
|
||||
// Fetch users with notifications in a single query
|
||||
const usersWithNotifications = await prisma.user.findMany({
|
||||
where: {
|
||||
memberships: {
|
||||
some: {
|
||||
organization: {
|
||||
workspaces: {
|
||||
some: {
|
||||
id: workspaceId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
memberships: {
|
||||
every: {
|
||||
role: {
|
||||
in: ["owner", "manager"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
teamUsers: {
|
||||
some: {
|
||||
team: {
|
||||
workspaceTeams: {
|
||||
some: {
|
||||
workspace: {
|
||||
id: workspaceId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
notificationSettings: {
|
||||
path: ["alert", surveyId],
|
||||
equals: true,
|
||||
},
|
||||
},
|
||||
select: { email: true, locale: true },
|
||||
});
|
||||
|
||||
if (survey.followUps?.length > 0) {
|
||||
// send follow up emails
|
||||
const followUpsResult = await sendFollowUpsForResponse(response.id);
|
||||
if (!followUpsResult.ok) {
|
||||
const { error: followUpsError } = followUpsResult;
|
||||
if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) {
|
||||
logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emailPromises = usersWithNotifications.map((user) =>
|
||||
sendResponseFinishedEmail(user.email, user.locale, workspaceId, survey, response, responseCount).catch(
|
||||
(error) => {
|
||||
logger.error(
|
||||
{ error, url: request.url, userEmail: user.email },
|
||||
`Failed to send email to ${user.email}`
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Update survey status if necessary
|
||||
if (survey.autoComplete && responseCount >= survey.autoComplete) {
|
||||
let logStatus: TAuditStatus = "success";
|
||||
|
||||
try {
|
||||
await updateSurvey({
|
||||
...survey,
|
||||
status: "completed",
|
||||
});
|
||||
} catch (error) {
|
||||
logStatus = "failure";
|
||||
logger.error(
|
||||
{ error, url: request.url, surveyId },
|
||||
`Failed to update survey ${surveyId} status to completed`
|
||||
);
|
||||
} finally {
|
||||
await queueAuditEvent({
|
||||
status: logStatus,
|
||||
action: "updated",
|
||||
targetType: "survey",
|
||||
userId: UNKNOWN_DATA,
|
||||
userType: "system",
|
||||
targetId: survey.id,
|
||||
organizationId: organization.id,
|
||||
newObject: {
|
||||
status: "completed",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Await webhook and email promises with allSettled to prevent early rejection
|
||||
const results = await Promise.allSettled([...webhookPromises, ...emailPromises]);
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
logger.error({ error: result.reason, url: request.url }, "Promise rejected");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Await webhook promises if no emails are sent (with allSettled to prevent early rejection)
|
||||
const results = await Promise.allSettled(webhookPromises);
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
logger.error({ error: result.reason, url: request.url }, "Promise rejected");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (event === "responseCreated") {
|
||||
recordResponseCreatedMeterEvent({
|
||||
stripeCustomerId: organization.billing.stripeCustomerId,
|
||||
responseId: response.id,
|
||||
createdAt: response.createdAt,
|
||||
}).catch((error) => {
|
||||
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
|
||||
});
|
||||
|
||||
if (POSTHOG_KEY) {
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId);
|
||||
|
||||
captureSurveyResponsePostHogEvent({
|
||||
organizationId: organization.id,
|
||||
surveyId,
|
||||
surveyType: survey.type,
|
||||
workspaceId,
|
||||
responseCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Send telemetry events
|
||||
await sendTelemetryEvents();
|
||||
}
|
||||
|
||||
return Response.json({ data: {} });
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||
import { ZResponse } from "@formbricks/types/responses";
|
||||
|
||||
export const ZPipelineInput = z.object({
|
||||
event: ZWebhook.shape.triggers.element,
|
||||
response: ZResponse,
|
||||
workspaceId: z.string(),
|
||||
surveyId: z.string(),
|
||||
});
|
||||
|
||||
export type TPipelineInput = z.infer<typeof ZPipelineInput>;
|
||||
+2
-2
@@ -254,7 +254,7 @@ export const putResponseHandler = async ({
|
||||
|
||||
const { quotaFull, ...responseData } = updatedResponse;
|
||||
|
||||
sendToPipeline({
|
||||
await sendToPipeline({
|
||||
event: "responseUpdated",
|
||||
workspaceId: survey.workspaceId,
|
||||
surveyId: survey.id,
|
||||
@@ -262,7 +262,7 @@ export const putResponseHandler = async ({
|
||||
});
|
||||
|
||||
if (updatedResponse.finished) {
|
||||
sendToPipeline({
|
||||
await sendToPipeline({
|
||||
event: "responseFinished",
|
||||
workspaceId: survey.workspaceId,
|
||||
surveyId: survey.id,
|
||||
|
||||
@@ -186,7 +186,7 @@ export const POST = withV1ApiWrapper({
|
||||
|
||||
const { quotaFull, ...responseData } = response;
|
||||
|
||||
sendToPipeline({
|
||||
await sendToPipeline({
|
||||
event: "responseCreated",
|
||||
workspaceId,
|
||||
surveyId: responseData.surveyId,
|
||||
@@ -194,7 +194,7 @@ export const POST = withV1ApiWrapper({
|
||||
});
|
||||
|
||||
if (responseInput.finished) {
|
||||
sendToPipeline({
|
||||
await sendToPipeline({
|
||||
event: "responseFinished",
|
||||
workspaceId,
|
||||
surveyId: responseData.surveyId,
|
||||
|
||||
@@ -169,7 +169,7 @@ export const PUT = withV1ApiWrapper({
|
||||
auditLog.newObject = updated;
|
||||
}
|
||||
|
||||
sendToPipeline({
|
||||
await sendToPipeline({
|
||||
event: "responseUpdated",
|
||||
workspaceId: result.survey.workspaceId,
|
||||
surveyId: result.survey.id,
|
||||
@@ -177,7 +177,7 @@ export const PUT = withV1ApiWrapper({
|
||||
});
|
||||
|
||||
if (updated.finished) {
|
||||
sendToPipeline({
|
||||
await sendToPipeline({
|
||||
event: "responseFinished",
|
||||
workspaceId: result.survey.workspaceId,
|
||||
surveyId: result.survey.id,
|
||||
|
||||
@@ -165,7 +165,7 @@ export const POST = withV1ApiWrapper({
|
||||
auditLog.newObject = response;
|
||||
}
|
||||
|
||||
sendToPipeline({
|
||||
await sendToPipeline({
|
||||
event: "responseCreated",
|
||||
workspaceId: surveyResult.survey.workspaceId,
|
||||
surveyId: response.surveyId,
|
||||
@@ -173,7 +173,7 @@ export const POST = withV1ApiWrapper({
|
||||
});
|
||||
|
||||
if (response.finished) {
|
||||
sendToPipeline({
|
||||
await sendToPipeline({
|
||||
event: "responseFinished",
|
||||
workspaceId: surveyResult.survey.workspaceId,
|
||||
surveyId: response.surveyId,
|
||||
|
||||
@@ -237,7 +237,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
}
|
||||
const { quotaFull, ...responseData } = createdResponse;
|
||||
|
||||
sendToPipeline({
|
||||
await sendToPipeline({
|
||||
event: "responseCreated",
|
||||
workspaceId,
|
||||
surveyId: responseData.surveyId,
|
||||
@@ -245,7 +245,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
});
|
||||
|
||||
if (responseData.finished) {
|
||||
sendToPipeline({
|
||||
await sendToPipeline({
|
||||
event: "responseFinished",
|
||||
workspaceId,
|
||||
surveyId: responseData.surveyId,
|
||||
|
||||
@@ -1,113 +1,84 @@
|
||||
import { PipelineTriggers } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TResponsePipelineJobData, getBackgroundJobProducer } from "@formbricks/jobs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TPipelineInput } from "@/app/lib/types/pipelines";
|
||||
import { sendToPipeline } from "./pipelines";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getJobsQueueingConfig } from "@/lib/jobs/config";
|
||||
|
||||
// Mock the constants module
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
CRON_SECRET: "mocked-cron-secret",
|
||||
WEBAPP_URL: "https://test.formbricks.com",
|
||||
const mockEnqueueResponsePipeline = vi.fn();
|
||||
|
||||
vi.mock("@formbricks/jobs", () => ({
|
||||
getBackgroundJobProducer: vi.fn(() => ({
|
||||
enqueueResponsePipeline: mockEnqueueResponsePipeline,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/config", () => ({
|
||||
getJobsQueueingConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
describe("sendToPipeline", () => {
|
||||
const testData: TResponsePipelineJobData = {
|
||||
event: PipelineTriggers.responseCreated,
|
||||
surveyId: "cm8ckvchx000008lb710n0gdn",
|
||||
workspaceId: "cm8cmp9hp000008jf7l570ml2",
|
||||
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
|
||||
};
|
||||
|
||||
describe("pipelines", () => {
|
||||
// Reset mocks before each test
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getJobsQueueingConfig).mockReturnValue({
|
||||
enabled: true,
|
||||
redisUrl: "redis://localhost:6379",
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("sendToPipeline should call fetch with correct parameters", async () => {
|
||||
// Mock the fetch implementation to return a successful response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
test("enqueues the pipeline job through the BullMQ producer", async () => {
|
||||
mockEnqueueResponsePipeline.mockResolvedValue({
|
||||
jobId: "job-1",
|
||||
jobName: "response-pipeline.process",
|
||||
queueName: "background-jobs",
|
||||
});
|
||||
|
||||
// Create sample data for testing
|
||||
const testData: TPipelineInput = {
|
||||
event: PipelineTriggers.responseCreated,
|
||||
surveyId: "cm8ckvchx000008lb710n0gdn",
|
||||
workspaceId: "cm8cnq2hp000008jf7l570abc",
|
||||
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
|
||||
};
|
||||
|
||||
// Call the function with test data
|
||||
await sendToPipeline(testData);
|
||||
|
||||
// Check that fetch was called with the correct arguments
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://test.formbricks.com/api/pipeline", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": "mocked-cron-secret",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: testData.workspaceId,
|
||||
surveyId: testData.surveyId,
|
||||
expect(getBackgroundJobProducer).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnqueueResponsePipeline).toHaveBeenCalledWith(testData);
|
||||
});
|
||||
|
||||
test("logs enqueue failures and rethrows", async () => {
|
||||
const testError = new Error("Redis unavailable");
|
||||
mockEnqueueResponsePipeline.mockRejectedValue(testError);
|
||||
|
||||
await expect(sendToPipeline(testData)).rejects.toThrow(testError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{
|
||||
error: testError,
|
||||
event: testData.event,
|
||||
response: testData.response,
|
||||
}),
|
||||
surveyId: testData.surveyId,
|
||||
workspaceId: testData.workspaceId,
|
||||
},
|
||||
"Error queueing pipeline event"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when BullMQ queueing is disabled", async () => {
|
||||
vi.mocked(getJobsQueueingConfig).mockReturnValue({
|
||||
enabled: false,
|
||||
redisUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("sendToPipeline should handle fetch errors", async () => {
|
||||
// Mock fetch to throw an error
|
||||
const testError = new Error("Network error");
|
||||
mockFetch.mockRejectedValueOnce(testError);
|
||||
|
||||
// Create sample data for testing
|
||||
const testData: TPipelineInput = {
|
||||
event: PipelineTriggers.responseCreated,
|
||||
surveyId: "cm8ckvchx000008lb710n0gdn",
|
||||
workspaceId: "cm8cnq2hp000008jf7l570abc",
|
||||
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
|
||||
};
|
||||
|
||||
// Call the function
|
||||
await sendToPipeline(testData);
|
||||
|
||||
// Check that the error was logged using logger
|
||||
expect(logger.error).toHaveBeenCalledWith(testError, "Error sending event to pipeline");
|
||||
});
|
||||
|
||||
test("sendToPipeline should throw error if CRON_SECRET is not set", async () => {
|
||||
// For this test, we need to mock CRON_SECRET as undefined
|
||||
// Let's use a more compatible approach to reset the mocks
|
||||
const originalModule = await import("@/lib/constants");
|
||||
const mockConstants = { ...originalModule, CRON_SECRET: undefined };
|
||||
|
||||
vi.doMock("@/lib/constants", () => mockConstants);
|
||||
|
||||
// Re-import the module to get the new mocked values
|
||||
const { sendToPipeline: sendToPipelineNoSecret } = await import("./pipelines");
|
||||
|
||||
// Create sample data for testing
|
||||
const testData: TPipelineInput = {
|
||||
event: PipelineTriggers.responseCreated,
|
||||
surveyId: "cm8ckvchx000008lb710n0gdn",
|
||||
workspaceId: "cm8cnq2hp000008jf7l570abc",
|
||||
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
|
||||
};
|
||||
|
||||
// Expect the function to throw an error
|
||||
await expect(sendToPipelineNoSecret(testData)).rejects.toThrow("CRON_SECRET is not set");
|
||||
await expect(sendToPipeline(testData)).rejects.toThrow(
|
||||
"BullMQ response pipeline queueing is not enabled"
|
||||
);
|
||||
expect(getBackgroundJobProducer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { TResponsePipelineJobData, getBackgroundJobProducer } from "@formbricks/jobs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TPipelineInput } from "@/app/lib/types/pipelines";
|
||||
import { CRON_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getJobsQueueingConfig } from "@/lib/jobs/config";
|
||||
|
||||
export const sendToPipeline = async ({ event, surveyId, workspaceId, response }: TPipelineInput) => {
|
||||
if (!CRON_SECRET) {
|
||||
throw new Error("CRON_SECRET is not set");
|
||||
export const sendToPipeline = async (job: TResponsePipelineJobData): Promise<void> => {
|
||||
try {
|
||||
const jobsQueueingConfig = getJobsQueueingConfig();
|
||||
if (!jobsQueueingConfig.enabled) {
|
||||
throw new Error("BullMQ response pipeline queueing is not enabled");
|
||||
}
|
||||
|
||||
const producer = getBackgroundJobProducer();
|
||||
await producer.enqueueResponsePipeline(job);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, event: job.event, surveyId: job.surveyId, workspaceId: job.workspaceId },
|
||||
"Error queueing pipeline event"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return fetch(`${WEBAPP_URL}/api/pipeline`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": CRON_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
surveyId,
|
||||
event,
|
||||
response,
|
||||
}),
|
||||
}).catch((error) => {
|
||||
logger.error(error, "Error sending event to pipeline");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { PipelineTriggers } from "@prisma/client";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
|
||||
export interface TPipelineInput {
|
||||
event: PipelineTriggers;
|
||||
response: TResponse;
|
||||
workspaceId: string;
|
||||
surveyId: string;
|
||||
}
|
||||
+55
-17
@@ -185,6 +185,7 @@ checksums:
|
||||
common/delete_what: 718ddfcc1dec7f3e8b67856fba838267
|
||||
common/description: e17686a22ffad04cc7bb70524ed4478b
|
||||
common/disable: 81b754fd7962e0bd9b6ba87f3972e7fc
|
||||
common/disabled: 0889a3dfd914a7ef638611796b17bf72
|
||||
common/disallow: 01c8ed3ce545ed836d3ccffc562c8a0c
|
||||
common/discard: de83a114a79d086e372c43dbfe9f47b4
|
||||
common/dismissed: f0e21b3fe28726c577a7238a63cc29c2
|
||||
@@ -304,6 +305,7 @@ checksums:
|
||||
common/not_authenticated: fed6c62208524ea6782b5f9c07a95a4f
|
||||
common/not_authorized: 4be80383fe1a6f52c61138f1aa8d01d4
|
||||
common/not_connected: 91ebf07fff6b2ead94d85bd17212e0ba
|
||||
common/not_set: 380482630d60ee2d1531b31246caa467
|
||||
common/note: e0337f202c911423275f834edeffc54b
|
||||
common/notifications: c52df856139b50dbb1cae7bfb1cf73bb
|
||||
common/number: 2789f8391f63e7200a5521078aab017d
|
||||
@@ -404,6 +406,7 @@ checksums:
|
||||
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
|
||||
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
|
||||
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
|
||||
common/soon: b12e79beb0aef9414a445a1b95dd4322
|
||||
common/sort_by: 8adf3dbc5668379558957662f0c43563
|
||||
common/start_free_trial: e346e4ed7d138dcc873db187922369da
|
||||
common/status: 4e1fcce15854d824919b4a582c697c90
|
||||
@@ -1786,6 +1789,9 @@ checksums:
|
||||
workspace/app-connection/app_connection_description: dde226414bd2265cbd0daf6635efcfdd
|
||||
workspace/app-connection/cache_update_delay_description: 3368e4a8090b7684117a16c94f0c409c
|
||||
workspace/app-connection/cache_update_delay_title: 60e4a0fcfbd8850bddf29b5c3f59550c
|
||||
workspace/app-connection/environment_id_legacy: d5c701874d34b4591e780755f7ac7a58
|
||||
workspace/app-connection/environment_id_legacy_alert: 09ac96821ff99fad4590c661503fa0cd
|
||||
workspace/app-connection/environment_id_legacy_alert_link: 25c529078a115d1ff044a321dd8ee01b
|
||||
workspace/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
||||
workspace/app-connection/formbricks_sdk_not_connected: 557c534e665750978ba6edb0eacb428e
|
||||
workspace/app-connection/formbricks_sdk_not_connected_description: 4ddbacae084238bd0cefeded0fe9dbb9
|
||||
@@ -2441,10 +2447,14 @@ checksums:
|
||||
workspace/settings/feedback_record_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
|
||||
workspace/settings/feedback_record_directories/no_access: cc3385cd01a11e3949003a2cc6fb5b31
|
||||
workspace/settings/feedback_record_directories/no_connectors: b1becb4fe4e2ba7c5d277db149f092ff
|
||||
workspace/settings/feedback_record_directories/pause_connectors_confirmation_description: a3c2c56daed9f2a9e6a853cb8b924bad
|
||||
workspace/settings/feedback_record_directories/pause_connectors_confirmation_title: 09041363c55fb2686f8115df6fa2afc1
|
||||
workspace/settings/feedback_record_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
|
||||
workspace/settings/feedback_record_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
|
||||
workspace/settings/feedback_record_directories/title: e3d425c27f80162f29ce094e31a3fd8f
|
||||
workspace/settings/feedback_record_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
|
||||
workspace/settings/feedback_record_directories/unarchive_workspace_conflict: 82f4b8ebaf41589cfb96e6398dafcc76
|
||||
workspace/settings/feedback_record_directories/workspace_access: 32407b39cf878fb579559c1ed3660892
|
||||
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
|
||||
workspace/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
|
||||
workspace/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
|
||||
@@ -3453,16 +3463,21 @@ checksums:
|
||||
workspace/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
|
||||
workspace/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
|
||||
workspace/teams/team_settings_description: 52f91883b9ceb6de83efbf8efd4f11c0
|
||||
workspace/unify/add_feedback_record: 19cf2b1fef0ca1400f2400e7ee681ea0
|
||||
workspace/unify/add_feedback_record_description: 94bca46246ba7353049b33742554b4c0
|
||||
workspace/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
|
||||
workspace/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
|
||||
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
|
||||
workspace/unify/api_ingestion: a14642d27bbb6843f9f4903b6555dfbb
|
||||
workspace/unify/api_ingestion_manage_api_keys: 116786a004fb7b16ead8a5b7a6a2debe
|
||||
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
|
||||
workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d
|
||||
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
|
||||
workspace/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
|
||||
workspace/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
|
||||
workspace/unify/collected_at: b41902ddb4586ba4a4611d726b5014aa
|
||||
workspace/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd
|
||||
workspace/unify/configure_mapping: c794411c50bc511f8fc332def0e4e2f9
|
||||
workspace/unify/connection: 421e709602c92ffbe04a266f6a092089
|
||||
workspace/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa
|
||||
workspace/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780
|
||||
workspace/unify/connector_duplicated_successfully: eb21ce42cdbef5fa38244206bf65fe4e
|
||||
@@ -3481,9 +3496,12 @@ checksums:
|
||||
workspace/unify/csv_import_duplicate_warning: 56625e4613b93690e95661e5faaa4b27
|
||||
workspace/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
|
||||
workspace/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
|
||||
workspace/unify/custom_source_type: d931a8a74d3a5becd568e398107979da
|
||||
workspace/unify/custom_source_type_placeholder: f139e3e5d70dbf426d7c6b5ab2b198cc
|
||||
workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
|
||||
workspace/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922
|
||||
workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941
|
||||
workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e
|
||||
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
|
||||
workspace/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
|
||||
workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9
|
||||
@@ -3493,47 +3511,64 @@ checksums:
|
||||
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
|
||||
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
|
||||
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
|
||||
workspace/unify/feedback_record_created_successfully: 0ff30472085f1313a5ad53837c83e7c1
|
||||
workspace/unify/feedback_record_details: 823f3353db049a9d263ef31405054cda
|
||||
workspace/unify/feedback_record_details_description: 0b6f908154161241ce6bdeb4a2acaecd
|
||||
workspace/unify/feedback_record_directory: 89a08a540d1c6eb9f0b1a4b8f56e8aca
|
||||
workspace/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
|
||||
workspace/unify/feedback_record_mcp: cdddbef2944489820fd7f376a49c2803
|
||||
workspace/unify/feedback_record_updated_successfully: cb40ef4b924e21fa627ebe6809d1d826
|
||||
workspace/unify/feedback_record_value_required: b54d4d86f82071a93dc979e8eb359cf0
|
||||
workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388
|
||||
workspace/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
|
||||
workspace/unify/feedback_sources: e58ec9be19db8789e7096a756d24f2b2
|
||||
workspace/unify/feedback_sources_directory_access_multiple: 11d613bc1e9825aa6faa3db17ae678eb
|
||||
workspace/unify/feedback_sources_directory_access_single: c9da6b30d410a0ca6302a00a5747dc19
|
||||
workspace/unify/feedback_sources_settings_description: 45f162f2f81cd195c23cb3ec490bb3df
|
||||
workspace/unify/field_group_id: 17024bb46ff1e088afb6a279dc85aad4
|
||||
workspace/unify/field_group_label: 3df09c3b6fd22310359cf955ecff5c8e
|
||||
workspace/unify/field_id: 7791b5d581b7a525dcadf11ec73c6ab7
|
||||
workspace/unify/field_label: 6384505ca0e40010c666b712511132a6
|
||||
workspace/unify/field_type: 2581066dc304c853a4a817c20996fa08
|
||||
workspace/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
|
||||
workspace/unify/frd_cannot_be_changed: 265c12529f540d8309811f4e0090272f
|
||||
workspace/unify/go_to_feedback_record_directories: 16b66b62f85e7be311778f39315d118a
|
||||
workspace/unify/historical_import_complete: f46f98bf4db63bf2993bfb234dc95f62
|
||||
workspace/unify/import_csv_data: f05e1d1ed88d528256efe5702df46646
|
||||
workspace/unify/import_feedback: f05e1d1ed88d528256efe5702df46646
|
||||
workspace/unify/import_historical_responses: d7941f65344b6bfba56a40cc53a063b4
|
||||
workspace/unify/import_historical_responses_description: c860f7c6dbe8b74383ecf9cae9c219a0
|
||||
workspace/unify/import_rows: d2963498a7d2766264c4d67db677e8ff
|
||||
workspace/unify/import_via_source_name: eae32ae2fc87f925ca016fe8283bcbfd
|
||||
workspace/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984
|
||||
workspace/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20
|
||||
workspace/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6
|
||||
workspace/unify/invalid_values_found: 5011dc9c0294a222033f9910ea919b8a
|
||||
workspace/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
|
||||
workspace/unify/n_supported_questions: d75413d386441b5eb137a1ea191e4bd9
|
||||
workspace/unify/manage_directories: 460e00e1cbf1f51de57a2548546e33d7
|
||||
workspace/unify/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8
|
||||
workspace/unify/metadata: 695d4f7da261ba76e3be4de495491028
|
||||
workspace/unify/metadata_key: c478d228673f59fa556208ece60452f6
|
||||
workspace/unify/metadata_read_only_entries: 1934fee46c0a117f4926b61cc3d2d602
|
||||
workspace/unify/metadata_value: 8d69be1f5a20d9473a33c35670dff216
|
||||
workspace/unify/missing_feedback_source_title: 9ab1b8d54b4da72dd00ce03fe3b698b5
|
||||
workspace/unify/no_feedback_record_directory_available: b8126ef5d6276d9655a9b27ffcaca824
|
||||
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
|
||||
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
|
||||
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
|
||||
workspace/unify/no_surveys_found: 649a2f29b4c34525778d9177605fb326
|
||||
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
workspace/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
|
||||
workspace/unify/question_selected: b9ff13b6212874258da911867932dc7d
|
||||
workspace/unify/question_type_not_supported: 8d9f7554e3b509dfd5307d8d1fef08d7
|
||||
workspace/unify/questions_selected: 1f13d6fecafa2ce5ea9e6d07078a1d38
|
||||
workspace/unify/records_will_go_to: 6a3f5a6580857a931bab389ad354831c
|
||||
workspace/unify/refresh_feedback_records: c111751e02a7dee57390ed7fb79cfcc6
|
||||
workspace/unify/refreshing_feedback_records: 2a03b44510ebe19eea6473639e9a7222
|
||||
workspace/unify/request_feedback_source: 51045caa2c81dee971d23a1841d19a7e
|
||||
workspace/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||
workspace/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
|
||||
workspace/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
|
||||
workspace/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
|
||||
workspace/unify/select_all: eedc7cdb02de467c15dc418a066a77f2
|
||||
workspace/unify/select_feedback_record_directory: 88afbf2c2a322249908ee5d00ec5f65d
|
||||
workspace/unify/select_feedback_record_source_type: 10997fcbea2f93e756888cf7a7476fdf
|
||||
workspace/unify/select_questions: 13c79b8c284423eb6140534bf2137e56
|
||||
workspace/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc
|
||||
workspace/unify/select_source_type_prompt: c3fce7d908ee62b9e1b7fab1b17606d7
|
||||
workspace/unify/select_survey: bac52e59c7847417bef6fe7b7096b475
|
||||
workspace/unify/select_survey_and_questions: 53914988a2f48caecea23f3b3b868b9f
|
||||
workspace/unify/select_survey_questions_description: 3386ed56085eabebefa3cc453269fc5b
|
||||
@@ -3543,20 +3578,19 @@ checksums:
|
||||
workspace/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131
|
||||
workspace/unify/source: 45309626f464f4bda161ee783a4c8c80
|
||||
workspace/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
|
||||
workspace/unify/source_connect_feedback_record_mcp_description: a3f56e2a6e403f4021e83f1b1a466d95
|
||||
workspace/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
|
||||
workspace/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
|
||||
workspace/unify/source_id: 134a9a7d473508c5623ac724a5ba4be9
|
||||
workspace/unify/source_name: 157675beca12efcd8ec512c5256b1a61
|
||||
workspace/unify/source_type: d1ff69af76c687eb189db72030717570
|
||||
workspace/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
|
||||
workspace/unify/sources: ecbbe6e49baa335c5afd7b04b609d006
|
||||
workspace/unify/status_active: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
workspace/unify/status_completed: 0e4bbce9985f25eb673d9a054c8d5334
|
||||
workspace/unify/status_draft: e8a92958ad300aacfe46c2bf6644927e
|
||||
workspace/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
workspace/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
|
||||
workspace/unify/status_live_sync: 7e794257419414f57d34845ef38d0939
|
||||
workspace/unify/status_ready: 437c0eea608e15ad5cdab94bde2f4b48
|
||||
workspace/unify/submission_id: 02edf76883b47079dbe20f3f36b7c1a7
|
||||
workspace/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d
|
||||
workspace/unify/survey_import_line: 63fa0ea1d7daa3ba333436fbc65f8b19
|
||||
workspace/unify/total_feedback_records: 8962087650b62e4a12b81e7d09317ffa
|
||||
workspace/unify/topics_and_subtopics: 1148eca01a1993fadca932efcdea7641
|
||||
workspace/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
|
||||
workspace/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
|
||||
workspace/unify/updated_at: 8fdb85248e591254973403755dcc3724
|
||||
@@ -3564,6 +3598,10 @@ checksums:
|
||||
workspace/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
|
||||
workspace/unify/user_identifier: 61073457a5c3901084b557d065f876be
|
||||
workspace/unify/value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||
workspace/unify/value_boolean: bbdcd3f46954b6304b9069e94e1371ab
|
||||
workspace/unify/value_date: c8d705d1975affc01c002324725fec3f
|
||||
workspace/unify/value_number: 1f14da79d14bd7b1c2324141f4470675
|
||||
workspace/unify/value_text: e097a597cc507c716401ad18255de578
|
||||
workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9
|
||||
workspace/xm-templates/ces_description: c8d9794dd17d5ab85a979f1b3e1bc935
|
||||
workspace/xm-templates/csat: fdfc1dc6214cce661dcdc32a71d80337
|
||||
|
||||
@@ -39,12 +39,12 @@ vi.mock("@formbricks/logger", () => ({
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
AI_PROVIDER: "gcp",
|
||||
AI_PROVIDER: "google",
|
||||
AI_MODEL: "gemini-2.5-flash",
|
||||
AI_GCP_PROJECT: "vertex-project",
|
||||
AI_GCP_LOCATION: "us-central1",
|
||||
AI_GCP_CREDENTIALS_JSON: undefined,
|
||||
AI_GCP_APPLICATION_CREDENTIALS: "/tmp/vertex.json",
|
||||
AI_GOOGLE_CLOUD_PROJECT: "google-cloud-project",
|
||||
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
|
||||
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: undefined,
|
||||
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: "/tmp/google-cloud.json",
|
||||
AI_AWS_REGION: "us-east-1",
|
||||
AI_AWS_ACCESS_KEY_ID: "aws-access-key-id",
|
||||
AI_AWS_SECRET_ACCESS_KEY: "aws-secret-access-key",
|
||||
@@ -144,9 +144,9 @@ describe("AI organization service", () => {
|
||||
prompt: "Translate this survey",
|
||||
},
|
||||
expect.objectContaining({
|
||||
AI_PROVIDER: "gcp",
|
||||
AI_PROVIDER: "google",
|
||||
AI_MODEL: "gemini-2.5-flash",
|
||||
AI_GCP_PROJECT: "vertex-project",
|
||||
AI_GOOGLE_CLOUD_PROJECT: "google-cloud-project",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ZConnectorUpdateInput,
|
||||
getHubFieldTypeFromElementType,
|
||||
} from "@formbricks/types/connector";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { AuthorizationError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
@@ -86,20 +86,24 @@ const resolveSurveyMappings = async (
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const elementMap = new Map(elements.map((el) => [el.id, el]));
|
||||
|
||||
return elementIds
|
||||
.filter((elementId) => {
|
||||
if (elementMap.has(elementId)) return true;
|
||||
return elementIds.flatMap((elementId) => {
|
||||
const element = elementMap.get(elementId);
|
||||
if (!element) {
|
||||
logger.warn({ surveyId, elementId }, "Skipping unknown elementId when building connector mappings");
|
||||
return false;
|
||||
})
|
||||
.map((elementId) => {
|
||||
const element = elementMap.get(elementId)!;
|
||||
return {
|
||||
surveyId,
|
||||
elementId,
|
||||
hubFieldType: getHubFieldTypeFromElementType(element.type),
|
||||
};
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const hubFieldType = getHubFieldTypeFromElementType(element.type);
|
||||
if (!hubFieldType) {
|
||||
logger.warn(
|
||||
{ surveyId, elementId, elementType: element.type },
|
||||
"Skipping unmappable element type when building connector mappings"
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ surveyId, elementId, hubFieldType }];
|
||||
});
|
||||
};
|
||||
|
||||
const resolveFormbricksMappingsInput = async (
|
||||
@@ -108,7 +112,12 @@ const resolveFormbricksMappingsInput = async (
|
||||
const allMappings = await Promise.all(
|
||||
entries.map(({ surveyId, elementIds }) => resolveSurveyMappings(surveyId, elementIds))
|
||||
);
|
||||
return { type: "formbricks", mappings: allMappings.flat() };
|
||||
const flattenedMappings = allMappings.flat();
|
||||
if (flattenedMappings.length === 0) {
|
||||
throw new InvalidInputError("No supported survey questions selected for connector mapping");
|
||||
}
|
||||
|
||||
return { type: "formbricks_survey", mappings: flattenedMappings };
|
||||
};
|
||||
|
||||
const ZFormbricksSurveyMapping = z.object({
|
||||
@@ -116,15 +125,23 @@ const ZFormbricksSurveyMapping = z.object({
|
||||
elementIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
// Temporary compatibility to support legacy client payloads using `formbricks`.
|
||||
const ZConnectorCreateInputWithLegacyType = ZConnectorCreateInput.extend({
|
||||
type: z.enum(["formbricks_survey", "csv", "formbricks"]),
|
||||
});
|
||||
|
||||
const ZCreateConnectorWithMappingsAction = z
|
||||
.object({
|
||||
workspaceId: ZId,
|
||||
connectorInput: ZConnectorCreateInput,
|
||||
connectorInput: ZConnectorCreateInputWithLegacyType,
|
||||
formbricksMappings: z.array(ZFormbricksSurveyMapping).optional(),
|
||||
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.connectorInput.type === "formbricks") {
|
||||
const normalizedType =
|
||||
data.connectorInput.type === "formbricks" ? "formbricks_survey" : data.connectorInput.type;
|
||||
|
||||
if (normalizedType === "formbricks_survey") {
|
||||
if (!data.formbricksMappings?.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
@@ -132,7 +149,7 @@ const ZCreateConnectorWithMappingsAction = z
|
||||
message: "At least one survey mapping is required for Formbricks connectors",
|
||||
});
|
||||
}
|
||||
} else if (data.connectorInput.type === "csv") {
|
||||
} else if (normalizedType === "csv") {
|
||||
if (!data.fieldMappings?.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
@@ -146,6 +163,14 @@ const ZCreateConnectorWithMappingsAction = z
|
||||
export const createConnectorWithMappingsAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateConnectorWithMappingsAction)
|
||||
.action(async ({ ctx, parsedInput }): Promise<TConnectorWithMappings> => {
|
||||
const connectorInput = ZConnectorCreateInput.parse({
|
||||
...parsedInput.connectorInput,
|
||||
type:
|
||||
parsedInput.connectorInput.type === "formbricks"
|
||||
? "formbricks_survey"
|
||||
: parsedInput.connectorInput.type,
|
||||
});
|
||||
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -165,7 +190,7 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
|
||||
|
||||
// Verify FRD belongs to same org
|
||||
const frd = await prisma.feedbackRecordDirectory.findUnique({
|
||||
where: { id: parsedInput.connectorInput.feedbackRecordDirectoryId },
|
||||
where: { id: connectorInput.feedbackRecordDirectoryId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (frd?.organizationId !== organizationId) {
|
||||
@@ -193,7 +218,7 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
|
||||
|
||||
return createConnectorWithMappings(
|
||||
parsedInput.workspaceId,
|
||||
{ ...parsedInput.connectorInput, createdBy: ctx.user.id },
|
||||
{ ...connectorInput, createdBy: ctx.user.id },
|
||||
mappingsInput
|
||||
);
|
||||
});
|
||||
@@ -298,9 +323,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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TConnectorFieldMapping } from "@formbricks/types/connector";
|
||||
import { transformCsvRowToFeedbackRecord, transformCsvRowsToFeedbackRecords } from "./csv-transform";
|
||||
|
||||
const NOW = new Date("2026-02-25T10:00:00.000Z");
|
||||
const TENANT = "tenant-test";
|
||||
|
||||
const makeMapping = (
|
||||
sourceFieldId: string,
|
||||
@@ -34,7 +35,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.source_type).toBe("survey");
|
||||
@@ -42,13 +43,77 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
expect(result!.field_type).toBe("text");
|
||||
expect(result!.value_text).toBe("Great product!");
|
||||
expect(result!.collected_at).toBe("2026-01-15T10:00:00.000Z");
|
||||
expect(result!.tenant_id).toBe(TENANT);
|
||||
});
|
||||
|
||||
test("returns null when required fields are missing", () => {
|
||||
const row = { feedback_text: "Great product!" };
|
||||
const mappings = [makeMapping("feedback_text", "value_text")];
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when tenant_id is missing", () => {
|
||||
const row = {
|
||||
feedback_text: "Great product!",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("auto-generates submission_id as a UUID when unmapped", () => {
|
||||
const row = {
|
||||
feedback_text: "Great product!",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const a = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
|
||||
const b = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
|
||||
|
||||
expect(a!.submission_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
expect(b!.submission_id).not.toBe(a!.submission_id);
|
||||
});
|
||||
|
||||
test("uses explicit submission_id mapping when provided", () => {
|
||||
const mappings = [...baseMappings, makeMapping("order_id", "submission_id")];
|
||||
const row = {
|
||||
feedback_text: "x",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15",
|
||||
order_id: "ORD-42",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
|
||||
expect(result!.submission_id).toBe("ORD-42");
|
||||
});
|
||||
|
||||
test("returns null when submission_id mapped but cell is empty", () => {
|
||||
const mappings = [...baseMappings, makeMapping("order_id", "submission_id")];
|
||||
const row = {
|
||||
feedback_text: "x",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15",
|
||||
order_id: "",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when submission_id mapped but column missing from row", () => {
|
||||
const mappings = [...baseMappings, makeMapping("order_id", "submission_id")];
|
||||
const row = {
|
||||
feedback_text: "x",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@@ -61,7 +126,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
rating: "4.5",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
|
||||
expect(result!.value_number).toBe(4.5);
|
||||
});
|
||||
|
||||
@@ -74,7 +139,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
rating: "not-a-number",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
|
||||
expect(result!.value_number).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -84,21 +149,24 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "true" },
|
||||
mappings
|
||||
mappings,
|
||||
TENANT
|
||||
)!.value_boolean
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "0" },
|
||||
mappings
|
||||
mappings,
|
||||
TENANT
|
||||
)!.value_boolean
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "yes" },
|
||||
mappings
|
||||
mappings,
|
||||
TENANT
|
||||
)!.value_boolean
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -114,7 +182,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
makeMapping("", "collected_at", "$now"),
|
||||
];
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord({ question: "q1" }, mappings);
|
||||
const result = transformCsvRowToFeedbackRecord({ question: "q1" }, mappings, TENANT);
|
||||
expect(result!.collected_at).toBe(NOW.toISOString());
|
||||
|
||||
vi.useRealTimers();
|
||||
@@ -129,7 +197,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
];
|
||||
|
||||
const row = { question: "q1", type_column: "review", timestamp: "2026-01-15" };
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
|
||||
expect(result!.source_type).toBe("always_survey");
|
||||
});
|
||||
|
||||
@@ -140,7 +208,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
|
||||
expect(result!.value_text).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -153,7 +221,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
meta: '{"device":"mobile","version":"2.1"}',
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
|
||||
expect(result!.metadata).toEqual({ device: "mobile", version: "2.1" });
|
||||
});
|
||||
|
||||
@@ -166,7 +234,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
meta: "just a string",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
|
||||
expect(result!.metadata).toEqual({ raw: "just a string" });
|
||||
});
|
||||
|
||||
@@ -177,7 +245,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
timestamp: "not-a-date",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
|
||||
expect(result!.collected_at).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -198,16 +266,19 @@ describe("transformCsvRowsToFeedbackRecords", () => {
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords(rows, mappings);
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords(rows, mappings, TENANT);
|
||||
|
||||
expect(records).toHaveLength(2);
|
||||
expect(skipped).toBe(1);
|
||||
expect(records[0].field_id).toBe("q1");
|
||||
expect(records[1].field_id).toBe("q2");
|
||||
expect(records[0].submission_id).toBeTruthy();
|
||||
expect(records[1].submission_id).toBeTruthy();
|
||||
expect(records[0].submission_id).not.toBe(records[1].submission_id);
|
||||
});
|
||||
|
||||
test("returns empty records for empty input", () => {
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords([], baseMappings);
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords([], baseMappings, TENANT);
|
||||
expect(records).toHaveLength(0);
|
||||
expect(skipped).toBe(0);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { TConnectorFieldMapping, THubTargetField } from "@formbricks/types/connector";
|
||||
import { FeedbackRecordCreateParams } from "@/modules/hub";
|
||||
|
||||
@@ -50,8 +51,10 @@ const resolveValue = (
|
||||
/**
|
||||
* Transform a single CSV row into a FeedbackRecord using field mappings.
|
||||
*
|
||||
* Each mapping maps a CSV column (sourceFieldId) or a static value to a target field.
|
||||
* Returns null if required fields (source_type, field_id, field_type) are missing after mapping.
|
||||
* Returns null if any of source_type, field_id, field_type, tenant_id are missing,
|
||||
* or if submission_id is mapped but resolves empty for this row (would break
|
||||
* idempotency on re-import). Falls back to a random UUID for submission_id only
|
||||
* when no mapping for it exists.
|
||||
*/
|
||||
export const transformCsvRowToFeedbackRecord = (
|
||||
row: Record<string, string>,
|
||||
@@ -83,6 +86,18 @@ export const transformCsvRowToFeedbackRecord = (
|
||||
record.tenant_id = tenantId;
|
||||
}
|
||||
|
||||
if (!record.tenant_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!("submission_id" in record)) {
|
||||
const submissionMapped = mappings.some((m) => m.targetFieldId === "submission_id");
|
||||
if (submissionMapped) {
|
||||
return null;
|
||||
}
|
||||
record.submission_id = randomUUID();
|
||||
}
|
||||
|
||||
return record as unknown as FeedbackRecordCreateParams;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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" }],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
+47
-28
@@ -6,10 +6,10 @@ const ZActiveAIProvider = z.enum(AI_PROVIDERS);
|
||||
const ZAIConfigurationEnv = z.object({
|
||||
AI_PROVIDER: ZActiveAIProvider.optional(),
|
||||
AI_MODEL: z.string().optional(),
|
||||
AI_GCP_PROJECT: z.string().optional(),
|
||||
AI_GCP_LOCATION: z.string().optional(),
|
||||
AI_GCP_CREDENTIALS_JSON: z.string().optional(),
|
||||
AI_GCP_APPLICATION_CREDENTIALS: z.string().optional(),
|
||||
AI_GOOGLE_CLOUD_PROJECT: z.string().optional(),
|
||||
AI_GOOGLE_CLOUD_LOCATION: z.string().optional(),
|
||||
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: z.string().optional(),
|
||||
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: z.string().optional(),
|
||||
AI_AWS_REGION: z.string().optional(),
|
||||
AI_AWS_ACCESS_KEY_ID: z.string().optional(),
|
||||
AI_AWS_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
@@ -20,6 +20,9 @@ const ZAIConfigurationEnv = z.object({
|
||||
|
||||
type TAIConfigurationEnv = z.infer<typeof ZAIConfigurationEnv>;
|
||||
|
||||
const isJsonObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const addEnvIssue = (ctx: z.RefinementCtx, path: keyof TAIConfigurationEnv, message: string): void => {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
@@ -48,28 +51,44 @@ const validateAwsAIConfiguration = (values: TAIConfigurationEnv, ctx: z.Refineme
|
||||
}
|
||||
};
|
||||
|
||||
const validateGcpAIConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
|
||||
if (!values.AI_GCP_PROJECT) {
|
||||
addEnvIssue(ctx, "AI_GCP_PROJECT", "AI_GCP_PROJECT is required when AI_PROVIDER=gcp");
|
||||
}
|
||||
|
||||
if (!values.AI_GCP_LOCATION) {
|
||||
addEnvIssue(ctx, "AI_GCP_LOCATION", "AI_GCP_LOCATION is required when AI_PROVIDER=gcp");
|
||||
}
|
||||
|
||||
if (!values.AI_GCP_CREDENTIALS_JSON && !values.AI_GCP_APPLICATION_CREDENTIALS) {
|
||||
const validateGoogleAIConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
|
||||
if (!values.AI_GOOGLE_CLOUD_PROJECT) {
|
||||
addEnvIssue(
|
||||
ctx,
|
||||
"AI_GCP_CREDENTIALS_JSON",
|
||||
"AI_GCP_CREDENTIALS_JSON or AI_GCP_APPLICATION_CREDENTIALS is required when AI_PROVIDER=gcp"
|
||||
"AI_GOOGLE_CLOUD_PROJECT",
|
||||
"AI_GOOGLE_CLOUD_PROJECT is required when AI_PROVIDER=google"
|
||||
);
|
||||
}
|
||||
|
||||
if (values.AI_GCP_CREDENTIALS_JSON) {
|
||||
if (!values.AI_GOOGLE_CLOUD_LOCATION) {
|
||||
addEnvIssue(
|
||||
ctx,
|
||||
"AI_GOOGLE_CLOUD_LOCATION",
|
||||
"AI_GOOGLE_CLOUD_LOCATION is required when AI_PROVIDER=google"
|
||||
);
|
||||
}
|
||||
|
||||
if (!values.AI_GOOGLE_CLOUD_CREDENTIALS_JSON && !values.AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS) {
|
||||
addEnvIssue(
|
||||
ctx,
|
||||
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON",
|
||||
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON or AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS is required when AI_PROVIDER=google"
|
||||
);
|
||||
}
|
||||
|
||||
if (values.AI_GOOGLE_CLOUD_CREDENTIALS_JSON) {
|
||||
try {
|
||||
JSON.parse(values.AI_GCP_CREDENTIALS_JSON);
|
||||
const parsedCredentials = JSON.parse(values.AI_GOOGLE_CLOUD_CREDENTIALS_JSON) as unknown;
|
||||
|
||||
if (!isJsonObject(parsedCredentials)) {
|
||||
throw new Error("AI_GOOGLE_CLOUD_CREDENTIALS_JSON must be a JSON object");
|
||||
}
|
||||
} catch {
|
||||
addEnvIssue(ctx, "AI_GCP_CREDENTIALS_JSON", "AI_GCP_CREDENTIALS_JSON must be valid JSON");
|
||||
addEnvIssue(
|
||||
ctx,
|
||||
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON",
|
||||
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON must be a valid JSON object"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -100,7 +119,7 @@ const validateActiveAIProviderConfiguration = (values: TAIConfigurationEnv, ctx:
|
||||
(values: TAIConfigurationEnv, ctx: z.RefinementCtx) => void
|
||||
> = {
|
||||
aws: validateAwsAIConfiguration,
|
||||
gcp: validateGcpAIConfiguration,
|
||||
google: validateGoogleAIConfiguration,
|
||||
azure: validateAzureAIConfiguration,
|
||||
};
|
||||
|
||||
@@ -160,10 +179,10 @@ const parsedEnv = createEnv({
|
||||
GITHUB_SECRET: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
AI_GCP_PROJECT: z.string().optional(),
|
||||
AI_GCP_LOCATION: z.string().optional(),
|
||||
AI_GCP_CREDENTIALS_JSON: z.string().optional(),
|
||||
AI_GCP_APPLICATION_CREDENTIALS: z.string().optional(),
|
||||
AI_GOOGLE_CLOUD_PROJECT: z.string().optional(),
|
||||
AI_GOOGLE_CLOUD_LOCATION: z.string().optional(),
|
||||
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: z.string().optional(),
|
||||
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: z.string().optional(),
|
||||
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
|
||||
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
|
||||
@@ -315,10 +334,10 @@ const parsedEnv = createEnv({
|
||||
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
AI_GCP_PROJECT: process.env.AI_GCP_PROJECT,
|
||||
AI_GCP_LOCATION: process.env.AI_GCP_LOCATION,
|
||||
AI_GCP_CREDENTIALS_JSON: process.env.AI_GCP_CREDENTIALS_JSON,
|
||||
AI_GCP_APPLICATION_CREDENTIALS: process.env.AI_GCP_APPLICATION_CREDENTIALS,
|
||||
AI_GOOGLE_CLOUD_PROJECT: process.env.AI_GOOGLE_CLOUD_PROJECT,
|
||||
AI_GOOGLE_CLOUD_LOCATION: process.env.AI_GOOGLE_CLOUD_LOCATION,
|
||||
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: process.env.AI_GOOGLE_CLOUD_CREDENTIALS_JSON,
|
||||
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: process.env.AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS,
|
||||
GOOGLE_SHEETS_CLIENT_ID: process.env.GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: process.env.GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
|
||||
|
||||
export type TElementResponseMappingSurvey = Pick<TSurvey, "blocks" | "languages">;
|
||||
|
||||
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
|
||||
export const convertResponseValue = (
|
||||
answer: TResponseDataValue,
|
||||
@@ -34,7 +36,7 @@ export const convertResponseValue = (
|
||||
};
|
||||
|
||||
export const getElementResponseMapping = (
|
||||
survey: TSurvey,
|
||||
survey: TElementResponseMappingSurvey,
|
||||
response: TResponse
|
||||
): { element: string; response: string | string[]; type: TSurveyElementTypeEnum }[] => {
|
||||
const elementResponseMapping: {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { isLight, mixColor } from "@/lib/utils/colors";
|
||||
|
||||
export const COLOR_DEFAULTS = {
|
||||
brandColor: "#64748b",
|
||||
questionColor: "#2b2524",
|
||||
inputColor: "#ffffff",
|
||||
elementHeadlineColor: "#2b2524",
|
||||
inputBgColor: "#ffffff",
|
||||
inputBorderColor: "#cbd5e1",
|
||||
cardBackgroundColor: "#ffffff",
|
||||
cardBorderColor: "#f8fafc",
|
||||
@@ -40,10 +40,8 @@ export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) =>
|
||||
return {
|
||||
// General
|
||||
"brandColor.light": brandColor,
|
||||
"questionColor.light": questionColor,
|
||||
|
||||
// Headlines & Descriptions — use questionColor to match the legacy behaviour
|
||||
// where all text elements derived their color from questionColor.
|
||||
// Headlines & Descriptions
|
||||
"elementHeadlineColor.light": questionColor,
|
||||
"elementDescriptionColor.light": questionColor,
|
||||
"elementUpperLabelColor.light": questionColor,
|
||||
@@ -53,7 +51,7 @@ export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) =>
|
||||
"buttonTextColor.light": isLight(brandColor) ? "#0f172a" : "#ffffff",
|
||||
|
||||
// Inputs
|
||||
"inputColor.light": inputBg,
|
||||
"inputBgColor.light": inputBg,
|
||||
"inputBorderColor.light": inputBorder,
|
||||
"inputTextColor.light": questionColor,
|
||||
|
||||
@@ -94,8 +92,6 @@ const _colors = getSuggestedColors(DEFAULT_BRAND_COLOR);
|
||||
export const STYLE_DEFAULTS: TWorkspaceStyling = {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: { light: _colors["brandColor.light"] },
|
||||
questionColor: { light: _colors["questionColor.light"] },
|
||||
inputColor: { light: _colors["inputColor.light"] },
|
||||
inputBorderColor: { light: _colors["inputBorderColor.light"] },
|
||||
cardBackgroundColor: { light: _colors["cardBackgroundColor.light"] },
|
||||
cardBorderColor: { light: _colors["cardBorderColor.light"] },
|
||||
@@ -117,6 +113,7 @@ export const STYLE_DEFAULTS: TWorkspaceStyling = {
|
||||
elementUpperLabelFontWeight: 400,
|
||||
|
||||
// Inputs
|
||||
inputBgColor: { light: _colors["inputBgColor.light"] },
|
||||
inputTextColor: { light: _colors["inputTextColor.light"] },
|
||||
inputBorderRadius: 8,
|
||||
inputHeight: 20,
|
||||
@@ -151,43 +148,6 @@ export const STYLE_DEFAULTS: TWorkspaceStyling = {
|
||||
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
|
||||
*
|
||||
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
|
||||
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
|
||||
*
|
||||
* When loading v4.6 data the new fields are absent. Without this helper the
|
||||
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
|
||||
* colour), causing a visible mismatch. This function derives the new fields
|
||||
* from the actually-saved legacy fields so the preview and form stay coherent.
|
||||
*
|
||||
* Only sets a field when the legacy source exists AND the new field is absent.
|
||||
*/
|
||||
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
|
||||
const light = (key: string): string | undefined =>
|
||||
(saved[key] as { light?: string } | null | undefined)?.light;
|
||||
|
||||
const q = light("questionColor");
|
||||
const b = light("brandColor");
|
||||
const i = light("inputColor");
|
||||
const inputBorder = light("inputBorderColor");
|
||||
|
||||
return {
|
||||
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
|
||||
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
|
||||
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
|
||||
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
|
||||
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
|
||||
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
|
||||
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
|
||||
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
|
||||
...(inputBorder && !saved.optionBorderColor && { optionBorderColor: { light: inputBorder } }),
|
||||
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
|
||||
...(b && !saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a complete TWorkspaceStyling object from a single brand color.
|
||||
*
|
||||
@@ -203,13 +163,12 @@ export const buildStylingFromBrandColor = (brandColor: string = DEFAULT_BRAND_CO
|
||||
return {
|
||||
...STYLE_DEFAULTS,
|
||||
brandColor: { light: colors["brandColor.light"] },
|
||||
questionColor: { light: colors["questionColor.light"] },
|
||||
elementHeadlineColor: { light: colors["elementHeadlineColor.light"] },
|
||||
elementDescriptionColor: { light: colors["elementDescriptionColor.light"] },
|
||||
elementUpperLabelColor: { light: colors["elementUpperLabelColor.light"] },
|
||||
buttonBgColor: { light: colors["buttonBgColor.light"] },
|
||||
buttonTextColor: { light: colors["buttonTextColor.light"] },
|
||||
inputColor: { light: colors["inputColor.light"] },
|
||||
inputBgColor: { light: colors["inputBgColor.light"] },
|
||||
inputBorderColor: { light: colors["inputBorderColor.light"] },
|
||||
inputTextColor: { light: colors["inputTextColor.light"] },
|
||||
optionBgColor: { light: colors["optionBgColor.light"] },
|
||||
|
||||
@@ -12,6 +12,7 @@ const selectWorkspace = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
legacyEnvironmentId: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
languages: true,
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "{deleteWhat} löschen",
|
||||
"description": "Beschreibung",
|
||||
"disable": "Deaktivieren",
|
||||
"disabled": "Deaktiviert",
|
||||
"disallow": "Nicht erlauben",
|
||||
"discard": "Verwerfen",
|
||||
"dismissed": "Verworfen",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
|
||||
"not_authorized": "Nicht autorisiert",
|
||||
"not_connected": "Nicht verbunden",
|
||||
"not_set": "Nicht festgelegt",
|
||||
"note": "Hinweis",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"number": "Nummer",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden",
|
||||
"something_went_wrong": "Etwas ist schiefgelaufen",
|
||||
"something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||
"soon": "Bald",
|
||||
"sort_by": "Sortieren nach",
|
||||
"start_free_trial": "Kostenlose Testversion starten",
|
||||
"status": "Status",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "Verbinde deine App oder Website mit Formbricks.",
|
||||
"cache_update_delay_description": "Wenn du Aktualisierungen an Umfragen, Kontakten, Aktionen oder anderen Daten vornimmst, kann es bis zu 1 Minute dauern, bis diese Änderungen in deiner lokalen App mit dem Formbricks SDK sichtbar werden.",
|
||||
"cache_update_delay_title": "Änderungen werden nach ~1 Minute durch Caching übernommen",
|
||||
"environment_id_legacy": "Umgebungs-ID (veraltet)",
|
||||
"environment_id_legacy_alert": "Deine bestehende SDK-Konfiguration verwendet möglicherweise noch eine veraltete Umgebungs-ID.",
|
||||
"environment_id_legacy_alert_link": "Erfahre, warum und wie du migrieren kannst.",
|
||||
"formbricks_sdk_connected": "Formbricks SDK ist verbunden",
|
||||
"formbricks_sdk_not_connected": "Formbricks SDK ist noch nicht verbunden.",
|
||||
"formbricks_sdk_not_connected_description": "Füge das Formbricks SDK zu deiner Website oder App hinzu, um es mit Formbricks zu verbinden",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "Feedback-Verzeichnisse",
|
||||
"no_access": "Du hast keine Berechtigung, Feedback-Datensatz-Verzeichnisse zu verwalten.",
|
||||
"no_connectors": "Noch keine Connectoren mit diesem Verzeichnis verknüpft.",
|
||||
"pause_connectors_confirmation_description": "Wenn du diese Connectoren pausierst, werden keine neuen Datensätze mehr hinzugefügt.",
|
||||
"pause_connectors_confirmation_title": "Verknüpfte Connectoren pausieren?",
|
||||
"select_workspaces_placeholder": "Workspaces auswählen...",
|
||||
"show_archived": "Archivierte anzeigen",
|
||||
"title": "Feedback-Datensatz-Verzeichnisse",
|
||||
"unarchive": "Aus Archiv wiederherstellen"
|
||||
"unarchive": "Aus Archiv wiederherstellen",
|
||||
"unarchive_workspace_conflict": "Dieses Verzeichnis kann nicht wiederhergestellt werden, weil ein oder mehrere zugewiesene Workspaces archiviert sind.",
|
||||
"workspace_access": "Workspace-Zugriff"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Datenanreicherung & -analyse (KI)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "Sieh nach, welche Teams auf diesen Workspace zugreifen können."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Feedback-Datensatz hinzufügen",
|
||||
"add_feedback_record_description": "Erstellen Sie manuell einen Feedback-Datensatz.",
|
||||
"add_feedback_source": "Feedback-Quelle hinzufügen",
|
||||
"add_source": "Quelle hinzufügen",
|
||||
"allowed_values": "Zulässige Werte: {values}",
|
||||
"api_ingestion": "API-Erfassung",
|
||||
"api_ingestion_manage_api_keys": "API-Schlüssel verwalten",
|
||||
"api_ingestion_settings_description": "Sende Feedback-Datensätze über die Management-API.",
|
||||
"auto_generated": "Automatisch generiert",
|
||||
"change_file": "Datei ändern",
|
||||
"click_load_sample_csv": "Klick auf 'Beispiel-CSV laden', um Spalten zu sehen",
|
||||
"click_to_upload": "Zum Hochladen klicken",
|
||||
"collected_at": "Erfasst am",
|
||||
"configure_import": "Import konfigurieren",
|
||||
"configure_mapping": "Mapping konfigurieren",
|
||||
"connection": "Verbindung",
|
||||
"connector_created_successfully": "Connector erfolgreich erstellt",
|
||||
"connector_deleted_successfully": "Connector erfolgreich gelöscht",
|
||||
"connector_duplicated_successfully": "Connector erfolgreich dupliziert",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "Wenn Du die Daten zweimal importierst, entstehen doppelte Einträge.",
|
||||
"csv_inconsistent_columns": "Zeile {row} hat inkonsistente Spalten. Alle Zeilen müssen die gleichen Überschriften haben.",
|
||||
"csv_max_records": "Maximal {max} Einträge erlaubt.",
|
||||
"custom_source_type": "Benutzerdefinierter Quelltyp",
|
||||
"custom_source_type_placeholder": "Geben Sie den benutzerdefinierten Quelltyp ein",
|
||||
"default_connector_name_csv": "CSV-Import",
|
||||
"default_connector_name_formbricks": "Formbricks-Umfrage-Verbindung",
|
||||
"deselect_all": "Alle abwählen",
|
||||
"discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.",
|
||||
"discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?",
|
||||
"drop_a_field_here": "Ziehe ein Feld hierher",
|
||||
"drop_field_or": "Feld ablegen oder",
|
||||
"edit_csv_mapping": "CSV-Zuordnung bearbeiten",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "Aufzählung",
|
||||
"failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden",
|
||||
"feedback_date": "Aktuelles Datum",
|
||||
"feedback_record_created_successfully": "Feedback-Datensatz erfolgreich erstellt",
|
||||
"feedback_record_details": "Details zum Feedback-Datensatz",
|
||||
"feedback_record_details_description": "Überprüfen und aktualisieren Sie die Felder des Feedback-Datensatzes.",
|
||||
"feedback_record_directory": "Feedback-Datensatz-Verzeichnis",
|
||||
"feedback_record_fields": "Feedback-Eintragsfelder",
|
||||
"feedback_record_mcp": "Feedback-Datensatz MCP",
|
||||
"feedback_record_updated_successfully": "Feedback-Datensatz erfolgreich aktualisiert",
|
||||
"feedback_record_value_required": "Für den ausgewählten Feldtyp ist ein Wert erforderlich",
|
||||
"feedback_records": "Feedback-Einträge",
|
||||
"feedback_records_refreshed": "Feedback-Einträge aktualisiert",
|
||||
"feedback_sources": "Feedback-Quellen",
|
||||
"feedback_sources_directory_access_multiple": "Neue Datensätze aus diesen Quellen werden gespeichert in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Neue Datensätze aus dieser Quelle werden gespeichert in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Verbinde und verwalte alle Feedback-Quellen für diesen Workspace.",
|
||||
"field_group_id": "Feldgruppen-ID",
|
||||
"field_group_label": "Feldgruppenbezeichnung",
|
||||
"field_id": "Feld-ID",
|
||||
"field_label": "Feldbezeichnung",
|
||||
"field_type": "Feldtyp",
|
||||
"formbricks_surveys": "Formbricks-Umfragen",
|
||||
"frd_cannot_be_changed": "Das Feedback-Verzeichnis kann nach der Erstellung nicht mehr geändert werden.",
|
||||
"go_to_feedback_record_directories": "Zu den Verzeichnis-Einstellungen",
|
||||
"historical_import_complete": "Import abgeschlossen: {successes} erfolgreich, {failures} fehlgeschlagen, {skipped} übersprungen (keine Daten)",
|
||||
"import_csv_data": "Feedback importieren",
|
||||
"import_feedback": "Feedback importieren",
|
||||
"import_historical_responses": "Bisherige Antworten importieren",
|
||||
"import_historical_responses_description": "Importiere jetzt vorhandene Antworten aus dieser Umfrage.",
|
||||
"import_rows": "{count} Zeilen importieren",
|
||||
"import_via_source_name": "Import über „{sourceName}“",
|
||||
"importing_data": "Daten werden importiert...",
|
||||
"importing_historical_data": "Historische Daten werden importiert...",
|
||||
"invalid_enum_values": "Ungültige Werte in der Spalte, die {field} zugeordnet ist",
|
||||
"invalid_values_found": "Gefunden: {values} (Zeilen: {rows}) {extra}",
|
||||
"load_sample_csv": "Beispiel-CSV laden",
|
||||
"n_supported_questions": "{count} unterstützte Fragen",
|
||||
"manage_directories": "Verzeichnisse verwalten",
|
||||
"manage_feedback_sources": "Feedbackquellen verwalten",
|
||||
"metadata": "Metadaten",
|
||||
"metadata_key": "Metadatenschlüssel",
|
||||
"metadata_read_only_entries": "Schreibgeschützte Metadatenwerte (keine Zeichenfolge)",
|
||||
"metadata_value": "Metadatenwert",
|
||||
"missing_feedback_source_title": "Feedback-Quelle fehlt?",
|
||||
"no_feedback_record_directory_available": "Diesem Workspace ist kein Feedback-Datensatz-Verzeichnis zugewiesen. Erstelle oder weise zuerst eines zu.",
|
||||
"no_feedback_records": "Noch keine Feedback-Einträge vorhanden. Einträge erscheinen hier, sobald deine Konnektoren Daten senden.",
|
||||
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
|
||||
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
|
||||
"no_surveys_found": "Keine Umfragen in dieser Umgebung gefunden",
|
||||
"optional": "Optional",
|
||||
"or_drag_and_drop": "oder per Drag & Drop",
|
||||
"question_selected": "<strong>{count}</strong> Frage ausgewählt. Jede Antwort auf diese Frage wird einen neuen Feedback-Eintrag erstellen.",
|
||||
"question_type_not_supported": "Dieser Fragetyp wird nicht unterstützt",
|
||||
"questions_selected": "<strong>{count}</strong> Fragen ausgewählt. Jede Antwort auf diese Fragen wird einen neuen Feedback-Eintrag erstellen.",
|
||||
"records_will_go_to": "Datensätze gehen an",
|
||||
"refresh_feedback_records": "Feedback-Einträge aktualisieren",
|
||||
"refreshing_feedback_records": "Feedback-Einträge werden aktualisiert...",
|
||||
"request_feedback_source": "Quellen-Integration anfragen",
|
||||
"required": "Erforderlich",
|
||||
"save_changes": "Änderungen speichern",
|
||||
"select_a_survey_to_see_questions": "Wähle eine Umfrage aus, um ihre Fragen zu sehen",
|
||||
"select_a_value": "Wähle einen Wert aus...",
|
||||
"select_all": "Alle auswählen",
|
||||
"select_feedback_record_directory": "Verzeichnis auswählen",
|
||||
"select_feedback_record_source_type": "Wählen Sie den Quelltyp aus",
|
||||
"select_questions": "Fragen auswählen",
|
||||
"select_source_type_description": "Wähle die Art der Feedback-Quelle aus, die Du verbinden möchtest.",
|
||||
"select_source_type_prompt": "Wähle die Art der Feedback-Quelle aus, die Du verbinden möchtest:",
|
||||
"select_survey": "Umfrage auswählen",
|
||||
"select_survey_and_questions": "Umfrage & Fragen auswählen",
|
||||
"select_survey_questions_description": "Wähle aus, welche Umfragefragen FeedbackRecords erstellen sollen.",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "3 von {count} Zeilen werden angezeigt",
|
||||
"source": "Quelle",
|
||||
"source_connect_csv_description": "Feedback aus CSV-Dateien importieren",
|
||||
"source_connect_feedback_record_mcp_description": "Sende Feedback-Datensätze über die MCP-Integration.",
|
||||
"source_connect_formbricks_description": "Feedback aus Deinen Formbricks-Umfragen verbinden",
|
||||
"source_fields": "Quellfelder",
|
||||
"source_id": "Quell-ID",
|
||||
"source_name": "Quellenname",
|
||||
"source_type": "Quellentyp",
|
||||
"source_type_cannot_be_changed": "Quellentyp kann nicht geändert werden",
|
||||
"sources": "Quellen",
|
||||
"status_active": "In Bearbeitung",
|
||||
"status_completed": "Abgeschlossen",
|
||||
"status_draft": "Entwurf",
|
||||
"status_error": "Fehler",
|
||||
"status_paused": "Pausiert",
|
||||
"status_live_sync": "Live-Synchronisierung",
|
||||
"status_ready": "Bereit",
|
||||
"submission_id": "Einreichungs-ID",
|
||||
"survey_has_no_questions": "Diese Umfrage hat keine Fragen",
|
||||
"survey_import_line": "{surveyName}: {responseCount} Antworten × {questionCount} Fragen = {total} Feedback-Datensätze",
|
||||
"total_feedback_records": "Gesamt: {checked} von {total} Feedback-Datensätzen ausgewählt über {surveyCount} Umfragen",
|
||||
"topics_and_subtopics": "Themen & Unterthemen",
|
||||
"unify_feedback": "Feedback vereinheitlichen",
|
||||
"update_mapping_description": "Aktualisiere die Zuordnungskonfiguration für diese Quelle.",
|
||||
"updated_at": "Aktualisiert am",
|
||||
"upload_csv_data_description": "Lade eine CSV-Datei hoch, um Feedback-Daten zu importieren.",
|
||||
"upload_csv_file": "CSV-Datei hochladen",
|
||||
"user_identifier": "Benutzer",
|
||||
"value": "Wert"
|
||||
"value": "Wert",
|
||||
"value_boolean": "Wert (Boolescher Wert)",
|
||||
"value_date": "Wert (Datum)",
|
||||
"value_number": "Wert (Anzahl)",
|
||||
"value_text": "Wert (Text)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "Delete {deleteWhat}",
|
||||
"description": "Description",
|
||||
"disable": "Disable",
|
||||
"disabled": "Disabled",
|
||||
"disallow": "Do not allow",
|
||||
"discard": "Discard",
|
||||
"dismissed": "Dismissed",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "You are not authenticated to perform this action.",
|
||||
"not_authorized": "Not authorized",
|
||||
"not_connected": "Not Connected",
|
||||
"not_set": "Not set",
|
||||
"note": "Note",
|
||||
"notifications": "Notifications",
|
||||
"number": "Number",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "Some files failed to upload",
|
||||
"something_went_wrong": "Something went wrong",
|
||||
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
|
||||
"soon": "Soon",
|
||||
"sort_by": "Sort by",
|
||||
"start_free_trial": "Start free trial",
|
||||
"status": "Status",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "Connect your app or website to Formbricks.",
|
||||
"cache_update_delay_description": "When you make updates to surveys, contacts, actions, or other data, it can take up to 1 minute for those changes to appear in your local app running the Formbricks SDK.",
|
||||
"cache_update_delay_title": "Changes will be reflected after ~1 minute due to caching",
|
||||
"environment_id_legacy": "Environment ID (legacy)",
|
||||
"environment_id_legacy_alert": "Your existing SDK setup may still use a legacy Environment ID.",
|
||||
"environment_id_legacy_alert_link": "Learn why and how to migrate.",
|
||||
"formbricks_sdk_connected": "Formbricks SDK is connected",
|
||||
"formbricks_sdk_not_connected": "Formbricks SDK is not yet connected.",
|
||||
"formbricks_sdk_not_connected_description": "Add the Formbricks SDK to your website or app to connect it with Formbricks",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "Feedback Directories",
|
||||
"no_access": "You do not have permission to manage feedback record directories.",
|
||||
"no_connectors": "No connectors linked to this directory yet.",
|
||||
"pause_connectors_confirmation_description": "Pausing these connectors will stop new records from being added.",
|
||||
"pause_connectors_confirmation_title": "Pause linked connectors?",
|
||||
"select_workspaces_placeholder": "Select workspaces...",
|
||||
"show_archived": "Show archived",
|
||||
"title": "Feedback Record Directories",
|
||||
"unarchive": "Unarchive"
|
||||
"unarchive": "Unarchive",
|
||||
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more assigned workspaces are archived.",
|
||||
"workspace_access": "Workspace access"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "See which teams can access this workspace."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Add feedback record",
|
||||
"add_feedback_record_description": "Create a feedback record manually.",
|
||||
"add_feedback_source": "Add Feedback Source",
|
||||
"add_source": "Add source",
|
||||
"allowed_values": "Allowed values: {values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_manage_api_keys": "Manage API keys",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "Auto-generated",
|
||||
"change_file": "Change file",
|
||||
"click_load_sample_csv": "Click 'Load sample CSV' to see columns",
|
||||
"click_to_upload": "Click to upload",
|
||||
"collected_at": "Collected At",
|
||||
"configure_import": "Configure import",
|
||||
"configure_mapping": "Configure Mapping",
|
||||
"connection": "Connection",
|
||||
"connector_created_successfully": "Connector created successfully",
|
||||
"connector_deleted_successfully": "Connector deleted successfully",
|
||||
"connector_duplicated_successfully": "Connector duplicated successfully",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "Importing data twice will create duplicate records.",
|
||||
"csv_inconsistent_columns": "Row {row} has inconsistent columns. All rows must have the same headers.",
|
||||
"csv_max_records": "Maximum {max} records allowed.",
|
||||
"custom_source_type": "Custom source type",
|
||||
"custom_source_type_placeholder": "Enter custom source type",
|
||||
"default_connector_name_csv": "CSV Import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey Connection",
|
||||
"deselect_all": "Deselect all",
|
||||
"discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.",
|
||||
"discard_feedback_record_changes_title": "Discard unsaved changes?",
|
||||
"drop_a_field_here": "Drop a field here",
|
||||
"drop_field_or": "Drop field or",
|
||||
"edit_csv_mapping": "Edit CSV mapping",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Failed to load feedback records",
|
||||
"feedback_date": "Current date",
|
||||
"feedback_record_created_successfully": "Feedback record created successfully",
|
||||
"feedback_record_details": "Feedback record details",
|
||||
"feedback_record_details_description": "Review and update feedback record fields.",
|
||||
"feedback_record_directory": "Feedback Record Directory",
|
||||
"feedback_record_fields": "Feedback Record Fields",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"feedback_record_updated_successfully": "Feedback record updated successfully",
|
||||
"feedback_record_value_required": "A value is required for the selected field type",
|
||||
"feedback_records": "Feedback Records",
|
||||
"feedback_records_refreshed": "Feedback records refreshed",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
|
||||
"field_group_id": "Field Group ID",
|
||||
"field_group_label": "Field Group Label",
|
||||
"field_id": "Field ID",
|
||||
"field_label": "Field Label",
|
||||
"field_type": "Field Type",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "Feedback directory cannot be changed after creation.",
|
||||
"go_to_feedback_record_directories": "Go to directories settings",
|
||||
"historical_import_complete": "Import complete: {successes} succeeded, {failures} failed, {skipped} skipped (no data)",
|
||||
"import_csv_data": "Import feedback",
|
||||
"import_feedback": "Import feedback",
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "Import {count} rows",
|
||||
"import_via_source_name": "Import via \"{sourceName}\"",
|
||||
"importing_data": "Importing data...",
|
||||
"importing_historical_data": "Importing historical data...",
|
||||
"invalid_enum_values": "Invalid values in column mapped to {field}",
|
||||
"invalid_values_found": "Found: {values} (rows: {rows}) {extra}",
|
||||
"load_sample_csv": "Load sample CSV",
|
||||
"n_supported_questions": "{count} supported questions",
|
||||
"manage_directories": "Manage directories",
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"metadata": "Metadata",
|
||||
"metadata_key": "Metadata key",
|
||||
"metadata_read_only_entries": "Read-only metadata values (non-string)",
|
||||
"metadata_value": "Metadata value",
|
||||
"missing_feedback_source_title": "Missing feedback source?",
|
||||
"no_feedback_record_directory_available": "No feedback record directory assigned to this workspace. Create or assign one first.",
|
||||
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
|
||||
"no_source_fields_loaded": "No source fields loaded yet",
|
||||
"no_sources_connected": "No sources connected yet. Add a source to get started.",
|
||||
"no_surveys_found": "No surveys found in this environment",
|
||||
"optional": "Optional",
|
||||
"or_drag_and_drop": "or drag and drop",
|
||||
"question_selected": "<strong>{count}</strong> question selected. Each response to these questions will create a new Feedback Record.",
|
||||
"question_type_not_supported": "This question type is not supported",
|
||||
"questions_selected": "<strong>{count}</strong> questions selected. Each response to these questions will create a new Feedback Record.",
|
||||
"records_will_go_to": "Records will go to",
|
||||
"refresh_feedback_records": "Refresh feedback records",
|
||||
"refreshing_feedback_records": "Refreshing feedback records...",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "Required",
|
||||
"save_changes": "Save changes",
|
||||
"select_a_survey_to_see_questions": "Select a survey to see its questions",
|
||||
"select_a_value": "Select a value...",
|
||||
"select_all": "Select all",
|
||||
"select_feedback_record_directory": "Select a directory",
|
||||
"select_feedback_record_source_type": "Select source type",
|
||||
"select_questions": "Select questions",
|
||||
"select_source_type_description": "Select the type of feedback source you want to connect.",
|
||||
"select_source_type_prompt": "Select the type of feedback source you want to connect:",
|
||||
"select_survey": "Select Survey",
|
||||
"select_survey_and_questions": "Select Survey & Questions",
|
||||
"select_survey_questions_description": "Choose which survey questions should create FeedbackRecords.",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "Showing 3 of {count} rows",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "Import feedback from CSV files",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "Connect feedback from your Formbricks surveys",
|
||||
"source_fields": "Source Fields",
|
||||
"source_id": "Source ID",
|
||||
"source_name": "Source Name",
|
||||
"source_type": "Source Type",
|
||||
"source_type_cannot_be_changed": "Source type cannot be changed",
|
||||
"sources": "Sources",
|
||||
"status_active": "In Progress",
|
||||
"status_completed": "Completed",
|
||||
"status_draft": "Draft",
|
||||
"status_error": "Error",
|
||||
"status_paused": "Paused",
|
||||
"status_live_sync": "Live sync",
|
||||
"status_ready": "Ready",
|
||||
"submission_id": "Submission ID",
|
||||
"survey_has_no_questions": "This survey has no questions",
|
||||
"survey_import_line": "{surveyName}: {responseCount} responses × {questionCount} questions = {total} Feedback Records",
|
||||
"total_feedback_records": "Total: {checked} of {total} Feedback Records selected across {surveyCount} surveys",
|
||||
"topics_and_subtopics": "Topics & Subtopics",
|
||||
"unify_feedback": "Unify Feedback",
|
||||
"update_mapping_description": "Update the mapping configuration for this source.",
|
||||
"updated_at": "Updated at",
|
||||
"upload_csv_data_description": "Upload a CSV file to import feedback data.",
|
||||
"upload_csv_file": "Upload CSV File",
|
||||
"user_identifier": "User",
|
||||
"value": "Value"
|
||||
"value": "Value",
|
||||
"value_boolean": "Value (Boolean)",
|
||||
"value_date": "Value (Date)",
|
||||
"value_number": "Value (Number)",
|
||||
"value_text": "Value (Text)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "Eliminar {deleteWhat}",
|
||||
"description": "Descripción",
|
||||
"disable": "Desactivar",
|
||||
"disabled": "Desactivado",
|
||||
"disallow": "No permitir",
|
||||
"discard": "Descartar",
|
||||
"dismissed": "Descartado",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "No estás autenticado para realizar esta acción.",
|
||||
"not_authorized": "No autorizado",
|
||||
"not_connected": "No conectado",
|
||||
"not_set": "No establecido",
|
||||
"note": "Nota",
|
||||
"notifications": "Notificaciones",
|
||||
"number": "Número",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "Algunos archivos no se han podido subir",
|
||||
"something_went_wrong": "Algo ha salido mal",
|
||||
"something_went_wrong_please_try_again": "Algo ha salido mal. Por favor, inténtalo de nuevo.",
|
||||
"soon": "Próximamente",
|
||||
"sort_by": "Ordenar por",
|
||||
"start_free_trial": "Iniciar prueba gratuita",
|
||||
"status": "Estado",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "Conecta tu aplicación o sitio web a Formbricks.",
|
||||
"cache_update_delay_description": "Cuando realizas actualizaciones en encuestas, contactos, acciones u otros datos, puede tardar hasta 1 minuto en que esos cambios aparezcan en tu aplicación local que ejecuta el SDK de Formbricks.",
|
||||
"cache_update_delay_title": "Los cambios se reflejarán después de ~1 minuto debido al almacenamiento en caché",
|
||||
"environment_id_legacy": "ID de entorno (heredado)",
|
||||
"environment_id_legacy_alert": "Tu configuración actual del SDK puede seguir utilizando un ID de entorno heredado.",
|
||||
"environment_id_legacy_alert_link": "Descubre por qué y cómo migrar.",
|
||||
"formbricks_sdk_connected": "El SDK de Formbricks está conectado",
|
||||
"formbricks_sdk_not_connected": "El SDK de Formbricks aún no está conectado.",
|
||||
"formbricks_sdk_not_connected_description": "Añade el SDK de Formbricks a tu sitio web o aplicación para conectarlo con Formbricks",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "Directorios de Feedback",
|
||||
"no_access": "No tienes permiso para gestionar los directorios de registros de feedback.",
|
||||
"no_connectors": "Aún no hay conectores vinculados a este directorio.",
|
||||
"pause_connectors_confirmation_description": "Si pausas estos conectores, no se añadirán nuevos registros.",
|
||||
"pause_connectors_confirmation_title": "¿Pausar conectores vinculados?",
|
||||
"select_workspaces_placeholder": "Selecciona espacios de trabajo...",
|
||||
"show_archived": "Mostrar archivados",
|
||||
"title": "Directorios de Registros de Feedback",
|
||||
"unarchive": "Desarchivar"
|
||||
"unarchive": "Desarchivar",
|
||||
"unarchive_workspace_conflict": "No se puede desarchivar este directorio porque uno o más espacios de trabajo asignados están archivados.",
|
||||
"workspace_access": "Acceso al espacio de trabajo"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "Consulta qué equipos pueden acceder a este espacio de trabajo."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Agregar registro de comentarios",
|
||||
"add_feedback_record_description": "Cree un registro de comentarios manualmente.",
|
||||
"add_feedback_source": "Añadir fuente de feedback",
|
||||
"add_source": "Añadir fuente",
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"api_ingestion": "Ingesta de API",
|
||||
"api_ingestion_manage_api_keys": "Gestionar claves de API",
|
||||
"api_ingestion_settings_description": "Envía registros de feedback mediante la API de gestión.",
|
||||
"auto_generated": "Generado automáticamente",
|
||||
"change_file": "Cambiar archivo",
|
||||
"click_load_sample_csv": "Haz clic en 'Cargar CSV de muestra' para ver las columnas",
|
||||
"click_to_upload": "Haz clic para subir",
|
||||
"collected_at": "Recopilado el",
|
||||
"configure_import": "Configurar importación",
|
||||
"configure_mapping": "Configurar asignación",
|
||||
"connection": "Conexión",
|
||||
"connector_created_successfully": "Conector creado correctamente",
|
||||
"connector_deleted_successfully": "Conector eliminado correctamente",
|
||||
"connector_duplicated_successfully": "Conector duplicado correctamente",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "Importar datos dos veces creará registros duplicados.",
|
||||
"csv_inconsistent_columns": "La fila {row} tiene columnas inconsistentes. Todas las filas deben tener los mismos encabezados.",
|
||||
"csv_max_records": "Máximo de {max} registros permitidos.",
|
||||
"custom_source_type": "Tipo de fuente personalizado",
|
||||
"custom_source_type_placeholder": "Ingrese el tipo de fuente personalizado",
|
||||
"default_connector_name_csv": "Importación CSV",
|
||||
"default_connector_name_formbricks": "Conexión de encuesta de Formbricks",
|
||||
"deselect_all": "Deseleccionar todo",
|
||||
"discard_feedback_record_changes_description": "Sus cambios se perderán si cierra este cajón.",
|
||||
"discard_feedback_record_changes_title": "¿Descartar los cambios no guardados?",
|
||||
"drop_a_field_here": "Suelta un campo aquí",
|
||||
"drop_field_or": "Suelta el campo o",
|
||||
"edit_csv_mapping": "Editar mapeo de CSV",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
|
||||
"feedback_date": "Fecha actual",
|
||||
"feedback_record_created_successfully": "Registro de comentarios creado correctamente",
|
||||
"feedback_record_details": "Detalles del registro de comentarios",
|
||||
"feedback_record_details_description": "Revise y actualice los campos del registro de comentarios.",
|
||||
"feedback_record_directory": "Directorio de Registros de Comentarios",
|
||||
"feedback_record_fields": "Campos de registro de comentarios",
|
||||
"feedback_record_mcp": "MCP de registros de feedback",
|
||||
"feedback_record_updated_successfully": "Registro de comentarios actualizado correctamente",
|
||||
"feedback_record_value_required": "Se requiere un valor para el tipo de campo seleccionado",
|
||||
"feedback_records": "Registros de comentarios",
|
||||
"feedback_records_refreshed": "Registros de comentarios actualizados",
|
||||
"feedback_sources": "Fuentes de feedback",
|
||||
"feedback_sources_directory_access_multiple": "Los nuevos registros de estas fuentes se almacenarán en: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Los nuevos registros de esta fuente se almacenarán en: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Conecta y gestiona todas las fuentes de feedback para este espacio de trabajo.",
|
||||
"field_group_id": "ID de grupo de campos",
|
||||
"field_group_label": "Etiqueta de grupo de campos",
|
||||
"field_id": "ID de campo",
|
||||
"field_label": "Etiqueta de campo",
|
||||
"field_type": "Tipo de campo",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "El directorio de comentarios no se puede cambiar después de su creación.",
|
||||
"go_to_feedback_record_directories": "Ir a la configuración de directorios",
|
||||
"historical_import_complete": "Importación completada: {successes} correctas, {failures} fallidas, {skipped} omitidas (sin datos)",
|
||||
"import_csv_data": "Importar comentarios",
|
||||
"import_feedback": "Importar comentarios",
|
||||
"import_historical_responses": "Importar respuestas históricas",
|
||||
"import_historical_responses_description": "Importa las respuestas existentes de esta encuesta ahora.",
|
||||
"import_rows": "Importar {count} filas",
|
||||
"import_via_source_name": "Importar mediante \"{sourceName}\"",
|
||||
"importing_data": "Importando datos...",
|
||||
"importing_historical_data": "Importando datos históricos...",
|
||||
"invalid_enum_values": "Valores no válidos en la columna asignada a {field}",
|
||||
"invalid_values_found": "Encontrados: {values} (filas: {rows}) {extra}",
|
||||
"load_sample_csv": "Cargar CSV de muestra",
|
||||
"n_supported_questions": "{count} preguntas compatibles",
|
||||
"manage_directories": "Gestionar directorios",
|
||||
"manage_feedback_sources": "Administrar fuentes de comentarios",
|
||||
"metadata": "Metadatos",
|
||||
"metadata_key": "Clave de metadatos",
|
||||
"metadata_read_only_entries": "Valores de metadatos de solo lectura (no cadenas)",
|
||||
"metadata_value": "Valor de metadatos",
|
||||
"missing_feedback_source_title": "¿Falta alguna fuente de feedback?",
|
||||
"no_feedback_record_directory_available": "No hay ningún directorio de registros de comentarios asignado a este espacio de trabajo. Crea o asigna uno primero.",
|
||||
"no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.",
|
||||
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
|
||||
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
|
||||
"no_surveys_found": "No se encontraron encuestas en este entorno",
|
||||
"optional": "Opcional",
|
||||
"or_drag_and_drop": "o arrastra y suelta",
|
||||
"question_selected": "<strong>{count}</strong> pregunta seleccionada. Cada respuesta a esta pregunta creará un registro de feedback nuevo.",
|
||||
"question_type_not_supported": "Este tipo de pregunta no es compatible",
|
||||
"questions_selected": "<strong>{count}</strong> preguntas seleccionadas. Cada respuesta a estas preguntas creará un registro de feedback nuevo.",
|
||||
"records_will_go_to": "Los registros se enviarán a",
|
||||
"refresh_feedback_records": "Actualizar los registros de comentarios",
|
||||
"refreshing_feedback_records": "Actualizando registros de comentarios...",
|
||||
"request_feedback_source": "Solicitar integración de fuente",
|
||||
"required": "Obligatorio",
|
||||
"save_changes": "Guardar cambios",
|
||||
"select_a_survey_to_see_questions": "Selecciona una encuesta para ver sus preguntas",
|
||||
"select_a_value": "Selecciona un valor...",
|
||||
"select_all": "Seleccionar todo",
|
||||
"select_feedback_record_directory": "Selecciona un directorio",
|
||||
"select_feedback_record_source_type": "Seleccionar tipo de fuente",
|
||||
"select_questions": "Seleccionar preguntas",
|
||||
"select_source_type_description": "Selecciona el tipo de fuente de feedback que quieres conectar.",
|
||||
"select_source_type_prompt": "Selecciona el tipo de fuente de feedback que quieres conectar:",
|
||||
"select_survey": "Seleccionar encuesta",
|
||||
"select_survey_and_questions": "Seleccionar encuesta y preguntas",
|
||||
"select_survey_questions_description": "Elige qué preguntas de la encuesta deben crear FeedbackRecords.",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "Mostrando 3 de {count} filas",
|
||||
"source": "origen",
|
||||
"source_connect_csv_description": "Importar feedback desde archivos CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Envía registros de feedback a través de la integración MCP.",
|
||||
"source_connect_formbricks_description": "Conectar feedback de tus encuestas de Formbricks",
|
||||
"source_fields": "Campos de origen",
|
||||
"source_id": "ID de fuente",
|
||||
"source_name": "Nombre de origen",
|
||||
"source_type": "Tipo de fuente",
|
||||
"source_type_cannot_be_changed": "El tipo de origen no se puede cambiar",
|
||||
"sources": "Orígenes",
|
||||
"status_active": "En progreso",
|
||||
"status_completed": "Completado",
|
||||
"status_draft": "Borrador",
|
||||
"status_error": "Error",
|
||||
"status_paused": "Pausado",
|
||||
"status_live_sync": "Sincronización en vivo",
|
||||
"status_ready": "Listo",
|
||||
"submission_id": "ID de envío",
|
||||
"survey_has_no_questions": "Esta encuesta no tiene preguntas",
|
||||
"survey_import_line": "{surveyName}: {responseCount} respuestas × {questionCount} preguntas = {total} registros de feedback",
|
||||
"total_feedback_records": "Total: {checked} de {total} registros de feedback seleccionados en {surveyCount} encuestas",
|
||||
"topics_and_subtopics": "Temas y subtemas",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Actualiza la configuración de mapeo para esta fuente.",
|
||||
"updated_at": "Actualizado el",
|
||||
"upload_csv_data_description": "Sube un archivo CSV para importar datos de comentarios.",
|
||||
"upload_csv_file": "Subir archivo CSV",
|
||||
"user_identifier": "Usuario",
|
||||
"value": "Valor"
|
||||
"value": "Valor",
|
||||
"value_boolean": "Valor (booleano)",
|
||||
"value_date": "Valor (Fecha)",
|
||||
"value_number": "Valor (Número)",
|
||||
"value_text": "Valor (Texto)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "Supprimer {deleteWhat}",
|
||||
"description": "Description",
|
||||
"disable": "Désactiver",
|
||||
"disabled": "Désactivé",
|
||||
"disallow": "Ne pas autoriser",
|
||||
"discard": "Annuler",
|
||||
"dismissed": "Rejeté",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Vous n'êtes pas authentifié pour effectuer cette action.",
|
||||
"not_authorized": "Non autorisé",
|
||||
"not_connected": "Non connecté",
|
||||
"not_set": "Non défini",
|
||||
"note": "Remarque",
|
||||
"notifications": "Notifications",
|
||||
"number": "Numéro",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "Certains fichiers n'ont pas pu être téléchargés",
|
||||
"something_went_wrong": "Quelque chose s'est mal passé.",
|
||||
"something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.",
|
||||
"soon": "Bientôt",
|
||||
"sort_by": "Trier par",
|
||||
"start_free_trial": "Commencer l'essai gratuit",
|
||||
"status": "Statut",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "Connectez votre application ou votre site web à Formbricks.",
|
||||
"cache_update_delay_description": "Lorsque vous effectuez des mises à jour de sondages, de contacts, d'actions ou d'autres données, il peut falloir jusqu'à 1 minute pour que ces modifications apparaissent dans votre application locale exécutant le SDK Formbricks.",
|
||||
"cache_update_delay_title": "Les modifications seront reflétées après environ 1 minute en raison de la mise en cache",
|
||||
"environment_id_legacy": "ID d'environnement (ancien)",
|
||||
"environment_id_legacy_alert": "Votre configuration SDK existante peut encore utiliser un ID d'environnement ancien.",
|
||||
"environment_id_legacy_alert_link": "Découvrez pourquoi et comment migrer.",
|
||||
"formbricks_sdk_connected": "Le SDK Formbricks est connecté",
|
||||
"formbricks_sdk_not_connected": "Le SDK Formbricks n'est pas encore connecté.",
|
||||
"formbricks_sdk_not_connected_description": "Ajoutez le SDK Formbricks à votre site web ou à votre application pour le connecter à Formbricks",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "Répertoires de feedback",
|
||||
"no_access": "Tu n'as pas la permission de gérer les répertoires de feedback.",
|
||||
"no_connectors": "Aucun connecteur lié à ce répertoire pour le moment.",
|
||||
"pause_connectors_confirmation_description": "Si vous mettez ces connecteurs en pause, aucun nouvel enregistrement ne sera ajouté.",
|
||||
"pause_connectors_confirmation_title": "Mettre en pause les connecteurs liés ?",
|
||||
"select_workspaces_placeholder": "Sélectionner des espaces de travail...",
|
||||
"show_archived": "Afficher les éléments archivés",
|
||||
"title": "Répertoires d'enregistrement des retours",
|
||||
"unarchive": "Désarchiver"
|
||||
"unarchive": "Désarchiver",
|
||||
"unarchive_workspace_conflict": "Impossible de désarchiver ce répertoire, car un ou plusieurs espaces de travail attribués sont archivés.",
|
||||
"workspace_access": "Accès à l’espace de travail"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "Voir quelles équipes peuvent accéder à cet espace de travail."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Ajouter un enregistrement de commentaires",
|
||||
"add_feedback_record_description": "Créez manuellement un enregistrement de commentaires.",
|
||||
"add_feedback_source": "Ajouter une source de feedback",
|
||||
"add_source": "Ajouter une source",
|
||||
"allowed_values": "Valeurs autorisées : {values}",
|
||||
"api_ingestion": "Ingestion par API",
|
||||
"api_ingestion_manage_api_keys": "Gérer les clés API",
|
||||
"api_ingestion_settings_description": "Envoyer des enregistrements de feedback via l'API de gestion.",
|
||||
"auto_generated": "Généré automatiquement",
|
||||
"change_file": "Changer de fichier",
|
||||
"click_load_sample_csv": "Clique sur « Charger un exemple CSV » pour voir les colonnes",
|
||||
"click_to_upload": "Clique pour charger",
|
||||
"collected_at": "Collecté le",
|
||||
"configure_import": "Configurer l'importation",
|
||||
"configure_mapping": "Configurer le mappage",
|
||||
"connection": "Connexion",
|
||||
"connector_created_successfully": "Connecteur créé avec succès",
|
||||
"connector_deleted_successfully": "Connecteur supprimé avec succès",
|
||||
"connector_duplicated_successfully": "Connecteur dupliqué avec succès",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "Importer les données deux fois créera des enregistrements en double.",
|
||||
"csv_inconsistent_columns": "La ligne {row} a des colonnes incohérentes. Toutes les lignes doivent avoir les mêmes en-têtes.",
|
||||
"csv_max_records": "Maximum {max} enregistrements autorisés.",
|
||||
"custom_source_type": "Type de source personnalisé",
|
||||
"custom_source_type_placeholder": "Entrez le type de source personnalisé",
|
||||
"default_connector_name_csv": "Importation CSV",
|
||||
"default_connector_name_formbricks": "Connexion de sondage Formbricks",
|
||||
"deselect_all": "Tout désélectionner",
|
||||
"discard_feedback_record_changes_description": "Vos modifications seront perdues si vous fermez ce tiroir.",
|
||||
"discard_feedback_record_changes_title": "Supprimer les modifications non enregistrées ?",
|
||||
"drop_a_field_here": "Déposez un champ ici",
|
||||
"drop_field_or": "Déposez un champ ou",
|
||||
"edit_csv_mapping": "Modifier le mappage CSV",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
|
||||
"feedback_date": "Date actuelle",
|
||||
"feedback_record_created_successfully": "Enregistrement de commentaires créé avec succès",
|
||||
"feedback_record_details": "Détails de l'enregistrement des commentaires",
|
||||
"feedback_record_details_description": "Examiner et mettre à jour les champs d’enregistrement des commentaires.",
|
||||
"feedback_record_directory": "Répertoire d'enregistrements de retour d'expérience",
|
||||
"feedback_record_fields": "Champs d'enregistrement de feedback",
|
||||
"feedback_record_mcp": "MCP d'enregistrement de feedback",
|
||||
"feedback_record_updated_successfully": "L'enregistrement des commentaires a été mis à jour avec succès",
|
||||
"feedback_record_value_required": "Une valeur est requise pour le type de champ sélectionné",
|
||||
"feedback_records": "Enregistrements de feedback",
|
||||
"feedback_records_refreshed": "Enregistrements de feedback actualisés",
|
||||
"feedback_sources": "Sources de feedback",
|
||||
"feedback_sources_directory_access_multiple": "Les nouveaux enregistrements de ces sources seront stockés dans : {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Les nouveaux enregistrements de cette source seront stockés dans : {directoryNames}",
|
||||
"feedback_sources_settings_description": "Connecte et gère toutes les sources de feedback pour cet espace de travail.",
|
||||
"field_group_id": "ID de groupe de champs",
|
||||
"field_group_label": "Libellé du groupe de champs",
|
||||
"field_id": "Identifiant du champ",
|
||||
"field_label": "Libellé du champ",
|
||||
"field_type": "Type de champ",
|
||||
"formbricks_surveys": "Sondages Formbricks",
|
||||
"frd_cannot_be_changed": "Le répertoire de retours d'expérience ne peut pas être modifié après sa création.",
|
||||
"go_to_feedback_record_directories": "Accéder aux paramètres des répertoires",
|
||||
"historical_import_complete": "Importation terminée : {successes} réussies, {failures} échouées, {skipped} ignorées (aucune donnée)",
|
||||
"import_csv_data": "Importer les retours",
|
||||
"import_feedback": "Importer les retours",
|
||||
"import_historical_responses": "Importer les réponses historiques",
|
||||
"import_historical_responses_description": "Importe les réponses existantes de cette enquête maintenant.",
|
||||
"import_rows": "Importer {count} lignes",
|
||||
"import_via_source_name": "Importer via \"{sourceName}\"",
|
||||
"importing_data": "Importation des données...",
|
||||
"importing_historical_data": "Importation des données historiques...",
|
||||
"invalid_enum_values": "Valeurs non valides dans la colonne mappée à {field}",
|
||||
"invalid_values_found": "Trouvées : {values} (lignes : {rows}) {extra}",
|
||||
"load_sample_csv": "Charger un exemple de CSV",
|
||||
"n_supported_questions": "{count} questions prises en charge",
|
||||
"manage_directories": "Gérer les répertoires",
|
||||
"manage_feedback_sources": "Gérer les sources de commentaires",
|
||||
"metadata": "Métadonnées",
|
||||
"metadata_key": "Clé de métadonnées",
|
||||
"metadata_read_only_entries": "Valeurs de métadonnées en lecture seule (non-chaîne)",
|
||||
"metadata_value": "Valeur des métadonnées",
|
||||
"missing_feedback_source_title": "Il manque une source de feedback ?",
|
||||
"no_feedback_record_directory_available": "Aucun répertoire d'enregistrements de retour d'expérience n'est assigné à cet espace de travail. Créez-en un ou assignez-en un d'abord.",
|
||||
"no_feedback_records": "Aucun enregistrement de feedback pour le moment. Les enregistrements apparaîtront ici une fois que vos connecteurs commenceront à envoyer des données.",
|
||||
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
|
||||
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
|
||||
"no_surveys_found": "Aucune enquête trouvée dans cet environnement",
|
||||
"optional": "Facultatif",
|
||||
"or_drag_and_drop": "ou glisser-déposer",
|
||||
"question_selected": "<strong>{count}</strong> question sélectionnée. Chaque réponse à cette question créera un nouvel enregistrement de feedback.",
|
||||
"question_type_not_supported": "Ce type de question n'est pas pris en charge",
|
||||
"questions_selected": "<strong>{count}</strong> questions sélectionnées. Chaque réponse à ces questions créera un nouvel enregistrement de feedback.",
|
||||
"records_will_go_to": "Les enregistrements seront envoyés vers",
|
||||
"refresh_feedback_records": "Actualiser les enregistrements de retours",
|
||||
"refreshing_feedback_records": "Actualisation des enregistrements de feedback...",
|
||||
"request_feedback_source": "Demander une intégration de source",
|
||||
"required": "Requis",
|
||||
"save_changes": "Enregistrer les modifications",
|
||||
"select_a_survey_to_see_questions": "Sélectionnez une enquête pour voir ses questions",
|
||||
"select_a_value": "Sélectionnez une valeur...",
|
||||
"select_all": "Sélectionner tout",
|
||||
"select_feedback_record_directory": "Sélectionner un répertoire",
|
||||
"select_feedback_record_source_type": "Sélectionnez le type de source",
|
||||
"select_questions": "Sélectionner les questions",
|
||||
"select_source_type_description": "Sélectionnez le type de source de feedback que vous souhaitez connecter.",
|
||||
"select_source_type_prompt": "Sélectionnez le type de source de feedback que vous souhaitez connecter :",
|
||||
"select_survey": "Sélectionner l'enquête",
|
||||
"select_survey_and_questions": "Sélectionner l'enquête et les questions",
|
||||
"select_survey_questions_description": "Choisissez quelles questions d'enquête doivent créer des FeedbackRecords.",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "Affichage de 3 sur {count} lignes",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "Importer des feedbacks depuis des fichiers CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Envoyer des enregistrements de feedback via l'intégration MCP.",
|
||||
"source_connect_formbricks_description": "Connecter les feedbacks de vos enquêtes Formbricks",
|
||||
"source_fields": "Champs source",
|
||||
"source_id": "Identifiant de la source",
|
||||
"source_name": "Nom de la source",
|
||||
"source_type": "Type de source",
|
||||
"source_type_cannot_be_changed": "Le type de source ne peut pas être modifié",
|
||||
"sources": "Sources",
|
||||
"status_active": "En cours",
|
||||
"status_completed": "Terminé",
|
||||
"status_draft": "Brouillon",
|
||||
"status_error": "Erreur",
|
||||
"status_paused": "En pause",
|
||||
"status_live_sync": "Synchronisation en direct",
|
||||
"status_ready": "Prêt",
|
||||
"submission_id": "ID de soumission",
|
||||
"survey_has_no_questions": "Ce sondage n'a pas de questions",
|
||||
"survey_import_line": "{surveyName} : {responseCount} réponses × {questionCount} questions = {total} enregistrements de feedback",
|
||||
"total_feedback_records": "Total : {checked} sur {total} enregistrements de feedback sélectionnés parmi {surveyCount} sondages",
|
||||
"topics_and_subtopics": "Thèmes et sous-thèmes",
|
||||
"unify_feedback": "Unifier les retours",
|
||||
"update_mapping_description": "Mettre à jour la configuration de mappage pour cette source.",
|
||||
"updated_at": "Mis à jour à",
|
||||
"upload_csv_data_description": "Téléchargez un fichier CSV pour importer des données de feedback.",
|
||||
"upload_csv_file": "Télécharger un fichier CSV",
|
||||
"user_identifier": "Utilisateur",
|
||||
"value": "Valeur"
|
||||
"value": "Valeur",
|
||||
"value_boolean": "Valeur (booléenne)",
|
||||
"value_date": "Valeur (Date)",
|
||||
"value_number": "Valeur (Nombre)",
|
||||
"value_text": "Valeur (texte)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "{deleteWhat} törlése",
|
||||
"description": "Leírás",
|
||||
"disable": "Letiltás",
|
||||
"disabled": "Letiltva",
|
||||
"disallow": "Ne engedélyezze",
|
||||
"discard": "Elvetés",
|
||||
"dismissed": "Eltüntetve",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Nincs jogosultsága ennek a műveletnek a végrehajtásához.",
|
||||
"not_authorized": "Nincs felhatalmazva",
|
||||
"not_connected": "Nincs kapcsolódva",
|
||||
"not_set": "Nincs beállítva",
|
||||
"note": "Jegyzet",
|
||||
"notifications": "Értesítések",
|
||||
"number": "Szám",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "Néhány fájlt nem sikerült feltölteni",
|
||||
"something_went_wrong": "Valami probléma történt",
|
||||
"something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.",
|
||||
"soon": "Hamarosan",
|
||||
"sort_by": "Rendezési sorrend",
|
||||
"start_free_trial": "Ingyenes próbaidőszak indítása",
|
||||
"status": "Állapot",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "Alkalmazás vagy webhely csatlakoztatása a Formbrickshez.",
|
||||
"cache_update_delay_description": "Ha frissítéseket hajt végre a kérdőíveken, partnereken, műveleteken vagy egyéb adatokon, akkor akár 1 percet is igénybe vehet, mire azok a változtatások megjelennek a Formbricks SDK-t futtató helyi alkalmazásban.",
|
||||
"cache_update_delay_title": "A változtatások körülbelül 1 perc múlva jelennek meg a gyorsítótárazás miatt",
|
||||
"environment_id_legacy": "Környezet azonosító (elavult)",
|
||||
"environment_id_legacy_alert": "Az Ön meglévő SDK beállítása esetleg még egy elavult környezet azonosítót használ.",
|
||||
"environment_id_legacy_alert_link": "Ismerje meg, miért és hogyan kell migrálnia.",
|
||||
"formbricks_sdk_connected": "A Formbricks SDK csatlakoztatva van",
|
||||
"formbricks_sdk_not_connected": "A Formbricks SDK még nincs csatlakoztatva.",
|
||||
"formbricks_sdk_not_connected_description": "Adja hozzá a Formbricks SDK-t a webhelyéhez vagy az alkalmazásához, hogy összekapcsolja azt a Formbricks platformmal",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "Visszajelzési könyvtárak",
|
||||
"no_access": "Nem rendelkezik jogosultsággal a visszajelzési nyilvántartási könyvtárak kezeléséhez.",
|
||||
"no_connectors": "Még nincsenek csatlakozók társítva ehhez a könyvtárhoz.",
|
||||
"pause_connectors_confirmation_description": "Ha szünetelteti ezeket a csatlakozókat, nem kerülnek be új rekordok.",
|
||||
"pause_connectors_confirmation_title": "Szünetelteti a kapcsolódó csatlakozókat?",
|
||||
"select_workspaces_placeholder": "Munkaterületek kiválasztása...",
|
||||
"show_archived": "Archivált elemek megjelenítése",
|
||||
"title": "Visszajelzési Nyilvántartási Könyvtárak",
|
||||
"unarchive": "Archiválás visszavonása"
|
||||
"unarchive": "Archiválás visszavonása",
|
||||
"unarchive_workspace_conflict": "A könyvtár nem állítható vissza, mert egy vagy több hozzárendelt munkaterület archiválva van.",
|
||||
"workspace_access": "Munkaterület-hozzáférés"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "Annak megtekintése, hogy mely csapatok férhetnek hozzá ehhez a munkaterülethez."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Visszajelzés hozzáadása",
|
||||
"add_feedback_record_description": "Készítsen visszajelzési rekordot manuálisan.",
|
||||
"add_feedback_source": "Visszajelzési forrás hozzáadása",
|
||||
"add_source": "Forrás hozzáadása",
|
||||
"allowed_values": "Engedélyezett értékek: {values}",
|
||||
"api_ingestion": "API betöltés",
|
||||
"api_ingestion_manage_api_keys": "API kulcsok kezelése",
|
||||
"api_ingestion_settings_description": "Visszajelzési rekordok küldése a Management API használatával.",
|
||||
"auto_generated": "Automatikusan generált",
|
||||
"change_file": "Fájl módosítása",
|
||||
"click_load_sample_csv": "Kattintson a 'Minta CSV betöltése' gombra az oszlopok megtekintéséhez",
|
||||
"click_to_upload": "Kattintson a feltöltéshez",
|
||||
"collected_at": "Gyűjtve",
|
||||
"configure_import": "Importálás konfigurálása",
|
||||
"configure_mapping": "Leképezés konfigurálása",
|
||||
"connection": "Kapcsolat",
|
||||
"connector_created_successfully": "Csatlakozó sikeresen létrehozva",
|
||||
"connector_deleted_successfully": "Csatlakozó sikeresen törölve",
|
||||
"connector_duplicated_successfully": "Csatlakozó sikeresen duplikálva",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "Az adatok kétszeri importálása duplikált rekordokat hoz létre.",
|
||||
"csv_inconsistent_columns": "A(z) {row}. sor inkonzisztens oszlopokat tartalmaz. Minden sornak ugyanazokkal a fejlécekkel kell rendelkeznie.",
|
||||
"csv_max_records": "Maximum {max} rekord engedélyezett.",
|
||||
"custom_source_type": "Egyéni forrástípus",
|
||||
"custom_source_type_placeholder": "Adja meg az egyéni forrástípust",
|
||||
"default_connector_name_csv": "CSV importálás",
|
||||
"default_connector_name_formbricks": "Formbricks kérdőív kapcsolat",
|
||||
"deselect_all": "Összes kijelölés törlése",
|
||||
"discard_feedback_record_changes_description": "A módosítások elvesznek, ha bezárja ezt a fiókot.",
|
||||
"discard_feedback_record_changes_title": "Elveti a nem mentett módosításokat?",
|
||||
"drop_a_field_here": "Húzz ide egy mezőt",
|
||||
"drop_field_or": "Húzz ide egy mezőt vagy",
|
||||
"edit_csv_mapping": "CSV leképezés szerkesztése",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "felsorolás",
|
||||
"failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat",
|
||||
"feedback_date": "Aktuális dátum",
|
||||
"feedback_record_created_successfully": "A visszajelzési rekord sikeresen létrehozva",
|
||||
"feedback_record_details": "A visszajelzési rekord részletei",
|
||||
"feedback_record_details_description": "Tekintse át és frissítse a visszajelzési rekordmezőket.",
|
||||
"feedback_record_directory": "Visszajelzési Rekord Könyvtár",
|
||||
"feedback_record_fields": "Visszajelzési rekord mezők",
|
||||
"feedback_record_mcp": "Visszajelzési rekord MCP",
|
||||
"feedback_record_updated_successfully": "A visszajelzési rekord sikeresen frissítve",
|
||||
"feedback_record_value_required": "A kiválasztott mezőtípushoz értéket kell megadni",
|
||||
"feedback_records": "Visszajelzési rekordok",
|
||||
"feedback_records_refreshed": "Visszajelzési rekordok frissítve",
|
||||
"feedback_sources": "Visszajelzési források",
|
||||
"feedback_sources_directory_access_multiple": "Az ezekből a forrásokból származó új rekordok a következő helyen lesznek tárolva: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Az ebből a forrásból származó új rekordok a következő helyen lesznek tárolva: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Összes visszajelzési forrás csatlakoztatása és kezelése ezen munkaterület számára.",
|
||||
"field_group_id": "Mezőcsoport azonosítója",
|
||||
"field_group_label": "Mezőcsoport címke",
|
||||
"field_id": "Mezőazonosító",
|
||||
"field_label": "Mező címke",
|
||||
"field_type": "Mező típus",
|
||||
"formbricks_surveys": "Formbricks kérdőívek",
|
||||
"frd_cannot_be_changed": "A visszajelzési könyvtár a létrehozás után nem módosítható.",
|
||||
"go_to_feedback_record_directories": "Ugrás a könyvtárbeállításokhoz",
|
||||
"historical_import_complete": "Importálás befejezve: {successes} sikeres, {failures} sikertelen, {skipped} kihagyva (nincs adat)",
|
||||
"import_csv_data": "Visszajelzés importálása",
|
||||
"import_feedback": "Visszajelzés importálása",
|
||||
"import_historical_responses": "Korábbi válaszok importálása",
|
||||
"import_historical_responses_description": "Meglévő válaszok importálása ebből a felmérésből most.",
|
||||
"import_rows": "{count} sor importálása",
|
||||
"import_via_source_name": "Importálás a következőn keresztül: \"{sourceName}\"",
|
||||
"importing_data": "Adatok importálása...",
|
||||
"importing_historical_data": "Történeti adatok importálása...",
|
||||
"invalid_enum_values": "Érvénytelen értékek a(z) {field} mezőhöz rendelt oszlopban",
|
||||
"invalid_values_found": "Talált értékek: {values} (sorok: {rows}) {extra}",
|
||||
"load_sample_csv": "Minta CSV betöltése",
|
||||
"n_supported_questions": "{count} támogatott kérdés",
|
||||
"manage_directories": "Könyvtárak kezelése",
|
||||
"manage_feedback_sources": "Visszajelzési források kezelése",
|
||||
"metadata": "Metaadatok",
|
||||
"metadata_key": "Metaadatkulcs",
|
||||
"metadata_read_only_entries": "Csak olvasható metaadatértékek (nem karakterlánc)",
|
||||
"metadata_value": "A metaadat értéke",
|
||||
"missing_feedback_source_title": "Hiányzik egy visszajelzési forrás?",
|
||||
"no_feedback_record_directory_available": "Ehhez a munkaterülethez nem tartozik visszajelzési rekord könyvtár. Először hozzon létre vagy rendeljen hozzá egyet.",
|
||||
"no_feedback_records": "Még nincsenek visszajelzési rekordok. A rekordok itt fognak megjelenni, amint a csatlakozók elkezdik küldeni az adatokat.",
|
||||
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
|
||||
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
|
||||
"no_surveys_found": "Nem találhatók kérdőívek ebben a környezetben",
|
||||
"optional": "Elhagyható",
|
||||
"or_drag_and_drop": "vagy húzd ide",
|
||||
"question_selected": "<strong>{count}</strong> kérdés kiválasztva. Minden válasz ezekre a kérdésekre új visszajelzési rekordot hoz létre.",
|
||||
"question_type_not_supported": "Ez a kérdéstípus nem támogatott",
|
||||
"questions_selected": "<strong>{count}</strong> kérdés kiválasztva. Minden válasz ezekre a kérdésekre új visszajelzési rekordot hoz létre.",
|
||||
"records_will_go_to": "A rekordok ide kerülnek",
|
||||
"refresh_feedback_records": "Visszajelzési rekordok frissítése",
|
||||
"refreshing_feedback_records": "Visszajelzési rekordok frissítése...",
|
||||
"request_feedback_source": "Forrásintegráció kérése",
|
||||
"required": "Kötelező",
|
||||
"save_changes": "Változtatások mentése",
|
||||
"select_a_survey_to_see_questions": "Válassz egy kérdőívet a kérdések megtekintéséhez",
|
||||
"select_a_value": "Válassz egy értéket...",
|
||||
"select_all": "Összes kiválasztása",
|
||||
"select_feedback_record_directory": "Válasszon egy könyvtárat",
|
||||
"select_feedback_record_source_type": "Válassza ki a forrás típusát",
|
||||
"select_questions": "Kérdések kiválasztása",
|
||||
"select_source_type_description": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát.",
|
||||
"select_source_type_prompt": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát:",
|
||||
"select_survey": "Kérdőív kiválasztása",
|
||||
"select_survey_and_questions": "Kérdőív és kérdések kiválasztása",
|
||||
"select_survey_questions_description": "Válassza ki, mely kérdőívkérdések hozzanak létre visszajelzési rekordokat.",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "3 megjelenítve {count} sorból",
|
||||
"source": "forrás",
|
||||
"source_connect_csv_description": "Visszajelzések importálása CSV fájlokból",
|
||||
"source_connect_feedback_record_mcp_description": "Visszajelzési rekordok küldése az MCP integráción keresztül.",
|
||||
"source_connect_formbricks_description": "Visszajelzések csatlakoztatása a Formbricks kérdőívekből",
|
||||
"source_fields": "Forrásmezők",
|
||||
"source_id": "Forrásazonosító",
|
||||
"source_name": "Forrásnév",
|
||||
"source_type": "Forrás típus",
|
||||
"source_type_cannot_be_changed": "A forrástípus nem módosítható",
|
||||
"sources": "Források",
|
||||
"status_active": "Folyamatban",
|
||||
"status_completed": "Befejezve",
|
||||
"status_draft": "Piszkozat",
|
||||
"status_error": "Hiba",
|
||||
"status_paused": "Szüneteltetve",
|
||||
"status_live_sync": "Élő szinkronizálás",
|
||||
"status_ready": "Kész",
|
||||
"submission_id": "Beküldés azonosítója",
|
||||
"survey_has_no_questions": "Ez a felmérés nem tartalmaz kérdéseket",
|
||||
"survey_import_line": "{surveyName}: {responseCount} válasz × {questionCount} kérdés = {total} visszajelzési rekord",
|
||||
"total_feedback_records": "Összesen: {checked} / {total} visszajelzési rekord kiválasztva {surveyCount} felmérésből",
|
||||
"topics_and_subtopics": "Témák és altémák",
|
||||
"unify_feedback": "Visszajelzések egyesítése",
|
||||
"update_mapping_description": "Frissítse a leképezési konfigurációt ehhez a forráshoz.",
|
||||
"updated_at": "Frissítve",
|
||||
"upload_csv_data_description": "Tölts fel egy CSV fájlt a visszajelzési adatok importálásához.",
|
||||
"upload_csv_file": "CSV fájl feltöltése",
|
||||
"user_identifier": "Felhasználó",
|
||||
"value": "Érték"
|
||||
"value": "Érték",
|
||||
"value_boolean": "Érték (logikai)",
|
||||
"value_date": "Érték (dátum)",
|
||||
"value_number": "Érték (szám)",
|
||||
"value_text": "Érték (szöveg)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "{deleteWhat}を削除",
|
||||
"description": "説明",
|
||||
"disable": "無効にする",
|
||||
"disabled": "無効",
|
||||
"disallow": "許可しない",
|
||||
"discard": "破棄",
|
||||
"dismissed": "非表示",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "このアクションを実行するための認証がされていません。",
|
||||
"not_authorized": "権限がありません",
|
||||
"not_connected": "未接続",
|
||||
"not_set": "未設定",
|
||||
"note": "メモ",
|
||||
"notifications": "通知",
|
||||
"number": "数値",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "一部のファイルのアップロードに失敗しました",
|
||||
"something_went_wrong": "問題が発生しました",
|
||||
"something_went_wrong_please_try_again": "問題が発生しました。もう一度お試しください。",
|
||||
"soon": "近日公開",
|
||||
"sort_by": "並び替え",
|
||||
"start_free_trial": "無料トライアルを開始",
|
||||
"status": "ステータス",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "アプリやウェブサイトをFormbricksに接続します。",
|
||||
"cache_update_delay_description": "アンケート、連絡先、アクション、その他のデータを更新した場合、Formbricks SDKを実行しているローカルアプリに変更が反映されるまで最大1分かかることがあります。",
|
||||
"cache_update_delay_title": "キャッシュにより変更は約1分後に反映されます",
|
||||
"environment_id_legacy": "環境ID(レガシー)",
|
||||
"environment_id_legacy_alert": "既存のSDKセットアップでは、レガシーの環境IDが使用されている可能性があります。",
|
||||
"environment_id_legacy_alert_link": "理由と移行方法について詳しく見る。",
|
||||
"formbricks_sdk_connected": "Formbricks SDKは接続されています",
|
||||
"formbricks_sdk_not_connected": "Formbricks SDKはまだ接続されていません。",
|
||||
"formbricks_sdk_not_connected_description": "ウェブサイトやアプリにFormbricks SDKを追加してFormbricksと接続してください",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "フィードバックディレクトリ",
|
||||
"no_access": "フィードバック記録ディレクトリを管理する権限がありません。",
|
||||
"no_connectors": "このディレクトリにリンクされているコネクタはまだありません。",
|
||||
"pause_connectors_confirmation_description": "これらのコネクタを一時停止すると、新しいレコードは追加されません。",
|
||||
"pause_connectors_confirmation_title": "関連するコネクタを一時停止しますか?",
|
||||
"select_workspaces_placeholder": "ワークスペースを選択...",
|
||||
"show_archived": "アーカイブ済みを表示",
|
||||
"title": "フィードバック記録ディレクトリ",
|
||||
"unarchive": "アーカイブ解除"
|
||||
"unarchive": "アーカイブ解除",
|
||||
"unarchive_workspace_conflict": "割り当てられているワークスペースの1つ以上がアーカイブされているため、このディレクトリをアーカイブ解除できません。",
|
||||
"workspace_access": "ワークスペースアクセス"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "このワークスペースにアクセスできるチームを確認します。"
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "フィードバックレコードを追加する",
|
||||
"add_feedback_record_description": "フィードバック記録を手動で作成します。",
|
||||
"add_feedback_source": "フィードバックソースを追加",
|
||||
"add_source": "ソースを追加",
|
||||
"allowed_values": "許可される値: {values}",
|
||||
"api_ingestion": "API取り込み",
|
||||
"api_ingestion_manage_api_keys": "APIキーを管理",
|
||||
"api_ingestion_settings_description": "管理APIを使用してフィードバックレコードを送信します。",
|
||||
"auto_generated": "自動生成",
|
||||
"change_file": "ファイルを変更",
|
||||
"click_load_sample_csv": "「サンプルCSVを読み込む」をクリックして列を表示",
|
||||
"click_to_upload": "クリックしてアップロード",
|
||||
"collected_at": "収集日時",
|
||||
"configure_import": "インポートを設定",
|
||||
"configure_mapping": "マッピングを設定",
|
||||
"connection": "接続",
|
||||
"connector_created_successfully": "コネクタが正常に作成されました",
|
||||
"connector_deleted_successfully": "コネクタが正常に削除されました",
|
||||
"connector_duplicated_successfully": "コネクタが正常に複製されました",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "データを2回インポートすると、重複したレコードが作成されます。",
|
||||
"csv_inconsistent_columns": "行 {row} の列が一致しません。すべての行は同じヘッダーを持つ必要があります。",
|
||||
"csv_max_records": "最大 {max} 件のレコードまで許可されています。",
|
||||
"custom_source_type": "カスタムソースタイプ",
|
||||
"custom_source_type_placeholder": "カスタムソースタイプを入力してください",
|
||||
"default_connector_name_csv": "CSVインポート",
|
||||
"default_connector_name_formbricks": "Formbricks フォーム接続",
|
||||
"deselect_all": "すべて選択解除",
|
||||
"discard_feedback_record_changes_description": "このドロワーを閉じると、変更内容は失われます。",
|
||||
"discard_feedback_record_changes_title": "保存されていない変更を破棄しますか?",
|
||||
"drop_a_field_here": "ここにフィールドをドロップ",
|
||||
"drop_field_or": "フィールドをドロップまたは",
|
||||
"edit_csv_mapping": "CSVマッピングを編集",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "列挙型",
|
||||
"failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました",
|
||||
"feedback_date": "現在の日付",
|
||||
"feedback_record_created_successfully": "フィードバックレコードが正常に作成されました",
|
||||
"feedback_record_details": "フィードバック記録の詳細",
|
||||
"feedback_record_details_description": "フィードバック レコード フィールドを確認して更新します。",
|
||||
"feedback_record_directory": "フィードバックレコードディレクトリ",
|
||||
"feedback_record_fields": "フィードバックレコードフィールド",
|
||||
"feedback_record_mcp": "フィードバックレコードMCP",
|
||||
"feedback_record_updated_successfully": "フィードバックレコードが正常に更新されました",
|
||||
"feedback_record_value_required": "選択したフィールド タイプには値が必要です",
|
||||
"feedback_records": "フィードバックレコード",
|
||||
"feedback_records_refreshed": "フィードバックレコードを更新しました",
|
||||
"feedback_sources": "フィードバックソース",
|
||||
"feedback_sources_directory_access_multiple": "これらのソースからの新しいレコードは次の場所に保存されます:{directoryNames}",
|
||||
"feedback_sources_directory_access_single": "このソースからの新しいレコードは次の場所に保存されます:{directoryNames}",
|
||||
"feedback_sources_settings_description": "このワークスペースのすべてのフィードバックソースを接続・管理します。",
|
||||
"field_group_id": "フィールドグループID",
|
||||
"field_group_label": "フィールドグループラベル",
|
||||
"field_id": "フィールドID",
|
||||
"field_label": "フィールドラベル",
|
||||
"field_type": "フィールドタイプ",
|
||||
"formbricks_surveys": "Formbricks フォーム",
|
||||
"frd_cannot_be_changed": "フィードバックディレクトリは作成後に変更できません。",
|
||||
"go_to_feedback_record_directories": "ディレクトリ設定へ移動",
|
||||
"historical_import_complete": "インポート完了: {successes}件成功、{failures}件失敗、{skipped}件スキップ(データなし)",
|
||||
"import_csv_data": "フィードバックをインポート",
|
||||
"import_feedback": "フィードバックをインポート",
|
||||
"import_historical_responses": "過去の回答をインポート",
|
||||
"import_historical_responses_description": "このアンケートから既存の回答を今すぐインポートします。",
|
||||
"import_rows": "{count}行をインポート",
|
||||
"import_via_source_name": "「{sourceName}」経由でインポート",
|
||||
"importing_data": "データをインポート中...",
|
||||
"importing_historical_data": "過去のデータをインポート中...",
|
||||
"invalid_enum_values": "{field}にマッピングされた列に無効な値があります",
|
||||
"invalid_values_found": "検出された値: {values}(行: {rows}){extra}",
|
||||
"load_sample_csv": "サンプルCSVを読み込む",
|
||||
"n_supported_questions": "{count} 件のサポートされている質問",
|
||||
"manage_directories": "ディレクトリを管理",
|
||||
"manage_feedback_sources": "フィードバックソースを管理する",
|
||||
"metadata": "メタデータ",
|
||||
"metadata_key": "メタデータキー",
|
||||
"metadata_read_only_entries": "読み取り専用メタデータ値 (非文字列)",
|
||||
"metadata_value": "メタデータ値",
|
||||
"missing_feedback_source_title": "フィードバックソースが見つかりませんか?",
|
||||
"no_feedback_record_directory_available": "このワークスペースにフィードバックレコードディレクトリが割り当てられていません。まず作成または割り当てを行ってください。",
|
||||
"no_feedback_records": "フィードバックレコードはまだありません。コネクタがデータの送信を開始すると、ここにレコードが表示されます。",
|
||||
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
|
||||
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
|
||||
"no_surveys_found": "この環境にフォームが見つかりません",
|
||||
"optional": "任意",
|
||||
"or_drag_and_drop": "またはドラッグ&ドロップ",
|
||||
"question_selected": "<strong>{count}</strong>件の質問が選択されています。これらの質問への各回答は、新しいフィードバックレコードを作成します。",
|
||||
"question_type_not_supported": "この質問タイプはサポートされていません",
|
||||
"questions_selected": "<strong>{count}</strong>件の質問が選択されています。これらの質問への各回答は、新しいフィードバックレコードを作成します。",
|
||||
"records_will_go_to": "レコードの保存先",
|
||||
"refresh_feedback_records": "フィードバック記録を更新",
|
||||
"refreshing_feedback_records": "フィードバックレコードを更新中...",
|
||||
"request_feedback_source": "ソース統合をリクエスト",
|
||||
"required": "必須",
|
||||
"save_changes": "変更を保存",
|
||||
"select_a_survey_to_see_questions": "フォームを選択して質問を表示",
|
||||
"select_a_value": "値を選択...",
|
||||
"select_all": "すべて選択",
|
||||
"select_feedback_record_directory": "ディレクトリを選択",
|
||||
"select_feedback_record_source_type": "ソースタイプを選択してください",
|
||||
"select_questions": "質問を選択",
|
||||
"select_source_type_description": "接続するフィードバックソースの種類を選択してください。",
|
||||
"select_source_type_prompt": "接続するフィードバックソースの種類を選択してください:",
|
||||
"select_survey": "フォームを選択",
|
||||
"select_survey_and_questions": "フォームと質問を選択",
|
||||
"select_survey_questions_description": "フィードバックレコードを作成するフォームの質問を選択してください。",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "{count}行中3行を表示",
|
||||
"source": "ソース",
|
||||
"source_connect_csv_description": "CSVファイルからフィードバックをインポート",
|
||||
"source_connect_feedback_record_mcp_description": "MCP統合を通じてフィードバックレコードを送信します。",
|
||||
"source_connect_formbricks_description": "Formbricksフォームからフィードバックを接続",
|
||||
"source_fields": "ソースフィールド",
|
||||
"source_id": "ソースID",
|
||||
"source_name": "ソース名",
|
||||
"source_type": "ソースタイプ",
|
||||
"source_type_cannot_be_changed": "ソースタイプは変更できません",
|
||||
"sources": "ソース",
|
||||
"status_active": "進行中",
|
||||
"status_completed": "完了",
|
||||
"status_draft": "下書き",
|
||||
"status_error": "エラー",
|
||||
"status_paused": "一時停止",
|
||||
"status_live_sync": "リアルタイム同期",
|
||||
"status_ready": "準備完了",
|
||||
"submission_id": "提出ID",
|
||||
"survey_has_no_questions": "このアンケートには質問がありません",
|
||||
"survey_import_line": "{surveyName}: {responseCount}件の回答 × {questionCount}件の質問 = {total}件のフィードバックレコード",
|
||||
"total_feedback_records": "合計: {surveyCount}件のアンケート全体で{total}件中{checked}件のフィードバックレコードが選択されています",
|
||||
"topics_and_subtopics": "トピックとサブトピック",
|
||||
"unify_feedback": "フィードバックを統合",
|
||||
"update_mapping_description": "このソースのマッピング設定を更新します。",
|
||||
"updated_at": "更新日時",
|
||||
"upload_csv_data_description": "CSVファイルをアップロードして、フィードバックデータをインポートします。",
|
||||
"upload_csv_file": "CSVファイルをアップロード",
|
||||
"user_identifier": "ユーザー",
|
||||
"value": "値"
|
||||
"value": "値",
|
||||
"value_boolean": "値 (ブール値)",
|
||||
"value_date": "値 (日付)",
|
||||
"value_number": "値(数値)",
|
||||
"value_text": "値 (テキスト)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "Verwijder {deleteWhat}",
|
||||
"description": "Beschrijving",
|
||||
"disable": "Uitzetten",
|
||||
"disabled": "Uitgeschakeld",
|
||||
"disallow": "Niet toestaan",
|
||||
"discard": "Weggooien",
|
||||
"dismissed": "Afgewezen",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "U bent niet geverifieerd om deze actie uit te voeren.",
|
||||
"not_authorized": "Niet geautoriseerd",
|
||||
"not_connected": "Niet verbonden",
|
||||
"not_set": "Niet ingesteld",
|
||||
"note": "Opmerking",
|
||||
"notifications": "Meldingen",
|
||||
"number": "Nummer",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "Sommige bestanden konden niet worden geüpload",
|
||||
"something_went_wrong": "Er is iets misgegaan",
|
||||
"something_went_wrong_please_try_again": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||
"soon": "Binnenkort",
|
||||
"sort_by": "Sorteer op",
|
||||
"start_free_trial": "Start gratis proefperiode",
|
||||
"status": "Status",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "Verbind uw app of website met Formbricks.",
|
||||
"cache_update_delay_description": "Wanneer u wijzigingen aanbrengt in enquêtes, contacten, acties of andere gegevens, kan het tot 1 minuut duren voordat deze wijzigingen verschijnen in uw lokale app die de Formbricks SDK gebruikt.",
|
||||
"cache_update_delay_title": "Wijzigingen worden na ~1 minuut weergegeven vanwege caching",
|
||||
"environment_id_legacy": "Omgeving-ID (verouderd)",
|
||||
"environment_id_legacy_alert": "Je bestaande SDK-configuratie gebruikt mogelijk nog een verouderde Omgeving-ID.",
|
||||
"environment_id_legacy_alert_link": "Lees waarom en hoe je kunt migreren.",
|
||||
"formbricks_sdk_connected": "Formbricks SDK is verbonden",
|
||||
"formbricks_sdk_not_connected": "Formbricks SDK is nog niet verbonden.",
|
||||
"formbricks_sdk_not_connected_description": "Voeg de Formbricks SDK toe aan uw website of app om deze te verbinden met Formbricks",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "Feedbackmappen",
|
||||
"no_access": "Je hebt geen toestemming om feedbackregistratiemappen te beheren.",
|
||||
"no_connectors": "Nog geen connectoren gekoppeld aan deze map.",
|
||||
"pause_connectors_confirmation_description": "Als je deze connectoren pauzeert, worden er geen nieuwe records meer toegevoegd.",
|
||||
"pause_connectors_confirmation_title": "Gekoppelde connectoren pauzeren?",
|
||||
"select_workspaces_placeholder": "Selecteer werkruimtes...",
|
||||
"show_archived": "Gearchiveerde weergeven",
|
||||
"title": "Feedbackregistratiemappen",
|
||||
"unarchive": "Dearchiveren"
|
||||
"unarchive": "Dearchiveren",
|
||||
"unarchive_workspace_conflict": "Deze map kan niet worden gedearchiveerd omdat een of meer toegewezen workspaces zijn gearchiveerd.",
|
||||
"workspace_access": "Workspace-toegang"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Dataverrijking & analyse (AI)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "Bekijk welke teams toegang hebben tot deze workspace."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Feedbackrecord toevoegen",
|
||||
"add_feedback_record_description": "Maak handmatig een feedbackrecord.",
|
||||
"add_feedback_source": "Feedbackbron toevoegen",
|
||||
"add_source": "Bron toevoegen",
|
||||
"allowed_values": "Toegestane waarden: {values}",
|
||||
"api_ingestion": "API-inname",
|
||||
"api_ingestion_manage_api_keys": "API-sleutels beheren",
|
||||
"api_ingestion_settings_description": "Verstuur feedbackrecords via de Management API.",
|
||||
"auto_generated": "Automatisch gegenereerd",
|
||||
"change_file": "Bestand wijzigen",
|
||||
"click_load_sample_csv": "Klik op 'Voorbeeld CSV laden' om kolommen te zien",
|
||||
"click_to_upload": "Klik om te uploaden",
|
||||
"collected_at": "Verzameld op",
|
||||
"configure_import": "Import configureren",
|
||||
"configure_mapping": "Koppeling configureren",
|
||||
"connection": "Verbinding",
|
||||
"connector_created_successfully": "Connector succesvol aangemaakt",
|
||||
"connector_deleted_successfully": "Connector succesvol verwijderd",
|
||||
"connector_duplicated_successfully": "Connector succesvol gedupliceerd",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "Gegevens twee keer importeren zal dubbele records aanmaken.",
|
||||
"csv_inconsistent_columns": "Rij {row} heeft inconsistente kolommen. Alle rijen moeten dezelfde headers hebben.",
|
||||
"csv_max_records": "Maximaal {max} records toegestaan.",
|
||||
"custom_source_type": "Aangepast brontype",
|
||||
"custom_source_type_placeholder": "Voer een aangepast brontype in",
|
||||
"default_connector_name_csv": "CSV import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey verbinding",
|
||||
"deselect_all": "Alles deselecteren",
|
||||
"discard_feedback_record_changes_description": "Als u deze lade sluit, gaan uw wijzigingen verloren.",
|
||||
"discard_feedback_record_changes_title": "Niet-opgeslagen wijzigingen verwijderen?",
|
||||
"drop_a_field_here": "Zet hier een veld neer",
|
||||
"drop_field_or": "Zet veld neer of",
|
||||
"edit_csv_mapping": "CSV-mapping bewerken",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Kan feedbackrecords niet laden",
|
||||
"feedback_date": "Huidige datum",
|
||||
"feedback_record_created_successfully": "Feedbackrecord is succesvol aangemaakt",
|
||||
"feedback_record_details": "Details van feedbackrecord",
|
||||
"feedback_record_details_description": "Controleer en update de feedbackrecordvelden.",
|
||||
"feedback_record_directory": "Feedbackrecordmap",
|
||||
"feedback_record_fields": "Feedbackrecordvelden",
|
||||
"feedback_record_mcp": "Feedbackrecord MCP",
|
||||
"feedback_record_updated_successfully": "Feedbackrecord is succesvol bijgewerkt",
|
||||
"feedback_record_value_required": "Er is een waarde vereist voor het geselecteerde veldtype",
|
||||
"feedback_records": "Feedbackrecords",
|
||||
"feedback_records_refreshed": "Feedbackrecords vernieuwd",
|
||||
"feedback_sources": "Feedbackbronnen",
|
||||
"feedback_sources_directory_access_multiple": "Nieuwe records van deze bronnen worden opgeslagen in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Nieuwe records van deze bron worden opgeslagen in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Verbind en beheer alle feedbackbronnen voor deze werkruimte.",
|
||||
"field_group_id": "Veldgroep-ID",
|
||||
"field_group_label": "Veldgroeplabel",
|
||||
"field_id": "Veld-ID",
|
||||
"field_label": "Veldlabel",
|
||||
"field_type": "Veldtype",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "Feedbackmap kan niet worden gewijzigd na aanmaak.",
|
||||
"go_to_feedback_record_directories": "Ga naar map-instellingen",
|
||||
"historical_import_complete": "Import voltooid: {successes} geslaagd, {failures} mislukt, {skipped} overgeslagen (geen data)",
|
||||
"import_csv_data": "Feedback importeren",
|
||||
"import_feedback": "Feedback importeren",
|
||||
"import_historical_responses": "Historische reacties importeren",
|
||||
"import_historical_responses_description": "Importeer bestaande reacties van deze enquête nu.",
|
||||
"import_rows": "{count, plural, one {Importeer 1 rij} other {Importeer # rijen}}",
|
||||
"import_via_source_name": "Importeren via \"{sourceName}\"",
|
||||
"importing_data": "Gegevens importeren...",
|
||||
"importing_historical_data": "Historische gegevens importeren...",
|
||||
"invalid_enum_values": "Ongeldige waarden in kolom gekoppeld aan {field}",
|
||||
"invalid_values_found": "Gevonden: {values} (rijen: {rows}) {extra}",
|
||||
"load_sample_csv": "Voorbeeld-CSV laden",
|
||||
"n_supported_questions": "{count} ondersteunde vragen",
|
||||
"manage_directories": "Mappen beheren",
|
||||
"manage_feedback_sources": "Beheer feedbackbronnen",
|
||||
"metadata": "Metagegevens",
|
||||
"metadata_key": "Metagegevenssleutel",
|
||||
"metadata_read_only_entries": "Alleen-lezen metadatawaarden (niet-tekenreeks)",
|
||||
"metadata_value": "Metagegevenswaarde",
|
||||
"missing_feedback_source_title": "Mis je een feedbackbron?",
|
||||
"no_feedback_record_directory_available": "Geen feedbackrecordmap toegewezen aan deze workspace. Maak er eerst een aan of wijs er een toe.",
|
||||
"no_feedback_records": "Nog geen feedbackrecords. Records verschijnen hier zodra je connectoren gegevens beginnen te verzenden.",
|
||||
"no_source_fields_loaded": "Nog geen bronvelden geladen",
|
||||
"no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.",
|
||||
"no_surveys_found": "Geen enquêtes gevonden in deze omgeving",
|
||||
"optional": "Optioneel",
|
||||
"or_drag_and_drop": "of sleep en zet neer",
|
||||
"question_selected": "<strong>{count}</strong> vraag geselecteerd. Elk antwoord op deze vraag zal een nieuw feedbackrecord aanmaken.",
|
||||
"question_type_not_supported": "Dit vraagtype wordt niet ondersteund",
|
||||
"questions_selected": "<strong>{count}</strong> vragen geselecteerd. Elk antwoord op deze vragen zal een nieuw feedbackrecord aanmaken.",
|
||||
"records_will_go_to": "Records gaan naar",
|
||||
"refresh_feedback_records": "Feedbackrecords verversen",
|
||||
"refreshing_feedback_records": "Feedbackrecords vernieuwen...",
|
||||
"request_feedback_source": "Bronintegratie aanvragen",
|
||||
"required": "Vereist",
|
||||
"save_changes": "Wijzigingen opslaan",
|
||||
"select_a_survey_to_see_questions": "Selecteer een enquête om de vragen te zien",
|
||||
"select_a_value": "Selecteer een waarde...",
|
||||
"select_all": "Selecteer alles",
|
||||
"select_feedback_record_directory": "Selecteer een map",
|
||||
"select_feedback_record_source_type": "Selecteer brontype",
|
||||
"select_questions": "Selecteer vragen",
|
||||
"select_source_type_description": "Selecteer het type feedbackbron dat je wilt verbinden.",
|
||||
"select_source_type_prompt": "Selecteer het type feedbackbron dat je wilt verbinden:",
|
||||
"select_survey": "Selecteer enquête",
|
||||
"select_survey_and_questions": "Selecteer enquête & vragen",
|
||||
"select_survey_questions_description": "Kies welke enquêtevragen FeedbackRecords moeten aanmaken.",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "3 van {count} rijen weergegeven",
|
||||
"source": "bron",
|
||||
"source_connect_csv_description": "Importeer feedback uit CSV-bestanden",
|
||||
"source_connect_feedback_record_mcp_description": "Verstuur feedbackrecords via de MCP-integratie.",
|
||||
"source_connect_formbricks_description": "Verbind feedback van je Formbricks-enquêtes",
|
||||
"source_fields": "Bronvelden",
|
||||
"source_id": "Bron-ID",
|
||||
"source_name": "Bronnaam",
|
||||
"source_type": "Brontype",
|
||||
"source_type_cannot_be_changed": "Brontype kan niet worden gewijzigd",
|
||||
"sources": "Bronnen",
|
||||
"status_active": "In uitvoering",
|
||||
"status_completed": "Voltooid",
|
||||
"status_draft": "Voorlopige versie",
|
||||
"status_error": "Fout",
|
||||
"status_paused": "Gepauzeerd",
|
||||
"status_live_sync": "Live synchronisatie",
|
||||
"status_ready": "Klaar",
|
||||
"submission_id": "Inzendings-ID",
|
||||
"survey_has_no_questions": "Deze enquête heeft geen vragen",
|
||||
"survey_import_line": "{surveyName}: {responseCount} antwoorden × {questionCount} vragen = {total} feedbackrecords",
|
||||
"total_feedback_records": "Totaal: {checked} van {total} feedbackrecords geselecteerd over {surveyCount} enquêtes",
|
||||
"topics_and_subtopics": "Onderwerpen en subonderwerpen",
|
||||
"unify_feedback": "Feedback verenigen",
|
||||
"update_mapping_description": "Werk de mappingconfiguratie voor deze bron bij.",
|
||||
"updated_at": "Bijgewerkt op",
|
||||
"upload_csv_data_description": "Upload een CSV-bestand om feedbackgegevens te importeren.",
|
||||
"upload_csv_file": "CSV-bestand uploaden",
|
||||
"user_identifier": "Gebruiker",
|
||||
"value": "Waarde"
|
||||
"value": "Waarde",
|
||||
"value_boolean": "Waarde (Booleaans)",
|
||||
"value_date": "Waarde (datum)",
|
||||
"value_number": "Waarde (getal)",
|
||||
"value_text": "Waarde (tekst)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "Excluir {deleteWhat}",
|
||||
"description": "Descrição",
|
||||
"disable": "desativar",
|
||||
"disabled": "Desativado",
|
||||
"disallow": "Não permita",
|
||||
"discard": "Descartar",
|
||||
"dismissed": "Dispensado",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Você não está autenticado para realizar essa ação.",
|
||||
"not_authorized": "Não autorizado",
|
||||
"not_connected": "Desconectado",
|
||||
"not_set": "Não definido",
|
||||
"note": "Nota",
|
||||
"notifications": "Notificações",
|
||||
"number": "Número",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "Alguns arquivos falharam ao enviar",
|
||||
"something_went_wrong": "Algo deu errado",
|
||||
"something_went_wrong_please_try_again": "Algo deu errado. Tente novamente.",
|
||||
"soon": "Em breve",
|
||||
"sort_by": "Ordenar por",
|
||||
"start_free_trial": "Iniciar teste gratuito",
|
||||
"status": "status",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "Conecte seu app ou site ao Formbricks.",
|
||||
"cache_update_delay_description": "Quando você faz atualizações em pesquisas, contatos, ações ou outros dados, pode levar até 1 minuto para que essas alterações apareçam no seu app local executando o SDK do Formbricks.",
|
||||
"cache_update_delay_title": "As alterações serão refletidas após ~1 minuto devido ao cache",
|
||||
"environment_id_legacy": "ID do Ambiente (legado)",
|
||||
"environment_id_legacy_alert": "Sua configuração de SDK existente pode ainda estar usando um ID de Ambiente legado.",
|
||||
"environment_id_legacy_alert_link": "Saiba por que e como migrar.",
|
||||
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
|
||||
"formbricks_sdk_not_connected": "O SDK do Formbricks ainda não está conectado.",
|
||||
"formbricks_sdk_not_connected_description": "Adicione o SDK do Formbricks ao seu site ou app para conectá-lo ao Formbricks",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "Diretórios de Feedback",
|
||||
"no_access": "Você não tem permissão para gerenciar diretórios de registros de feedback.",
|
||||
"no_connectors": "Nenhum conector vinculado a este diretório ainda.",
|
||||
"pause_connectors_confirmation_description": "Ao pausar esses conectores, novos registros deixarão de ser adicionados.",
|
||||
"pause_connectors_confirmation_title": "Pausar conectores vinculados?",
|
||||
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
|
||||
"show_archived": "Mostrar arquivados",
|
||||
"title": "Diretórios de Registros de Feedback",
|
||||
"unarchive": "Desarquivar"
|
||||
"unarchive": "Desarquivar",
|
||||
"unarchive_workspace_conflict": "Não é possível desarquivar este diretório porque um ou mais workspaces atribuídos estão arquivados.",
|
||||
"workspace_access": "Acesso ao workspace"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "Veja quais equipes podem acessar este workspace."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Adicionar registro de feedback",
|
||||
"add_feedback_record_description": "Crie um registro de feedback manualmente.",
|
||||
"add_feedback_source": "Adicionar fonte de feedback",
|
||||
"add_source": "Adicionar fonte",
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"api_ingestion": "Ingestão de API",
|
||||
"api_ingestion_manage_api_keys": "Gerenciar chaves de API",
|
||||
"api_ingestion_settings_description": "Envie registros de feedback usando a API de Gerenciamento.",
|
||||
"auto_generated": "Gerado automaticamente",
|
||||
"change_file": "Alterar arquivo",
|
||||
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
|
||||
"click_to_upload": "Clique para fazer upload",
|
||||
"collected_at": "Coletado em",
|
||||
"configure_import": "Configurar importação",
|
||||
"configure_mapping": "Configurar mapeamento",
|
||||
"connection": "Conexão",
|
||||
"connector_created_successfully": "Conector criado com sucesso",
|
||||
"connector_deleted_successfully": "Conector excluído com sucesso",
|
||||
"connector_duplicated_successfully": "Conector duplicado com sucesso",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "Importar dados duas vezes criará registros duplicados.",
|
||||
"csv_inconsistent_columns": "A linha {row} possui colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.",
|
||||
"csv_max_records": "Máximo de {max} registros permitidos.",
|
||||
"custom_source_type": "Tipo de origem personalizado",
|
||||
"custom_source_type_placeholder": "Insira o tipo de fonte personalizado",
|
||||
"default_connector_name_csv": "Importação CSV",
|
||||
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
|
||||
"deselect_all": "Desmarcar tudo",
|
||||
"discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.",
|
||||
"discard_feedback_record_changes_title": "Descartar alterações não salvas?",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
"drop_field_or": "Solte o campo ou",
|
||||
"edit_csv_mapping": "Editar mapeamento CSV",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registros de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_record_created_successfully": "Registro de feedback criado com sucesso",
|
||||
"feedback_record_details": "Detalhes do registro de feedback",
|
||||
"feedback_record_details_description": "Revise e atualize os campos de registro de feedback.",
|
||||
"feedback_record_directory": "Diretório de Registros de Feedback",
|
||||
"feedback_record_fields": "Campos do registro de feedback",
|
||||
"feedback_record_mcp": "Registro de Feedback MCP",
|
||||
"feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso",
|
||||
"feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado",
|
||||
"feedback_records": "Registros de feedback",
|
||||
"feedback_records_refreshed": "Registros de feedback atualizados",
|
||||
"feedback_sources": "Fontes de Feedback",
|
||||
"feedback_sources_directory_access_multiple": "Novos registros dessas fontes serão armazenados em: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Novos registros desta fonte serão armazenados em: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Conecte e gerencie todas as fontes de feedback para este workspace.",
|
||||
"field_group_id": "ID do grupo de campos",
|
||||
"field_group_label": "Etiqueta do grupo de campos",
|
||||
"field_id": "ID do campo",
|
||||
"field_label": "Rótulo do campo",
|
||||
"field_type": "Tipo de campo",
|
||||
"formbricks_surveys": "Pesquisas Formbricks",
|
||||
"frd_cannot_be_changed": "O diretório de feedback não pode ser alterado após a criação.",
|
||||
"go_to_feedback_record_directories": "Ir para configurações de diretórios",
|
||||
"historical_import_complete": "Importação concluída: {successes} bem-sucedidas, {failures} falharam, {skipped} ignoradas (sem dados)",
|
||||
"import_csv_data": "Importar feedback",
|
||||
"import_feedback": "Importar feedback",
|
||||
"import_historical_responses": "Importar respostas históricas",
|
||||
"import_historical_responses_description": "Importe respostas existentes desta pesquisa agora.",
|
||||
"import_rows": "Importar {count} linhas",
|
||||
"import_via_source_name": "Importar via \"{sourceName}\"",
|
||||
"importing_data": "Importando dados...",
|
||||
"importing_historical_data": "Importando dados históricos...",
|
||||
"invalid_enum_values": "Valores inválidos na coluna mapeada para {field}",
|
||||
"invalid_values_found": "Encontrados: {values} (linhas: {rows}) {extra}",
|
||||
"load_sample_csv": "Carregar CSV de exemplo",
|
||||
"n_supported_questions": "{count} perguntas suportadas",
|
||||
"manage_directories": "Gerenciar diretórios",
|
||||
"manage_feedback_sources": "Gerenciar fontes de feedback",
|
||||
"metadata": "Metadados",
|
||||
"metadata_key": "Chave de metadados",
|
||||
"metadata_read_only_entries": "Valores de metadados somente leitura (sem string)",
|
||||
"metadata_value": "Valor dos metadados",
|
||||
"missing_feedback_source_title": "Faltando alguma fonte de feedback?",
|
||||
"no_feedback_record_directory_available": "Nenhum diretório de registros de feedback atribuído a este workspace. Crie ou atribua um primeiro.",
|
||||
"no_feedback_records": "Nenhum registro de feedback ainda. Os registros aparecerão aqui assim que seus conectores começarem a enviar dados.",
|
||||
"no_source_fields_loaded": "Nenhum campo de origem carregado ainda",
|
||||
"no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.",
|
||||
"no_surveys_found": "Nenhuma pesquisa encontrada neste ambiente",
|
||||
"optional": "Opcional",
|
||||
"or_drag_and_drop": "ou arraste e solte",
|
||||
"question_selected": "<strong>{count}</strong> pergunta selecionada. Cada resposta a esta pergunta criará um novo registro de feedback.",
|
||||
"question_type_not_supported": "Este tipo de pergunta não é suportado",
|
||||
"questions_selected": "<strong>{count}</strong> perguntas selecionadas. Cada resposta a estas perguntas criará um novo registro de feedback.",
|
||||
"records_will_go_to": "Os registros serão enviados para",
|
||||
"refresh_feedback_records": "Atualizar registros de feedback",
|
||||
"refreshing_feedback_records": "Atualizando registros de feedback...",
|
||||
"request_feedback_source": "Solicitar integração de fonte",
|
||||
"required": "Obrigatório",
|
||||
"save_changes": "Salvar alterações",
|
||||
"select_a_survey_to_see_questions": "Selecione uma pesquisa para ver suas perguntas",
|
||||
"select_a_value": "Selecione um valor...",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_feedback_record_directory": "Selecione um diretório",
|
||||
"select_feedback_record_source_type": "Selecione o tipo de fonte",
|
||||
"select_questions": "Selecionar perguntas",
|
||||
"select_source_type_description": "Selecione o tipo de fonte de feedback que você deseja conectar.",
|
||||
"select_source_type_prompt": "Selecione o tipo de fonte de feedback que você deseja conectar:",
|
||||
"select_survey": "Selecionar pesquisa",
|
||||
"select_survey_and_questions": "Selecionar pesquisa e perguntas",
|
||||
"select_survey_questions_description": "Escolha quais perguntas da pesquisa devem criar FeedbackRecords.",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "Mostrando 3 de {count} linhas",
|
||||
"source": "fonte",
|
||||
"source_connect_csv_description": "Importar feedback de arquivos CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Envie registros de feedback através da integração MCP.",
|
||||
"source_connect_formbricks_description": "Conectar feedback das suas pesquisas Formbricks",
|
||||
"source_fields": "Campos de origem",
|
||||
"source_id": "ID da fonte",
|
||||
"source_name": "Nome da origem",
|
||||
"source_type": "Tipo de fonte",
|
||||
"source_type_cannot_be_changed": "O tipo de origem não pode ser alterado",
|
||||
"sources": "Origens",
|
||||
"status_active": "Em andamento",
|
||||
"status_completed": "Concluído",
|
||||
"status_draft": "Rascunho",
|
||||
"status_error": "Erro",
|
||||
"status_paused": "Pausado",
|
||||
"status_live_sync": "Sincronização ao vivo",
|
||||
"status_ready": "Pronto",
|
||||
"submission_id": "ID de envio",
|
||||
"survey_has_no_questions": "Esta pesquisa não possui perguntas",
|
||||
"survey_import_line": "{surveyName}: {responseCount} respostas × {questionCount} perguntas = {total} registros de feedback",
|
||||
"total_feedback_records": "Total: {checked} de {total} registros de feedback selecionados em {surveyCount} pesquisas",
|
||||
"topics_and_subtopics": "Tópicos e subtópicos",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Atualize a configuração de mapeamento para esta fonte.",
|
||||
"updated_at": "Atualizado em",
|
||||
"upload_csv_data_description": "Faça upload de um arquivo CSV para importar dados de feedback.",
|
||||
"upload_csv_file": "Fazer upload de arquivo CSV",
|
||||
"user_identifier": "Usuário",
|
||||
"value": "Valor"
|
||||
"value": "Valor",
|
||||
"value_boolean": "Valor (Booleano)",
|
||||
"value_date": "Valor (Data)",
|
||||
"value_number": "Valor (Número)",
|
||||
"value_text": "Valor (Texto)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "Eliminar {deleteWhat}",
|
||||
"description": "Descrição",
|
||||
"disable": "Desativar",
|
||||
"disabled": "Desativado",
|
||||
"disallow": "Não permitir",
|
||||
"discard": "Descartar",
|
||||
"dismissed": "Dispensado",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Não está autenticado para realizar esta ação.",
|
||||
"not_authorized": "Não autorizado",
|
||||
"not_connected": "Não Conectado",
|
||||
"not_set": "Não definido",
|
||||
"note": "Nota",
|
||||
"notifications": "Notificações",
|
||||
"number": "Número",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "Alguns ficheiros falharam ao carregar",
|
||||
"something_went_wrong": "Algo correu mal",
|
||||
"something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.",
|
||||
"soon": "Em breve",
|
||||
"sort_by": "Ordem",
|
||||
"start_free_trial": "Iniciar teste gratuito",
|
||||
"status": "Estado",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "Ligue a sua aplicação ou website ao Formbricks.",
|
||||
"cache_update_delay_description": "Quando faz atualizações a inquéritos, contactos, ações ou outros dados, pode demorar até 1 minuto para que essas alterações apareçam na sua aplicação local que executa o SDK do Formbricks.",
|
||||
"cache_update_delay_title": "As alterações serão refletidas após ~1 minuto devido ao caching",
|
||||
"environment_id_legacy": "ID de Ambiente (legado)",
|
||||
"environment_id_legacy_alert": "A tua configuração SDK existente pode ainda utilizar um ID de Ambiente legado.",
|
||||
"environment_id_legacy_alert_link": "Descobre porquê e como migrar.",
|
||||
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
|
||||
"formbricks_sdk_not_connected": "O SDK do Formbricks ainda não está conectado.",
|
||||
"formbricks_sdk_not_connected_description": "Adicione o SDK do Formbricks ao seu website ou aplicação para o conectar ao Formbricks",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "Diretórios de Feedback",
|
||||
"no_access": "Não tens permissão para gerir diretórios de registos de feedback.",
|
||||
"no_connectors": "Ainda não há conectores associados a este diretório.",
|
||||
"pause_connectors_confirmation_description": "Ao pausar estes conectores, deixam de ser adicionados novos registos.",
|
||||
"pause_connectors_confirmation_title": "Pausar conectores associados?",
|
||||
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
|
||||
"show_archived": "Mostrar arquivados",
|
||||
"title": "Diretórios de Registos de Feedback",
|
||||
"unarchive": "Desarquivar"
|
||||
"unarchive": "Desarquivar",
|
||||
"unarchive_workspace_conflict": "Não é possível desarquivar este diretório porque um ou mais workspaces atribuídos estão arquivados.",
|
||||
"workspace_access": "Acesso ao workspace"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "Veja quais as equipas que podem aceder a este espaço de trabalho."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Adicionar registro de feedback",
|
||||
"add_feedback_record_description": "Crie um registro de feedback manualmente.",
|
||||
"add_feedback_source": "Adicionar fonte de feedback",
|
||||
"add_source": "Adicionar fonte",
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"api_ingestion": "Ingestão de API",
|
||||
"api_ingestion_manage_api_keys": "Gerir chaves de API",
|
||||
"api_ingestion_settings_description": "Envia registos de feedback através da API de gestão.",
|
||||
"auto_generated": "Gerado automaticamente",
|
||||
"change_file": "Alterar ficheiro",
|
||||
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
|
||||
"click_to_upload": "Clique para carregar",
|
||||
"collected_at": "Recolhido em",
|
||||
"configure_import": "Configurar importação",
|
||||
"configure_mapping": "Configurar mapeamento",
|
||||
"connection": "Conexão",
|
||||
"connector_created_successfully": "Conector criado com sucesso",
|
||||
"connector_deleted_successfully": "Conector eliminado com sucesso",
|
||||
"connector_duplicated_successfully": "Conector duplicado com sucesso",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "Importar dados duas vezes irá criar registos duplicados.",
|
||||
"csv_inconsistent_columns": "A linha {row} tem colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.",
|
||||
"csv_max_records": "Máximo de {max} registos permitidos.",
|
||||
"custom_source_type": "Tipo de origem personalizado",
|
||||
"custom_source_type_placeholder": "Insira o tipo de fonte personalizado",
|
||||
"default_connector_name_csv": "Importação CSV",
|
||||
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
|
||||
"deselect_all": "Desselecionar tudo",
|
||||
"discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.",
|
||||
"discard_feedback_record_changes_title": "Descartar alterações não salvas?",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
"drop_field_or": "Solte o campo ou",
|
||||
"edit_csv_mapping": "Editar mapeamento CSV",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registos de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_record_created_successfully": "Registro de feedback criado com sucesso",
|
||||
"feedback_record_details": "Detalhes do registro de feedback",
|
||||
"feedback_record_details_description": "Revise e atualize os campos de registro de feedback.",
|
||||
"feedback_record_directory": "Diretório de Registos de Feedback",
|
||||
"feedback_record_fields": "Campos de registo de feedback",
|
||||
"feedback_record_mcp": "MCP de Registo de Feedback",
|
||||
"feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso",
|
||||
"feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado",
|
||||
"feedback_records": "Registos de feedback",
|
||||
"feedback_records_refreshed": "Registos de feedback atualizados",
|
||||
"feedback_sources": "Fontes de Feedback",
|
||||
"feedback_sources_directory_access_multiple": "Novos registos destas fontes serão armazenados em: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Novos registos desta fonte serão armazenados em: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Liga e gere todas as fontes de feedback para este espaço de trabalho.",
|
||||
"field_group_id": "ID do grupo de campos",
|
||||
"field_group_label": "Etiqueta do grupo de campos",
|
||||
"field_id": "ID do campo",
|
||||
"field_label": "Etiqueta do campo",
|
||||
"field_type": "Tipo de campo",
|
||||
"formbricks_surveys": "Pesquisas Formbricks",
|
||||
"frd_cannot_be_changed": "O diretório de feedback não pode ser alterado após a criação.",
|
||||
"go_to_feedback_record_directories": "Ir para definições de diretórios",
|
||||
"historical_import_complete": "Importação concluída: {successes} com sucesso, {failures} falharam, {skipped} ignorados (sem dados)",
|
||||
"import_csv_data": "Importar feedback",
|
||||
"import_feedback": "Importar feedback",
|
||||
"import_historical_responses": "Importar respostas históricas",
|
||||
"import_historical_responses_description": "Importa agora as respostas existentes deste inquérito.",
|
||||
"import_rows": "Importar {count} linhas",
|
||||
"import_via_source_name": "Importar via \"{sourceName}\"",
|
||||
"importing_data": "A importar dados...",
|
||||
"importing_historical_data": "A importar dados históricos...",
|
||||
"invalid_enum_values": "Valores inválidos na coluna mapeada para {field}",
|
||||
"invalid_values_found": "Encontrados: {values} (linhas: {rows}) {extra}",
|
||||
"load_sample_csv": "Carregar CSV de exemplo",
|
||||
"n_supported_questions": "{count} perguntas suportadas",
|
||||
"manage_directories": "Gerir diretórios",
|
||||
"manage_feedback_sources": "Gerenciar fontes de feedback",
|
||||
"metadata": "Metadados",
|
||||
"metadata_key": "Chave de metadados",
|
||||
"metadata_read_only_entries": "Valores de metadados somente leitura (sem string)",
|
||||
"metadata_value": "Valor dos metadados",
|
||||
"missing_feedback_source_title": "Falta alguma fonte de feedback?",
|
||||
"no_feedback_record_directory_available": "Não há nenhum diretório de registos de feedback atribuído a este espaço de trabalho. Cria ou atribui um primeiro.",
|
||||
"no_feedback_records": "Ainda não há registos de feedback. Os registos aparecerão aqui assim que os teus conectores começarem a enviar dados.",
|
||||
"no_source_fields_loaded": "Ainda não foram carregados campos de origem",
|
||||
"no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.",
|
||||
"no_surveys_found": "Nenhum inquérito encontrado neste ambiente",
|
||||
"optional": "Opcional",
|
||||
"or_drag_and_drop": "ou arraste e largue",
|
||||
"question_selected": "<strong>{count}</strong> pergunta selecionada. Cada resposta a esta pergunta criará um novo registo de feedback.",
|
||||
"question_type_not_supported": "Este tipo de pergunta não é suportado",
|
||||
"questions_selected": "<strong>{count}</strong> perguntas selecionadas. Cada resposta a estas perguntas criará um novo registo de feedback.",
|
||||
"records_will_go_to": "Os registos irão para",
|
||||
"refresh_feedback_records": "Atualizar registos de feedback",
|
||||
"refreshing_feedback_records": "A atualizar registos de feedback...",
|
||||
"request_feedback_source": "Solicitar integração de fonte",
|
||||
"required": "Obrigatório",
|
||||
"save_changes": "Guardar alterações",
|
||||
"select_a_survey_to_see_questions": "Selecione um inquérito para ver as suas perguntas",
|
||||
"select_a_value": "Selecione um valor...",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_feedback_record_directory": "Selecionar um diretório",
|
||||
"select_feedback_record_source_type": "Selecione o tipo de fonte",
|
||||
"select_questions": "Selecionar perguntas",
|
||||
"select_source_type_description": "Selecione o tipo de fonte de feedback que pretende conectar.",
|
||||
"select_source_type_prompt": "Selecione o tipo de fonte de feedback que pretende conectar:",
|
||||
"select_survey": "Selecionar inquérito",
|
||||
"select_survey_and_questions": "Selecionar inquérito e perguntas",
|
||||
"select_survey_questions_description": "Escolha quais perguntas do inquérito devem criar FeedbackRecords.",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "A mostrar 3 de {count} linhas",
|
||||
"source": "fonte",
|
||||
"source_connect_csv_description": "Importar feedback de ficheiros CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Envia registos de feedback através da integração MCP.",
|
||||
"source_connect_formbricks_description": "Conectar feedback dos seus inquéritos Formbricks",
|
||||
"source_fields": "Campos da fonte",
|
||||
"source_id": "ID da fonte",
|
||||
"source_name": "Nome da fonte",
|
||||
"source_type": "Tipo de fonte",
|
||||
"source_type_cannot_be_changed": "O tipo de fonte não pode ser alterado",
|
||||
"sources": "Fontes",
|
||||
"status_active": "Em progresso",
|
||||
"status_completed": "Concluído",
|
||||
"status_draft": "Rascunho",
|
||||
"status_error": "Erro",
|
||||
"status_paused": "Em pausa",
|
||||
"status_live_sync": "Sincronização em direto",
|
||||
"status_ready": "Pronto",
|
||||
"submission_id": "ID de envio",
|
||||
"survey_has_no_questions": "Este inquérito não tem perguntas",
|
||||
"survey_import_line": "{surveyName}: {responseCount} respostas × {questionCount} perguntas = {total} registos de feedback",
|
||||
"total_feedback_records": "Total: {checked} de {total} registos de feedback selecionados em {surveyCount} inquéritos",
|
||||
"topics_and_subtopics": "Tópicos e subtópicos",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Atualiza a configuração de mapeamento para esta origem.",
|
||||
"updated_at": "Atualizado em",
|
||||
"upload_csv_data_description": "Carrega um ficheiro CSV para importar dados de feedback.",
|
||||
"upload_csv_file": "Carregar ficheiro CSV",
|
||||
"user_identifier": "Utilizador",
|
||||
"value": "Valor"
|
||||
"value": "Valor",
|
||||
"value_boolean": "Valor (Booleano)",
|
||||
"value_date": "Valor (Data)",
|
||||
"value_number": "Valor (Número)",
|
||||
"value_text": "Valor (Texto)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "Șterge {deleteWhat}",
|
||||
"description": "Descriere",
|
||||
"disable": "Dezactivează",
|
||||
"disabled": "Dezactivat",
|
||||
"disallow": "Nu permite",
|
||||
"discard": "Renunță",
|
||||
"dismissed": "Respins",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Nu sunteți autentificat pentru a efectua această acțiune.",
|
||||
"not_authorized": "Neautorizat",
|
||||
"not_connected": "Neconectat",
|
||||
"not_set": "Nu setat",
|
||||
"note": "Notă",
|
||||
"notifications": "Notificări",
|
||||
"number": "Număr",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "Unele fișiere nu au reușit să se încarce",
|
||||
"something_went_wrong": "Ceva nu a mers bine",
|
||||
"something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.",
|
||||
"soon": "În curând",
|
||||
"sort_by": "Sortare după",
|
||||
"start_free_trial": "Începe perioada de probă gratuită",
|
||||
"status": "Stare",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "Conectează-ți aplicația sau site-ul la Formbricks.",
|
||||
"cache_update_delay_description": "Când faci actualizări la sondaje, contacte, acțiuni sau alte date, poate dura până la 1 minut până când aceste modificări apar în aplicația ta locală care rulează SDK-ul Formbricks.",
|
||||
"cache_update_delay_title": "Modificările vor fi vizibile după aproximativ 1 minut din cauza cache-ului",
|
||||
"environment_id_legacy": "ID mediu (legacy)",
|
||||
"environment_id_legacy_alert": "Configurația SDK existentă poate folosi încă un ID de mediu legacy.",
|
||||
"environment_id_legacy_alert_link": "Află de ce și cum să migrezi.",
|
||||
"formbricks_sdk_connected": "SDK Formbricks este conectat",
|
||||
"formbricks_sdk_not_connected": "SDK Formbricks nu este încă conectat.",
|
||||
"formbricks_sdk_not_connected_description": "Adaugă SDK-ul Formbricks pe site-ul sau în aplicația ta pentru a-l conecta cu Formbricks",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "Directoare de feedback",
|
||||
"no_access": "Nu ai permisiunea de a gestiona directoarele de înregistrări de feedback.",
|
||||
"no_connectors": "Niciun conector asociat acestui director încă.",
|
||||
"pause_connectors_confirmation_description": "Dacă pui pe pauză acești conectori, nu vor mai fi adăugate înregistrări noi.",
|
||||
"pause_connectors_confirmation_title": "Pauzezi conectorii asociați?",
|
||||
"select_workspaces_placeholder": "Selectează spații de lucru...",
|
||||
"show_archived": "Afișează arhivate",
|
||||
"title": "Directoare de Înregistrări Feedback",
|
||||
"unarchive": "Dezarhivează"
|
||||
"unarchive": "Dezarhivează",
|
||||
"unarchive_workspace_conflict": "Acest director nu poate fi dezarhivat deoarece unul sau mai multe spații de lucru alocate sunt arhivate.",
|
||||
"workspace_access": "Acces la spațiul de lucru"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Îmbogățire și analiză de date (AI)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "Vedeți ce echipe pot accesa acest spațiu de lucru."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Adăugați înregistrarea de feedback",
|
||||
"add_feedback_record_description": "Creați manual o înregistrare de feedback.",
|
||||
"add_feedback_source": "Adaugă sursă de feedback",
|
||||
"add_source": "Adaugă sursă",
|
||||
"allowed_values": "Valori permise: {values}",
|
||||
"api_ingestion": "Ingestie API",
|
||||
"api_ingestion_manage_api_keys": "Gestionează cheile API",
|
||||
"api_ingestion_settings_description": "Trimite înregistrări de feedback folosind API-ul de management.",
|
||||
"auto_generated": "Generat automat",
|
||||
"change_file": "Schimbă fișierul",
|
||||
"click_load_sample_csv": "Apasă pe „Încarcă CSV de exemplu” pentru a vedea coloanele",
|
||||
"click_to_upload": "Apasă pentru a încărca",
|
||||
"collected_at": "Colectat la",
|
||||
"configure_import": "Configurează importul",
|
||||
"configure_mapping": "Configurează maparea",
|
||||
"connection": "Conexiune",
|
||||
"connector_created_successfully": "Conector creat cu succes",
|
||||
"connector_deleted_successfully": "Conector șters cu succes",
|
||||
"connector_duplicated_successfully": "Conector duplicat cu succes",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "Importarea datelor de două ori va crea înregistrări duplicate.",
|
||||
"csv_inconsistent_columns": "Rândul {row} are coloane inconsistente. Toate rândurile trebuie să aibă aceleași antete.",
|
||||
"csv_max_records": "Sunt permise maximum {max} înregistrări.",
|
||||
"custom_source_type": "Tip sursă personalizat",
|
||||
"custom_source_type_placeholder": "Introduceți tipul de sursă personalizat",
|
||||
"default_connector_name_csv": "Import CSV",
|
||||
"default_connector_name_formbricks": "Conexiune chestionar Formbricks",
|
||||
"deselect_all": "Deselectează tot",
|
||||
"discard_feedback_record_changes_description": "Modificările dvs. se vor pierde dacă închideți acest sertar.",
|
||||
"discard_feedback_record_changes_title": "Renunțați la modificările nesalvate?",
|
||||
"drop_a_field_here": "Trage un câmp aici",
|
||||
"drop_field_or": "Trage câmpul sau",
|
||||
"edit_csv_mapping": "Editează maparea CSV",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback",
|
||||
"feedback_date": "Data curentă",
|
||||
"feedback_record_created_successfully": "Înregistrare de feedback creată cu succes",
|
||||
"feedback_record_details": "Detaliile înregistrării feedback-ului",
|
||||
"feedback_record_details_description": "Examinați și actualizați câmpurile pentru înregistrarea de feedback.",
|
||||
"feedback_record_directory": "Director de înregistrări feedback",
|
||||
"feedback_record_fields": "Câmpuri înregistrare feedback",
|
||||
"feedback_record_mcp": "MCP Înregistrări Feedback",
|
||||
"feedback_record_updated_successfully": "Înregistrarea feedback-ului a fost actualizată cu succes",
|
||||
"feedback_record_value_required": "Este necesară o valoare pentru tipul de câmp selectat",
|
||||
"feedback_records": "Înregistrări de feedback",
|
||||
"feedback_records_refreshed": "Înregistrările de feedback au fost actualizate",
|
||||
"feedback_sources": "Surse de feedback",
|
||||
"feedback_sources_directory_access_multiple": "Înregistrările noi din aceste surse vor fi stocate în: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Înregistrările noi din această sursă vor fi stocate în: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Conectează și gestionează toate sursele de feedback pentru acest spațiu de lucru.",
|
||||
"field_group_id": "ID grup de câmpuri",
|
||||
"field_group_label": "Eticheta grupului de câmpuri",
|
||||
"field_id": "ID-ul câmpului",
|
||||
"field_label": "Etichetă câmp",
|
||||
"field_type": "Tip câmp",
|
||||
"formbricks_surveys": "Chestionare Formbricks",
|
||||
"frd_cannot_be_changed": "Directorul de feedback nu poate fi modificat după creare.",
|
||||
"go_to_feedback_record_directories": "Mergi la setările directoarelor",
|
||||
"historical_import_complete": "Import finalizat: {successes} reușite, {failures} eșuate, {skipped} omise (fără date)",
|
||||
"import_csv_data": "Importă feedback",
|
||||
"import_feedback": "Importă feedback",
|
||||
"import_historical_responses": "Importă răspunsuri istorice",
|
||||
"import_historical_responses_description": "Importă acum răspunsurile existente din acest sondaj.",
|
||||
"import_rows": "Importă {count, plural, one {# rând} few {# rânduri} other {# de rânduri}}",
|
||||
"import_via_source_name": "Import prin „{sourceName}”",
|
||||
"importing_data": "Se importă datele...",
|
||||
"importing_historical_data": "Se importă datele istorice...",
|
||||
"invalid_enum_values": "Valori invalide în coloana mapată la {field}",
|
||||
"invalid_values_found": "Găsite: {values} (rânduri: {rows}) {extra}",
|
||||
"load_sample_csv": "Încarcă un CSV de exemplu",
|
||||
"n_supported_questions": "{count} întrebări acceptate",
|
||||
"manage_directories": "Gestionează directoarele",
|
||||
"manage_feedback_sources": "Gestionați sursele de feedback",
|
||||
"metadata": "Metadate",
|
||||
"metadata_key": "Cheia de metadate",
|
||||
"metadata_read_only_entries": "Valori de metadate numai pentru citire (fără șir)",
|
||||
"metadata_value": "Valoarea metadatelor",
|
||||
"missing_feedback_source_title": "Lipsește o sursă de feedback?",
|
||||
"no_feedback_record_directory_available": "Niciun director de înregistrări feedback atribuit acestui spațiu de lucru. Creează sau atribuie unul mai întâi.",
|
||||
"no_feedback_records": "Nu există încă înregistrări de feedback. Înregistrările vor apărea aici după ce conectorii tăi vor începe să trimită date.",
|
||||
"no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă",
|
||||
"no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.",
|
||||
"no_surveys_found": "Nu s-au găsit sondaje în acest mediu",
|
||||
"optional": "Opțional",
|
||||
"or_drag_and_drop": "sau trage și lasă aici",
|
||||
"question_selected": "<strong>{count}</strong> întrebare selectată. Fiecare răspuns la aceste întrebări va crea un nou Feedback Record.",
|
||||
"question_type_not_supported": "Acest tip de întrebare nu este suportat",
|
||||
"questions_selected": "<strong>{count}</strong> întrebări selectate. Fiecare răspuns la aceste întrebări va crea un nou Feedback Record.",
|
||||
"records_will_go_to": "Înregistrările vor ajunge în",
|
||||
"refresh_feedback_records": "Reîmprospătează înregistrările de feedback",
|
||||
"refreshing_feedback_records": "Se actualizează înregistrările de feedback...",
|
||||
"request_feedback_source": "Solicită integrarea sursei",
|
||||
"required": "Obligatoriu",
|
||||
"save_changes": "Salvează modificările",
|
||||
"select_a_survey_to_see_questions": "Selectează un chestionar pentru a vedea întrebările",
|
||||
"select_a_value": "Selectează o valoare...",
|
||||
"select_all": "Selectează tot",
|
||||
"select_feedback_record_directory": "Selectează un director",
|
||||
"select_feedback_record_source_type": "Selectați tipul sursei",
|
||||
"select_questions": "Selectează întrebări",
|
||||
"select_source_type_description": "Selectează tipul sursei de feedback pe care vrei să o conectezi.",
|
||||
"select_source_type_prompt": "Selectează tipul sursei de feedback pe care vrei să o conectezi:",
|
||||
"select_survey": "Selectează chestionar",
|
||||
"select_survey_and_questions": "Selectează chestionar și întrebări",
|
||||
"select_survey_questions_description": "Alege ce întrebări din chestionar vor crea FeedbackRecords.",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "Se afișează 3 din {count} rânduri",
|
||||
"source": "sursă",
|
||||
"source_connect_csv_description": "Importă feedback din fișiere CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Trimite înregistrări de feedback prin integrarea MCP.",
|
||||
"source_connect_formbricks_description": "Conectează feedback din sondajele Formbricks",
|
||||
"source_fields": "Câmpuri sursă",
|
||||
"source_id": "ID sursă",
|
||||
"source_name": "Nume sursă",
|
||||
"source_type": "Tip sursă",
|
||||
"source_type_cannot_be_changed": "Tipul sursei nu poate fi schimbat",
|
||||
"sources": "Surse",
|
||||
"status_active": "În progres",
|
||||
"status_completed": "Finalizat",
|
||||
"status_draft": "Schiță",
|
||||
"status_error": "Eroare",
|
||||
"status_paused": "Pauzat",
|
||||
"status_live_sync": "Sincronizare în timp real",
|
||||
"status_ready": "Gata",
|
||||
"submission_id": "ID-ul trimiterii",
|
||||
"survey_has_no_questions": "Acest sondaj nu are întrebări",
|
||||
"survey_import_line": "{surveyName}: {responseCount} răspunsuri × {questionCount} întrebări = {total} Feedback Records",
|
||||
"total_feedback_records": "Total: {checked} din {total} Feedback Records selectate în {surveyCount} sondaje",
|
||||
"topics_and_subtopics": "Subiecte și subiecte secundare",
|
||||
"unify_feedback": "Unify Feedback",
|
||||
"update_mapping_description": "Actualizează configurația de mapare pentru această sursă.",
|
||||
"updated_at": "Actualizat la",
|
||||
"upload_csv_data_description": "Încarcă un fișier CSV pentru a importa date de feedback.",
|
||||
"upload_csv_file": "Încarcă fișier CSV",
|
||||
"user_identifier": "Utilizator",
|
||||
"value": "Valoare"
|
||||
"value": "Valoare",
|
||||
"value_boolean": "Valoare (booleană)",
|
||||
"value_date": "Valoare (data)",
|
||||
"value_number": "Valoare (număr)",
|
||||
"value_text": "Valoare (Text)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "Удалить {deleteWhat}",
|
||||
"description": "Описание",
|
||||
"disable": "Отключить",
|
||||
"disabled": "Отключено",
|
||||
"disallow": "Не разрешать",
|
||||
"discard": "Отменить",
|
||||
"dismissed": "Отклонено",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "У вас нет прав для выполнения этого действия.",
|
||||
"not_authorized": "Нет доступа",
|
||||
"not_connected": "Нет подключения",
|
||||
"not_set": "Не установлено",
|
||||
"note": "Примечание",
|
||||
"notifications": "Уведомления",
|
||||
"number": "Номер",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "Не удалось загрузить некоторые файлы",
|
||||
"something_went_wrong": "Что-то пошло не так",
|
||||
"something_went_wrong_please_try_again": "Что-то пошло не так. Пожалуйста, попробуйте ещё раз.",
|
||||
"soon": "Скоро",
|
||||
"sort_by": "Сортировать по",
|
||||
"start_free_trial": "Начать бесплатный пробный период",
|
||||
"status": "Статус",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "Подключите ваше приложение или сайт к Formbricks.",
|
||||
"cache_update_delay_description": "Когда вы обновляете опросы, контакты, действия или другие данные, изменения могут появиться в вашем локальном приложении с Formbricks SDK с задержкой до 1 минуты.",
|
||||
"cache_update_delay_title": "Изменения отразятся примерно через 1 минуту из-за кэширования",
|
||||
"environment_id_legacy": "ID среды (устаревший)",
|
||||
"environment_id_legacy_alert": "Твоя текущая настройка SDK может всё ещё использовать устаревший ID среды.",
|
||||
"environment_id_legacy_alert_link": "Узнай, почему и как выполнить миграцию.",
|
||||
"formbricks_sdk_connected": "Formbricks SDK подключён",
|
||||
"formbricks_sdk_not_connected": "Formbricks SDK ещё не подключён.",
|
||||
"formbricks_sdk_not_connected_description": "Добавьте SDK Formbricks на ваш сайт или в приложение, чтобы подключить его к Formbricks",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "Каталоги отзывов",
|
||||
"no_access": "У тебя нет прав для управления каталогами записей отзывов.",
|
||||
"no_connectors": "К этому каталогу пока не привязано ни одного коннектора.",
|
||||
"pause_connectors_confirmation_description": "Если приостановить эти коннекторы, новые записи больше не будут добавляться.",
|
||||
"pause_connectors_confirmation_title": "Приостановить связанные коннекторы?",
|
||||
"select_workspaces_placeholder": "Выберите рабочие области...",
|
||||
"show_archived": "Показать архивные",
|
||||
"title": "Директории записей обратной связи",
|
||||
"unarchive": "Разархивировать"
|
||||
"unarchive": "Разархивировать",
|
||||
"unarchive_workspace_conflict": "Невозможно разархивировать этот каталог, потому что один или несколько назначенных рабочих пространств архивированы.",
|
||||
"workspace_access": "Доступ к рабочему пространству"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "Посмотрите, какие команды имеют доступ к этому рабочему пространству."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Добавить запись отзыва",
|
||||
"add_feedback_record_description": "Создайте запись обратной связи вручную.",
|
||||
"add_feedback_source": "Добавить источник отзывов",
|
||||
"add_source": "Добавить источник",
|
||||
"allowed_values": "Допустимые значения: {values}",
|
||||
"api_ingestion": "Импорт через API",
|
||||
"api_ingestion_manage_api_keys": "Управление API-ключами",
|
||||
"api_ingestion_settings_description": "Отправляйте записи обратной связи через Management API.",
|
||||
"auto_generated": "Автоматически генерируется",
|
||||
"change_file": "Изменить файл",
|
||||
"click_load_sample_csv": "Нажмите «Загрузить пример CSV», чтобы увидеть столбцы",
|
||||
"click_to_upload": "Кликните для загрузки",
|
||||
"collected_at": "Собрано",
|
||||
"configure_import": "Настроить импорт",
|
||||
"configure_mapping": "Настроить сопоставление",
|
||||
"connection": "Подключение",
|
||||
"connector_created_successfully": "Коннектор успешно создан",
|
||||
"connector_deleted_successfully": "Коннектор успешно удалён",
|
||||
"connector_duplicated_successfully": "Коннектор успешно дублирован",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "Импорт уже загруженных данных может создать дубликаты записей.",
|
||||
"csv_inconsistent_columns": "В строке {row} несоответствие столбцов. Во всех строках должны быть одинаковые заголовки.",
|
||||
"csv_max_records": "Допустимо не более {max} записей.",
|
||||
"custom_source_type": "Пользовательский тип источника",
|
||||
"custom_source_type_placeholder": "Введите собственный тип источника",
|
||||
"default_connector_name_csv": "Импорт CSV",
|
||||
"default_connector_name_formbricks": "Подключение опроса Formbricks",
|
||||
"deselect_all": "Снять выделение со всех",
|
||||
"discard_feedback_record_changes_description": "Ваши изменения будут потеряны, если вы закроете этот ящик.",
|
||||
"discard_feedback_record_changes_title": "Отменить несохраненные изменения?",
|
||||
"drop_a_field_here": "Перетащи сюда поле",
|
||||
"drop_field_or": "Перетащи поле или",
|
||||
"edit_csv_mapping": "Редактировать сопоставление CSV",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Не удалось загрузить отзывы",
|
||||
"feedback_date": "Текущая дата",
|
||||
"feedback_record_created_successfully": "Запись отзыва успешно создана",
|
||||
"feedback_record_details": "Детали записи обратной связи",
|
||||
"feedback_record_details_description": "Просмотрите и обновите поля записи отзыва.",
|
||||
"feedback_record_directory": "Каталог записей обратной связи",
|
||||
"feedback_record_fields": "Поля записи отзыва",
|
||||
"feedback_record_mcp": "MCP для записей обратной связи",
|
||||
"feedback_record_updated_successfully": "Запись отзыва успешно обновлена.",
|
||||
"feedback_record_value_required": "Требуется значение для выбранного типа поля.",
|
||||
"feedback_records": "Записи отзывов",
|
||||
"feedback_records_refreshed": "Записи отзывов обновлены",
|
||||
"feedback_sources": "Источники обратной связи",
|
||||
"feedback_sources_directory_access_multiple": "Новые записи из этих источников будут сохранены в: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Новые записи из этого источника будут сохранены в: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Подключайте источники обратной связи и управляйте ими для этого рабочего пространства.",
|
||||
"field_group_id": "Идентификатор группы полей",
|
||||
"field_group_label": "Метка группы полей",
|
||||
"field_id": "Идентификатор поля",
|
||||
"field_label": "Метка поля",
|
||||
"field_type": "Тип поля",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "Каталог обратной связи нельзя изменить после создания.",
|
||||
"go_to_feedback_record_directories": "Перейти к настройкам каталогов",
|
||||
"historical_import_complete": "Импорт завершён: {successes} успешно, {failures} с ошибками, {skipped} пропущено (нет данных)",
|
||||
"import_csv_data": "Импортировать отзывы",
|
||||
"import_feedback": "Импортировать отзывы",
|
||||
"import_historical_responses": "Импортировать предыдущие ответы",
|
||||
"import_historical_responses_description": "Импортируйте существующие ответы из этого опроса прямо сейчас.",
|
||||
"import_rows": "Импортировать {count, plural, one {# строку} few {# строки} many {# строк} other {# строки}}",
|
||||
"import_via_source_name": "Импорт через «{sourceName}»",
|
||||
"importing_data": "Импорт данных...",
|
||||
"importing_historical_data": "Импорт исторических данных...",
|
||||
"invalid_enum_values": "Недопустимые значения в столбце, сопоставленном с {field}",
|
||||
"invalid_values_found": "Найдено: {values} (строки: {rows}) {extra}",
|
||||
"load_sample_csv": "Загрузить пример CSV",
|
||||
"n_supported_questions": "Поддерживается {count} вопрос(ов)",
|
||||
"manage_directories": "Управление директориями",
|
||||
"manage_feedback_sources": "Управление источниками обратной связи",
|
||||
"metadata": "Метаданные",
|
||||
"metadata_key": "Ключ метаданных",
|
||||
"metadata_read_only_entries": "Значения метаданных только для чтения (нестроковые)",
|
||||
"metadata_value": "Значение метаданных",
|
||||
"missing_feedback_source_title": "Не нашли нужный источник обратной связи?",
|
||||
"no_feedback_record_directory_available": "К этому рабочему пространству не назначен каталог записей обратной связи. Сначала создайте или назначьте каталог.",
|
||||
"no_feedback_records": "Пока нет записей отзывов. Они появятся здесь, когда коннекторы начнут отправлять данные.",
|
||||
"no_source_fields_loaded": "Поля источника ещё не загружены",
|
||||
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
|
||||
"no_surveys_found": "В этой среде не найдено опросов",
|
||||
"optional": "Необязательно",
|
||||
"or_drag_and_drop": "или перетащите файл",
|
||||
"question_selected": "<strong>{count}</strong> выбранный вопрос. Каждый ответ на эти вопросы создаст новую запись обратной связи.",
|
||||
"question_type_not_supported": "Этот тип вопроса не поддерживается",
|
||||
"questions_selected": "<strong>{count}</strong> выбранных вопроса. Каждый ответ на эти вопросы создаст новую запись обратной связи.",
|
||||
"records_will_go_to": "Записи будут отправлены в",
|
||||
"refresh_feedback_records": "Обновить записи отзывов",
|
||||
"refreshing_feedback_records": "Обновляем записи отзывов...",
|
||||
"request_feedback_source": "Запросить интеграцию источника",
|
||||
"required": "Обязательно",
|
||||
"save_changes": "Сохранить изменения",
|
||||
"select_a_survey_to_see_questions": "Выберите опрос, чтобы увидеть его вопросы",
|
||||
"select_a_value": "Выберите значение...",
|
||||
"select_all": "Выбрать все",
|
||||
"select_feedback_record_directory": "Выберите каталог",
|
||||
"select_feedback_record_source_type": "Выберите тип источника",
|
||||
"select_questions": "Выберите вопросы",
|
||||
"select_source_type_description": "Выберите тип источника отзывов, который хотите подключить.",
|
||||
"select_source_type_prompt": "Выберите тип источника отзывов, который хотите подключить:",
|
||||
"select_survey": "Выбрать опрос",
|
||||
"select_survey_and_questions": "Выбрать опрос и вопросы",
|
||||
"select_survey_questions_description": "Выберите, какие вопросы опроса должны создавать FeedbackRecords.",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "Показано 3 из {count} строк",
|
||||
"source": "источник",
|
||||
"source_connect_csv_description": "Импортировать отзывы из CSV-файлов",
|
||||
"source_connect_feedback_record_mcp_description": "Отправляйте записи обратной связи через интеграцию MCP.",
|
||||
"source_connect_formbricks_description": "Подключить отзывы из ваших опросов Formbricks",
|
||||
"source_fields": "Поля источника",
|
||||
"source_id": "Идентификатор источника",
|
||||
"source_name": "Имя источника",
|
||||
"source_type": "Тип источника",
|
||||
"source_type_cannot_be_changed": "Тип источника нельзя изменить",
|
||||
"sources": "Источники",
|
||||
"status_active": "В процессе",
|
||||
"status_completed": "Завершён",
|
||||
"status_draft": "Черновик",
|
||||
"status_error": "Ошибка",
|
||||
"status_paused": "Приостановлен",
|
||||
"status_live_sync": "Синхронизация в реальном времени",
|
||||
"status_ready": "Готово",
|
||||
"submission_id": "Идентификатор отправки",
|
||||
"survey_has_no_questions": "В этом опросе нет вопросов",
|
||||
"survey_import_line": "{surveyName}: {responseCount} ответов × {questionCount} вопросов = {total} записей обратной связи",
|
||||
"total_feedback_records": "Всего: выбрано {checked} из {total} записей обратной связи в {surveyCount} опросах",
|
||||
"topics_and_subtopics": "Темы и подтемы",
|
||||
"unify_feedback": "Обратная связь Unify",
|
||||
"update_mapping_description": "Обнови настройки сопоставления для этого источника.",
|
||||
"updated_at": "Обновлено",
|
||||
"upload_csv_data_description": "Загрузи CSV-файл, чтобы импортировать данные отзывов.",
|
||||
"upload_csv_file": "Загрузить CSV-файл",
|
||||
"user_identifier": "Пользователь",
|
||||
"value": "Значение"
|
||||
"value": "Значение",
|
||||
"value_boolean": "Значение (логическое)",
|
||||
"value_date": "Значение (Дата)",
|
||||
"value_number": "Значение (число)",
|
||||
"value_text": "Значение (текст)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "Ta bort {deleteWhat}",
|
||||
"description": "Beskrivning",
|
||||
"disable": "Inaktivera",
|
||||
"disabled": "Inaktiverad",
|
||||
"disallow": "Tillåt inte",
|
||||
"discard": "Förkasta",
|
||||
"dismissed": "Avvisad",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Du är inte autentiserad för att utföra denna åtgärd.",
|
||||
"not_authorized": "Ej behörig",
|
||||
"not_connected": "Ej ansluten",
|
||||
"not_set": "Inte inställt",
|
||||
"note": "Anteckning",
|
||||
"notifications": "Aviseringar",
|
||||
"number": "Nummer",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "Några filer misslyckades att laddas upp",
|
||||
"something_went_wrong": "Något gick fel",
|
||||
"something_went_wrong_please_try_again": "Något gick fel. Försök igen.",
|
||||
"soon": "Snart",
|
||||
"sort_by": "Sortera efter",
|
||||
"start_free_trial": "Starta gratis provperiod",
|
||||
"status": "Status",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "Anslut din app eller webbplats till Formbricks.",
|
||||
"cache_update_delay_description": "När du gör uppdateringar av undersökningar, kontakter, åtgärder eller annan data kan det ta upp till 1 minut innan ändringarna syns i din lokala app som kör Formbricks SDK.",
|
||||
"cache_update_delay_title": "Ändringar syns efter cirka 1 minut på grund av cachelagring",
|
||||
"environment_id_legacy": "Miljö-ID (äldre version)",
|
||||
"environment_id_legacy_alert": "Din befintliga SDK-konfiguration kan fortfarande använda ett äldre miljö-ID.",
|
||||
"environment_id_legacy_alert_link": "Läs mer om varför och hur du migrerar.",
|
||||
"formbricks_sdk_connected": "Formbricks SDK är anslutet",
|
||||
"formbricks_sdk_not_connected": "Formbricks SDK är ännu inte anslutet.",
|
||||
"formbricks_sdk_not_connected_description": "Lägg till Formbricks SDK på din webbplats eller i din app för att ansluta den till Formbricks",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "Feedbackkataloger",
|
||||
"no_access": "Du har inte behörighet att hantera kataloger för feedbackposter.",
|
||||
"no_connectors": "Inga kopplingar länkade till den här katalogen ännu.",
|
||||
"pause_connectors_confirmation_description": "Om du pausar dessa kopplingar kommer inga nya poster att läggas till.",
|
||||
"pause_connectors_confirmation_title": "Pausa länkade kopplingar?",
|
||||
"select_workspaces_placeholder": "Välj arbetsytor...",
|
||||
"show_archived": "Visa arkiverade",
|
||||
"title": "Feedbackkataloger",
|
||||
"unarchive": "Avarkivera"
|
||||
"unarchive": "Avarkivera",
|
||||
"unarchive_workspace_conflict": "Den här katalogen kan inte avarkiveras eftersom en eller flera tilldelade arbetsytor är arkiverade.",
|
||||
"workspace_access": "Arbetsyteåtkomst"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Dataförbättring & analys (AI)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "Se vilka team som har tillgång till denna arbetsyta."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Lägg till feedbackpost",
|
||||
"add_feedback_record_description": "Skapa en feedbackpost manuellt.",
|
||||
"add_feedback_source": "Lägg till feedbackkälla",
|
||||
"add_source": "Lägg till källa",
|
||||
"allowed_values": "Tillåtna värden: {values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_manage_api_keys": "Manage API keys",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "Automatiskt genererad",
|
||||
"change_file": "Byt fil",
|
||||
"click_load_sample_csv": "Klicka på 'Ladda exempel-CSV' för att se kolumner",
|
||||
"click_to_upload": "Klicka för att ladda upp",
|
||||
"collected_at": "Insamlad",
|
||||
"configure_import": "Konfigurera import",
|
||||
"configure_mapping": "Konfigurera mappning",
|
||||
"connection": "Anslutning",
|
||||
"connector_created_successfully": "Kopplingen skapades",
|
||||
"connector_deleted_successfully": "Kopplingen togs bort",
|
||||
"connector_duplicated_successfully": "Kopplingen har duplicerats",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "Om du importerar data två gånger kommer det att skapa dubbletter.",
|
||||
"csv_inconsistent_columns": "Rad {row} har inkonsekventa kolumner. Alla rader måste ha samma rubriker.",
|
||||
"csv_max_records": "Maximalt {max} poster tillåtna.",
|
||||
"custom_source_type": "Anpassad källtyp",
|
||||
"custom_source_type_placeholder": "Ange anpassad källtyp",
|
||||
"default_connector_name_csv": "CSV-import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey-anslutning",
|
||||
"deselect_all": "Avmarkera alla",
|
||||
"discard_feedback_record_changes_description": "Dina ändringar kommer att gå förlorade om du stänger den här lådan.",
|
||||
"discard_feedback_record_changes_title": "Vill du ignorera osparade ändringar?",
|
||||
"drop_a_field_here": "Släpp ett fält här",
|
||||
"drop_field_or": "Släpp fält eller",
|
||||
"edit_csv_mapping": "Redigera CSV-mappning",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter",
|
||||
"feedback_date": "Aktuellt datum",
|
||||
"feedback_record_created_successfully": "Feedbackposten har skapats",
|
||||
"feedback_record_details": "Feedbackpostdetaljer",
|
||||
"feedback_record_details_description": "Granska och uppdatera fält för feedbackposter.",
|
||||
"feedback_record_directory": "Katalog för feedbackposter",
|
||||
"feedback_record_fields": "Fält för feedbackpost",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"feedback_record_updated_successfully": "Feedbackposten har uppdaterats",
|
||||
"feedback_record_value_required": "Ett värde krävs för den valda fälttypen",
|
||||
"feedback_records": "Feedbackposter",
|
||||
"feedback_records_refreshed": "Feedbackposter har uppdaterats",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
|
||||
"field_group_id": "Fältgrupp-ID",
|
||||
"field_group_label": "Fältgruppsetikett",
|
||||
"field_id": "Fält-ID",
|
||||
"field_label": "Fältetikett",
|
||||
"field_type": "Fälttyp",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "Feedbackkatalog kan inte ändras efter att den skapats.",
|
||||
"go_to_feedback_record_directories": "Gå till kataloginställningar",
|
||||
"historical_import_complete": "Importen klar: {successes} lyckades, {failures} misslyckades, {skipped} hoppades över (ingen data)",
|
||||
"import_csv_data": "Importera feedback",
|
||||
"import_feedback": "Importera feedback",
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "Importera {count} rader",
|
||||
"import_via_source_name": "Importera via \"{sourceName}\"",
|
||||
"importing_data": "Importerar data...",
|
||||
"importing_historical_data": "Importerar historisk data...",
|
||||
"invalid_enum_values": "Ogiltiga värden i kolumnen som är kopplad till {field}",
|
||||
"invalid_values_found": "Hittade: {values} (rader: {rows}) {extra}",
|
||||
"load_sample_csv": "Ladda exempel-CSV",
|
||||
"n_supported_questions": "{count} stödda frågor",
|
||||
"manage_directories": "Manage directories",
|
||||
"manage_feedback_sources": "Hantera feedbackkällor",
|
||||
"metadata": "Metadata",
|
||||
"metadata_key": "Metadatanyckel",
|
||||
"metadata_read_only_entries": "Skrivskyddade metadatavärden (icke-sträng)",
|
||||
"metadata_value": "Metadatavärde",
|
||||
"missing_feedback_source_title": "Missing feedback source?",
|
||||
"no_feedback_record_directory_available": "Ingen katalog för feedbackposter tilldelad till den här arbetsytan. Skapa eller tilldela en först.",
|
||||
"no_feedback_records": "Inga feedbackposter ännu. Poster visas här när dina connectors börjar skicka data.",
|
||||
"no_source_fields_loaded": "Inga källfält har laddats än",
|
||||
"no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.",
|
||||
"no_surveys_found": "Inga enkäter hittades i denna miljö",
|
||||
"optional": "Valfritt",
|
||||
"or_drag_and_drop": "eller dra och släpp",
|
||||
"question_selected": "<strong>{count}</strong> fråga vald. Varje svar på dessa frågor skapar en ny feedbackpost.",
|
||||
"question_type_not_supported": "Den här frågetypen stöds inte",
|
||||
"questions_selected": "<strong>{count}</strong> frågor valda. Varje svar på dessa frågor skapar en ny feedbackpost.",
|
||||
"records_will_go_to": "Poster kommer att hamna i",
|
||||
"refresh_feedback_records": "Uppdatera feedbackposter",
|
||||
"refreshing_feedback_records": "Uppdaterar feedbackposter...",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "Obligatoriskt",
|
||||
"save_changes": "Spara ändringar",
|
||||
"select_a_survey_to_see_questions": "Välj en enkät för att se dess frågor",
|
||||
"select_a_value": "Välj ett värde...",
|
||||
"select_all": "Välj alla",
|
||||
"select_feedback_record_directory": "Välj en katalog",
|
||||
"select_feedback_record_source_type": "Välj källtyp",
|
||||
"select_questions": "Välj frågor",
|
||||
"select_source_type_description": "Välj vilken typ av feedbackkälla du vill ansluta.",
|
||||
"select_source_type_prompt": "Välj vilken typ av feedbackkälla du vill ansluta:",
|
||||
"select_survey": "Välj enkät",
|
||||
"select_survey_and_questions": "Välj enkät & frågor",
|
||||
"select_survey_questions_description": "Välj vilka enkätfrågor som ska skapa FeedbackRecords.",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "Visar 3 av {count} rader",
|
||||
"source": "källa",
|
||||
"source_connect_csv_description": "Importera feedback från CSV-filer",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "Anslut feedback från dina Formbricks-enkäter",
|
||||
"source_fields": "Källfält",
|
||||
"source_id": "Käll-ID",
|
||||
"source_name": "Källnamn",
|
||||
"source_type": "Källtyp",
|
||||
"source_type_cannot_be_changed": "Källtyp kan inte ändras",
|
||||
"sources": "Källor",
|
||||
"status_active": "Pågående",
|
||||
"status_completed": "Slutförd",
|
||||
"status_draft": "Utkast",
|
||||
"status_error": "Fel",
|
||||
"status_paused": "Pausad",
|
||||
"status_live_sync": "Live sync",
|
||||
"status_ready": "Ready",
|
||||
"submission_id": "Inlämnings-ID",
|
||||
"survey_has_no_questions": "Den här enkäten har inga frågor",
|
||||
"survey_import_line": "{surveyName}: {responseCount} svar × {questionCount} frågor = {total} feedbackposter",
|
||||
"total_feedback_records": "Totalt: {checked} av {total} feedbackposter valda i {surveyCount} enkäter",
|
||||
"topics_and_subtopics": "Ämnen och delämnen",
|
||||
"unify_feedback": "Samla feedback",
|
||||
"update_mapping_description": "Uppdatera mappningskonfigurationen för den här källan.",
|
||||
"updated_at": "Uppdaterad",
|
||||
"upload_csv_data_description": "Ladda upp en CSV-fil för att importera feedbackdata.",
|
||||
"upload_csv_file": "Ladda upp CSV-fil",
|
||||
"user_identifier": "Användare",
|
||||
"value": "Värde"
|
||||
"value": "Värde",
|
||||
"value_boolean": "Värde (booleskt)",
|
||||
"value_date": "Värde (datum)",
|
||||
"value_number": "Värde (antal)",
|
||||
"value_text": "Värde (text)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+57
-19
@@ -212,6 +212,7 @@
|
||||
"delete_what": "{deleteWhat} sil",
|
||||
"description": "Açıklama",
|
||||
"disable": "Devre dışı bırak",
|
||||
"disabled": "Devre Dışı",
|
||||
"disallow": "İzin verme",
|
||||
"discard": "İptal et",
|
||||
"dismissed": "Reddedildi",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Bu işlemi gerçekleştirmek için yetkiniz yok.",
|
||||
"not_authorized": "Yetkisiz",
|
||||
"not_connected": "Bağlı Değil",
|
||||
"not_set": "Ayarlanmadı",
|
||||
"note": "Not",
|
||||
"notifications": "Bildirimler",
|
||||
"number": "Sayı",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "Bazı dosyalar yüklenemedi",
|
||||
"something_went_wrong": "Bir şeyler ters gitti",
|
||||
"something_went_wrong_please_try_again": "Bir sorun oluştu. Lütfen tekrar deneyin.",
|
||||
"soon": "Yakında",
|
||||
"sort_by": "Sıralama",
|
||||
"start_free_trial": "Ücretsiz denemeyi başlat",
|
||||
"status": "Durum",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "Uygulamanı veya web siteni Formbricks'e bağla.",
|
||||
"cache_update_delay_description": "Anketler, kişiler, eylemler veya diğer verilerde güncelleme yaptığında, bu değişikliklerin Formbricks SDK'sını çalıştıran yerel uygulamanda görünmesi 1 dakikaya kadar sürebilir.",
|
||||
"cache_update_delay_title": "Önbelleğe alma nedeniyle değişiklikler yaklaşık 1 dakika sonra yansıtılacak",
|
||||
"environment_id_legacy": "Ortam Kimliği (eski)",
|
||||
"environment_id_legacy_alert": "Mevcut SDK kurulumunuz hala eski bir Ortam Kimliği kullanıyor olabilir.",
|
||||
"environment_id_legacy_alert_link": "Neden ve nasıl geçiş yapacağınızı öğrenin.",
|
||||
"formbricks_sdk_connected": "Formbricks SDK bağlandı",
|
||||
"formbricks_sdk_not_connected": "Formbricks SDK henüz bağlanmadı.",
|
||||
"formbricks_sdk_not_connected_description": "Web sitenize veya uygulamanıza Formbricks SDK'sını ekleyerek Formbricks ile bağlantı kurun",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "Geri Bildirim Dizinleri",
|
||||
"no_access": "Geri bildirim kayıt dizinlerini yönetme izniniz yok.",
|
||||
"no_connectors": "Bu dizine henüz bağlı bağlayıcı yok.",
|
||||
"pause_connectors_confirmation_description": "Bu bağlayıcıları duraklatırsanız yeni kayıtlar eklenmez.",
|
||||
"pause_connectors_confirmation_title": "Bağlı bağlayıcılar duraklatılsın mı?",
|
||||
"select_workspaces_placeholder": "Çalışma alanlarını seç...",
|
||||
"show_archived": "Arşivlenmişleri göster",
|
||||
"title": "Geri Bildirim Kayıt Dizinleri",
|
||||
"unarchive": "Arşivden çıkar"
|
||||
"unarchive": "Arşivden çıkar",
|
||||
"unarchive_workspace_conflict": "Atanmış çalışma alanlarından biri veya daha fazlası arşivlendiği için bu dizin arşivden çıkarılamaz.",
|
||||
"workspace_access": "Çalışma alanı erişimi"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Veri zenginleştirme ve analiz (Yapay Zeka)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "Bu çalışma alanına hangi takımların erişebildiğini görün."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Geri bildirim kaydı ekle",
|
||||
"add_feedback_record_description": "Manuel olarak bir geri bildirim kaydı oluşturun.",
|
||||
"add_feedback_source": "Geri Bildirim Kaynağı Ekle",
|
||||
"add_source": "Kaynak ekle",
|
||||
"allowed_values": "İzin verilen değerler: {values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_manage_api_keys": "Manage API keys",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "Otomatik olarak oluşturuldu",
|
||||
"change_file": "Dosyayı değiştir",
|
||||
"click_load_sample_csv": "Sütunları görmek için 'Örnek CSV yükle'ye tıkla",
|
||||
"click_to_upload": "Yüklemek için tıkla",
|
||||
"collected_at": "Toplandığı Tarih",
|
||||
"configure_import": "İçe aktarmayı yapılandır",
|
||||
"configure_mapping": "Eşleştirmeyi yapılandır",
|
||||
"connection": "Bağlantı",
|
||||
"connector_created_successfully": "Bağlayıcı başarıyla oluşturuldu",
|
||||
"connector_deleted_successfully": "Bağlayıcı başarıyla silindi",
|
||||
"connector_duplicated_successfully": "Bağlayıcı başarıyla kopyalandı",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "Verileri iki kez içe aktarmak yinelenen kayıtlar oluşturacaktır.",
|
||||
"csv_inconsistent_columns": "Satır {row} tutarsız sütunlara sahip. Tüm satırlar aynı başlıklara sahip olmalıdır.",
|
||||
"csv_max_records": "Maksimum {max} kayda izin verilir.",
|
||||
"custom_source_type": "Özel kaynak türü",
|
||||
"custom_source_type_placeholder": "Özel kaynak türünü girin",
|
||||
"default_connector_name_csv": "CSV İçe Aktarma",
|
||||
"default_connector_name_formbricks": "Formbricks Anket Bağlantısı",
|
||||
"deselect_all": "Tümünün seçimini kaldır",
|
||||
"discard_feedback_record_changes_description": "Bu çekmeceyi kapatırsanız değişiklikleriniz kaybolacak.",
|
||||
"discard_feedback_record_changes_title": "Kaydedilmemiş değişiklikler silinsin mi?",
|
||||
"drop_a_field_here": "Buraya bir alan bırakın",
|
||||
"drop_field_or": "Alan bırakın veya",
|
||||
"edit_csv_mapping": "CSV eşlemesini düzenle",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi",
|
||||
"feedback_date": "Geçerli tarih",
|
||||
"feedback_record_created_successfully": "Geri bildirim kaydı başarıyla oluşturuldu",
|
||||
"feedback_record_details": "Geri bildirim kaydı ayrıntıları",
|
||||
"feedback_record_details_description": "Geri bildirim kayıt alanlarını inceleyin ve güncelleyin.",
|
||||
"feedback_record_directory": "Geri Bildirim Kayıt Dizini",
|
||||
"feedback_record_fields": "Geri Bildirim Kayıt Alanları",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"feedback_record_updated_successfully": "Geri bildirim kaydı başarıyla güncellendi",
|
||||
"feedback_record_value_required": "Seçilen alan türü için bir değer gerekli",
|
||||
"feedback_records": "Geri Bildirim Kayıtları",
|
||||
"feedback_records_refreshed": "Geri bildirim kayıtları yenilendi",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
|
||||
"field_group_id": "Alan Grubu Kimliği",
|
||||
"field_group_label": "Alan Grubu Etiketi",
|
||||
"field_id": "Alan Kimliği",
|
||||
"field_label": "Alan Etiketi",
|
||||
"field_type": "Alan Türü",
|
||||
"formbricks_surveys": "Formbricks Anketleri",
|
||||
"frd_cannot_be_changed": "Geri bildirim dizini oluşturulduktan sonra değiştirilemez.",
|
||||
"go_to_feedback_record_directories": "Dizin ayarlarına git",
|
||||
"historical_import_complete": "İçe aktarma tamamlandı: {successes} başarılı, {failures} başarısız, {skipped} atlandı (veri yok)",
|
||||
"import_csv_data": "Geri bildirimi içe aktar",
|
||||
"import_feedback": "Geri bildirimi içe aktar",
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "{count} satır içe aktar",
|
||||
"import_via_source_name": "\"{sourceName}\" yoluyla içe aktar",
|
||||
"importing_data": "Veri içe aktarılıyor...",
|
||||
"importing_historical_data": "Geçmiş veriler içe aktarılıyor...",
|
||||
"invalid_enum_values": "{field} alanına eşlenen sütunda geçersiz değerler",
|
||||
"invalid_values_found": "Bulunan: {values} (satırlar: {rows}) {extra}",
|
||||
"load_sample_csv": "Örnek CSV yükle",
|
||||
"n_supported_questions": "{count} desteklenen soru",
|
||||
"manage_directories": "Manage directories",
|
||||
"manage_feedback_sources": "Geri bildirim kaynaklarını yönetin",
|
||||
"metadata": "Meta veriler",
|
||||
"metadata_key": "Meta veri anahtarı",
|
||||
"metadata_read_only_entries": "Salt okunur meta veri değerleri (dize dışı)",
|
||||
"metadata_value": "Meta veri değeri",
|
||||
"missing_feedback_source_title": "Missing feedback source?",
|
||||
"no_feedback_record_directory_available": "Bu çalışma alanına atanmış bir geri bildirim kayıt dizini yok. Önce bir tane oluştur veya ata.",
|
||||
"no_feedback_records": "Henüz geri bildirim kaydı yok. Bağlayıcıların veri göndermeye başlamasıyla kayıtlar burada görünecek.",
|
||||
"no_source_fields_loaded": "Henüz kaynak alan yüklenmedi",
|
||||
"no_sources_connected": "Henüz bağlı kaynak yok. Başlamak için bir kaynak ekle.",
|
||||
"no_surveys_found": "Bu ortamda anket bulunamadı",
|
||||
"optional": "İsteğe bağlı",
|
||||
"or_drag_and_drop": "veya sürükle bırak",
|
||||
"question_selected": "<strong>{count}</strong> soru seçildi. Bu soruya verilen her yanıt yeni bir Geri Bildirim Kaydı oluşturacak.",
|
||||
"question_type_not_supported": "Bu soru türü desteklenmiyor",
|
||||
"questions_selected": "<strong>{count}</strong> soru seçildi. Bu sorulara verilen her yanıt yeni bir Geri Bildirim Kaydı oluşturacak.",
|
||||
"records_will_go_to": "Kayıtlar şuraya gidecek",
|
||||
"refresh_feedback_records": "Geri bildirim kayıtlarını yenile",
|
||||
"refreshing_feedback_records": "Geri bildirim kayıtları yenileniyor...",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "Gerekli",
|
||||
"save_changes": "Değişiklikleri kaydet",
|
||||
"select_a_survey_to_see_questions": "Sorularını görmek için bir anket seç",
|
||||
"select_a_value": "Bir değer seç...",
|
||||
"select_all": "Tümünü seç",
|
||||
"select_feedback_record_directory": "Bir dizin seç",
|
||||
"select_feedback_record_source_type": "Kaynak türünü seçin",
|
||||
"select_questions": "Soru seç",
|
||||
"select_source_type_description": "Bağlamak istediğin geri bildirim kaynağının türünü seç.",
|
||||
"select_source_type_prompt": "Bağlamak istediğiniz geri bildirim kaynağının türünü seçin:",
|
||||
"select_survey": "Anket Seç",
|
||||
"select_survey_and_questions": "Anket ve Soruları Seç",
|
||||
"select_survey_questions_description": "Hangi anket sorularının GeriBildirimKayıtları oluşturması gerektiğini seçin.",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "{count} satırdan 3'ü gösteriliyor",
|
||||
"source": "kaynak",
|
||||
"source_connect_csv_description": "CSV dosyalarından geri bildirim içe aktar",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "Formbricks anketlerinizdeki geri bildirimleri bağlayın",
|
||||
"source_fields": "Kaynak Alanları",
|
||||
"source_id": "Kaynak kimliği",
|
||||
"source_name": "Kaynak Adı",
|
||||
"source_type": "Kaynak Türü",
|
||||
"source_type_cannot_be_changed": "Kaynak türü değiştirilemez",
|
||||
"sources": "Kaynaklar",
|
||||
"status_active": "Devam Ediyor",
|
||||
"status_completed": "Tamamlandı",
|
||||
"status_draft": "Taslak",
|
||||
"status_error": "Hata",
|
||||
"status_paused": "Duraklatıldı",
|
||||
"status_live_sync": "Live sync",
|
||||
"status_ready": "Ready",
|
||||
"submission_id": "Gönderim Kimliği",
|
||||
"survey_has_no_questions": "Bu ankette soru yok",
|
||||
"survey_import_line": "{surveyName}: {responseCount} yanıt × {questionCount} soru = {total} Geri Bildirim Kaydı",
|
||||
"total_feedback_records": "Toplam: {surveyCount} anket genelinde {total} Geri Bildirim Kaydından {checked} tanesi seçildi",
|
||||
"topics_and_subtopics": "Konular ve alt konular",
|
||||
"unify_feedback": "Geri Bildirimleri Birleştir",
|
||||
"update_mapping_description": "Bu kaynak için eşleme yapılandırmasını güncelle.",
|
||||
"updated_at": "Güncellenme tarihi",
|
||||
"upload_csv_data_description": "Geri bildirim verilerini içe aktarmak için bir CSV dosyası yükle.",
|
||||
"upload_csv_file": "CSV Dosyası Yükle",
|
||||
"user_identifier": "Kullanıcı",
|
||||
"value": "Değer"
|
||||
"value": "Değer",
|
||||
"value_boolean": "Değer (Boolean)",
|
||||
"value_date": "Değer (Tarih)",
|
||||
"value_number": "Değer (Sayı)",
|
||||
"value_text": "Değer (Metin)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
@@ -212,6 +212,7 @@
|
||||
"delete_what": "删除{deleteWhat}",
|
||||
"description": "描述",
|
||||
"disable": "禁用",
|
||||
"disabled": "已禁用",
|
||||
"disallow": "不允许",
|
||||
"discard": "丢弃",
|
||||
"dismissed": "忽略",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "您 未 认证 以 执行 该 操作。",
|
||||
"not_authorized": "未授权",
|
||||
"not_connected": "未连接",
|
||||
"not_set": "未设置",
|
||||
"note": "注释",
|
||||
"notifications": "通知",
|
||||
"number": "数字",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "某些文件上传失败",
|
||||
"something_went_wrong": "出错了",
|
||||
"something_went_wrong_please_try_again": "出错了 。请 尝试 再次 操作 。",
|
||||
"soon": "即将推出",
|
||||
"sort_by": "排序 依据",
|
||||
"start_free_trial": "开始免费试用",
|
||||
"status": "状态",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "将您的应用或网站连接到 Formbricks。",
|
||||
"cache_update_delay_description": "当您更新问卷、联系人、操作或其他数据时,这些更改最多可能需要 1 分钟才能在本地运行 Formbricks SDK 的应用中显示。",
|
||||
"cache_update_delay_title": "由于缓存,变更将在约 1 分钟后生效",
|
||||
"environment_id_legacy": "环境 ID(旧版)",
|
||||
"environment_id_legacy_alert": "您现有的 SDK 设置可能仍在使用旧版环境 ID。",
|
||||
"environment_id_legacy_alert_link": "了解原因以及如何迁移。",
|
||||
"formbricks_sdk_connected": "Formbricks SDK 已连接",
|
||||
"formbricks_sdk_not_connected": "Formbricks SDK 尚未连接。",
|
||||
"formbricks_sdk_not_connected_description": "将 Formbricks SDK 添加到您的网站或应用,以实现与 Formbricks 的连接。",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "反馈目录",
|
||||
"no_access": "你没有管理反馈记录目录的权限。",
|
||||
"no_connectors": "此目录尚未链接任何连接器。",
|
||||
"pause_connectors_confirmation_description": "暂停这些连接器后,将不会再添加新记录。",
|
||||
"pause_connectors_confirmation_title": "暂停已关联的连接器?",
|
||||
"select_workspaces_placeholder": "选择工作区...",
|
||||
"show_archived": "显示已归档",
|
||||
"title": "反馈记录目录",
|
||||
"unarchive": "取消归档"
|
||||
"unarchive": "取消归档",
|
||||
"unarchive_workspace_conflict": "无法取消归档该目录,因为一个或多个已分配工作区已归档。",
|
||||
"workspace_access": "工作区访问权限"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "数据增强与分析(AI)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "查看哪些团队可以访问此工作区。"
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "添加反馈记录",
|
||||
"add_feedback_record_description": "手动创建反馈记录。",
|
||||
"add_feedback_source": "添加反馈来源",
|
||||
"add_source": "添加来源",
|
||||
"allowed_values": "允许的值:{values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_manage_api_keys": "Manage API keys",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "自动生成",
|
||||
"change_file": "更换文件",
|
||||
"click_load_sample_csv": "点击“加载示例 CSV”查看列",
|
||||
"click_to_upload": "点击上传",
|
||||
"collected_at": "收集时间",
|
||||
"configure_import": "配置导入",
|
||||
"configure_mapping": "配置映射",
|
||||
"connection": "连接",
|
||||
"connector_created_successfully": "连接器创建成功",
|
||||
"connector_deleted_successfully": "连接器删除成功",
|
||||
"connector_duplicated_successfully": "连接器复制成功",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "重复导入数据会产生重复记录。",
|
||||
"csv_inconsistent_columns": "第 {row} 行的列数不一致。所有行必须有相同的表头。",
|
||||
"csv_max_records": "最多允许 {max} 条记录。",
|
||||
"custom_source_type": "自定义源类型",
|
||||
"custom_source_type_placeholder": "输入自定义来源类型",
|
||||
"default_connector_name_csv": "CSV 导入",
|
||||
"default_connector_name_formbricks": "Formbricks 调查连接",
|
||||
"deselect_all": "取消全选",
|
||||
"discard_feedback_record_changes_description": "如果关闭此抽屉,您的更改将会丢失。",
|
||||
"discard_feedback_record_changes_title": "放弃未保存的更改?",
|
||||
"drop_a_field_here": "将字段拖到这里",
|
||||
"drop_field_or": "拖放字段或",
|
||||
"edit_csv_mapping": "编辑 CSV 映射",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "枚举",
|
||||
"failed_to_load_feedback_records": "加载反馈记录失败",
|
||||
"feedback_date": "当前日期",
|
||||
"feedback_record_created_successfully": "反馈记录创建成功",
|
||||
"feedback_record_details": "反馈记录详情",
|
||||
"feedback_record_details_description": "查看并更新反馈记录字段。",
|
||||
"feedback_record_directory": "反馈记录目录",
|
||||
"feedback_record_fields": "反馈记录字段",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"feedback_record_updated_successfully": "反馈记录更新成功",
|
||||
"feedback_record_value_required": "所选字段类型需要一个值",
|
||||
"feedback_records": "反馈记录",
|
||||
"feedback_records_refreshed": "反馈记录已刷新",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
|
||||
"field_group_id": "字段组 ID",
|
||||
"field_group_label": "字段组标签",
|
||||
"field_id": "字段ID",
|
||||
"field_label": "字段标签",
|
||||
"field_type": "字段类型",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "反馈目录创建后无法更改。",
|
||||
"go_to_feedback_record_directories": "前往目录设置",
|
||||
"historical_import_complete": "导入完成:{successes} 个成功,{failures} 个失败,{skipped} 个跳过(无数据)",
|
||||
"import_csv_data": "导入反馈",
|
||||
"import_feedback": "导入反馈",
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "导入{count}行数据",
|
||||
"import_via_source_name": "通过“{sourceName}”导入",
|
||||
"importing_data": "正在导入数据…",
|
||||
"importing_historical_data": "正在导入历史数据…",
|
||||
"invalid_enum_values": "映射到 {field} 的列中存在无效值",
|
||||
"invalid_values_found": "发现:{values}(行:{rows}){extra}",
|
||||
"load_sample_csv": "加载示例 CSV",
|
||||
"n_supported_questions": "{count} 个支持的问题",
|
||||
"manage_directories": "Manage directories",
|
||||
"manage_feedback_sources": "管理反馈来源",
|
||||
"metadata": "元数据",
|
||||
"metadata_key": "元数据键",
|
||||
"metadata_read_only_entries": "只读元数据值(非字符串)",
|
||||
"metadata_value": "元数据值",
|
||||
"missing_feedback_source_title": "Missing feedback source?",
|
||||
"no_feedback_record_directory_available": "此工作区未分配反馈记录目录。请先创建或分配一个。",
|
||||
"no_feedback_records": "暂无反馈记录。当你的连接器开始发送数据后,记录会显示在这里。",
|
||||
"no_source_fields_loaded": "尚未加载源字段",
|
||||
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
|
||||
"no_surveys_found": "此环境下未找到调查",
|
||||
"optional": "可选",
|
||||
"or_drag_and_drop": "或拖放",
|
||||
"question_selected": "<strong>{count}</strong> 个问题已选。每个问题的回答都会创建一条新的反馈记录。",
|
||||
"question_type_not_supported": "不支持此问题类型",
|
||||
"questions_selected": "<strong>{count}</strong> 个问题已选。每个问题的回答都会创建一条新的反馈记录。",
|
||||
"records_will_go_to": "记录将发送至",
|
||||
"refresh_feedback_records": "刷新反馈记录",
|
||||
"refreshing_feedback_records": "正在刷新反馈记录…",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "必填",
|
||||
"save_changes": "保存更改",
|
||||
"select_a_survey_to_see_questions": "请选择一个调查以查看其问题",
|
||||
"select_a_value": "选择一个值...",
|
||||
"select_all": "全选",
|
||||
"select_feedback_record_directory": "选择目录",
|
||||
"select_feedback_record_source_type": "选择来源类型",
|
||||
"select_questions": "选择问题",
|
||||
"select_source_type_description": "请选择你想要连接的反馈来源类型。",
|
||||
"select_source_type_prompt": "请选择你想要连接的反馈来源类型:",
|
||||
"select_survey": "选择调查",
|
||||
"select_survey_and_questions": "选择调查和问题",
|
||||
"select_survey_questions_description": "选择哪些调查问题会创建反馈记录。",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "显示 {count} 行中的 3 行",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "从 CSV 文件导入反馈",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "连接来自你 Formbricks 调查的反馈",
|
||||
"source_fields": "来源字段",
|
||||
"source_id": "源ID",
|
||||
"source_name": "来源名称",
|
||||
"source_type": "来源类型",
|
||||
"source_type_cannot_be_changed": "来源类型无法更改",
|
||||
"sources": "来源",
|
||||
"status_active": "进行中",
|
||||
"status_completed": "已完成",
|
||||
"status_draft": "草稿",
|
||||
"status_error": "错误",
|
||||
"status_paused": "已暂停",
|
||||
"status_live_sync": "Live sync",
|
||||
"status_ready": "Ready",
|
||||
"submission_id": "提交ID",
|
||||
"survey_has_no_questions": "该调查没有任何问题",
|
||||
"survey_import_line": "{surveyName}:{responseCount} 份答卷 × {questionCount} 个问题 = {total} 条反馈记录",
|
||||
"total_feedback_records": "总计:{surveyCount} 个调查中已选 {checked} / {total} 条反馈记录",
|
||||
"topics_and_subtopics": "主题和子主题",
|
||||
"unify_feedback": "统一反馈",
|
||||
"update_mapping_description": "更新此来源的映射配置。",
|
||||
"updated_at": "更新于",
|
||||
"upload_csv_data_description": "上传 CSV 文件以导入反馈数据。",
|
||||
"upload_csv_file": "上传 CSV 文件",
|
||||
"user_identifier": "用户",
|
||||
"value": "值"
|
||||
"value": "值",
|
||||
"value_boolean": "值(布尔值)",
|
||||
"value_date": "值(日期)",
|
||||
"value_number": "值(数量)",
|
||||
"value_text": "值(文本)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "客户努力评分",
|
||||
|
||||
@@ -212,6 +212,7 @@
|
||||
"delete_what": "刪除{deleteWhat}",
|
||||
"description": "描述",
|
||||
"disable": "停用",
|
||||
"disabled": "已停用",
|
||||
"disallow": "不允許",
|
||||
"discard": "捨棄",
|
||||
"dismissed": "已關閉",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "您未經授權執行此操作。",
|
||||
"not_authorized": "未授權",
|
||||
"not_connected": "未連線",
|
||||
"not_set": "未設定",
|
||||
"note": "筆記",
|
||||
"notifications": "通知",
|
||||
"number": "數字",
|
||||
@@ -431,6 +433,7 @@
|
||||
"some_files_failed_to_upload": "部分檔案上傳失敗",
|
||||
"something_went_wrong": "發生錯誤",
|
||||
"something_went_wrong_please_try_again": "發生錯誤。請再試一次。",
|
||||
"soon": "即將推出",
|
||||
"sort_by": "排序方式",
|
||||
"start_free_trial": "開始免費試用",
|
||||
"status": "狀態",
|
||||
@@ -1858,6 +1861,9 @@
|
||||
"app_connection_description": "將您的應用程式或網站連接到 Formbricks。",
|
||||
"cache_update_delay_description": "當您更新問卷、聯絡人、動作或其他資料時,這些變更最多可能需要 1 分鐘才會反映在執行 Formbricks SDK 的本地應用程式中。",
|
||||
"cache_update_delay_title": "因快取機制,變更約 1 分鐘後才會反映",
|
||||
"environment_id_legacy": "環境 ID(舊版)",
|
||||
"environment_id_legacy_alert": "你現有的 SDK 設定可能仍在使用舊版環境 ID。",
|
||||
"environment_id_legacy_alert_link": "了解原因及遷移方式。",
|
||||
"formbricks_sdk_connected": "Formbricks SDK 已連線",
|
||||
"formbricks_sdk_not_connected": "Formbricks SDK 尚未連線。",
|
||||
"formbricks_sdk_not_connected_description": "將 Formbricks SDK 加入您的網站或應用程式,以連接至 Formbricks",
|
||||
@@ -2548,10 +2554,14 @@
|
||||
"nav_label": "意見回饋目錄",
|
||||
"no_access": "您沒有權限管理意見回饋記錄目錄。",
|
||||
"no_connectors": "此目錄尚未連結任何連接器。",
|
||||
"pause_connectors_confirmation_description": "暫停這些連接器後,將不會再新增新紀錄。",
|
||||
"pause_connectors_confirmation_title": "暫停已連結的連接器?",
|
||||
"select_workspaces_placeholder": "選擇工作區...",
|
||||
"show_archived": "顯示已封存",
|
||||
"title": "意見回饋記錄目錄",
|
||||
"unarchive": "取消封存"
|
||||
"unarchive": "取消封存",
|
||||
"unarchive_workspace_conflict": "無法取消封存此目錄,因為一個或多個已指派工作區已封存。",
|
||||
"workspace_access": "工作區存取權限"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "資料增強與分析(AI)",
|
||||
@@ -3613,16 +3623,21 @@
|
||||
"team_settings_description": "查看哪些團隊可以存取此工作區。"
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "新增回饋記錄",
|
||||
"add_feedback_record_description": "手動建立回饋記錄。",
|
||||
"add_feedback_source": "新增回饋來源",
|
||||
"add_source": "新增來源",
|
||||
"allowed_values": "允許的值:{values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_manage_api_keys": "Manage API keys",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "自動生成",
|
||||
"change_file": "更換檔案",
|
||||
"click_load_sample_csv": "點擊「載入範例 CSV」以查看欄位",
|
||||
"click_to_upload": "點擊以上傳",
|
||||
"collected_at": "收集時間",
|
||||
"configure_import": "設定匯入",
|
||||
"configure_mapping": "設定對應關係",
|
||||
"connection": "連線",
|
||||
"connector_created_successfully": "連接器建立成功",
|
||||
"connector_deleted_successfully": "連接器刪除成功",
|
||||
"connector_duplicated_successfully": "連接器複製成功",
|
||||
@@ -3641,9 +3656,12 @@
|
||||
"csv_import_duplicate_warning": "匯入已經匯入過的資料,可能會產生重複紀錄。",
|
||||
"csv_inconsistent_columns": "第 {row} 列的欄位數不一致。所有列必須有相同的標題。",
|
||||
"csv_max_records": "最多允許 {max} 筆紀錄。",
|
||||
"custom_source_type": "自訂來源類型",
|
||||
"custom_source_type_placeholder": "輸入自訂來源類型",
|
||||
"default_connector_name_csv": "CSV 匯入",
|
||||
"default_connector_name_formbricks": "Formbricks 問卷連線",
|
||||
"deselect_all": "取消全選",
|
||||
"discard_feedback_record_changes_description": "如果關閉此抽屜,您的變更將會遺失。",
|
||||
"discard_feedback_record_changes_title": "放棄未儲存的變更?",
|
||||
"drop_a_field_here": "請將欄位拖曳到這裡",
|
||||
"drop_field_or": "拖曳欄位或",
|
||||
"edit_csv_mapping": "編輯 CSV 對應",
|
||||
@@ -3653,47 +3671,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "載入回饋紀錄失敗",
|
||||
"feedback_date": "目前日期",
|
||||
"feedback_record_created_successfully": "回饋記錄創建成功",
|
||||
"feedback_record_details": "反饋記錄詳情",
|
||||
"feedback_record_details_description": "查看並更新回饋記錄欄位。",
|
||||
"feedback_record_directory": "意見回饋記錄目錄",
|
||||
"feedback_record_fields": "回饋紀錄欄位",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"feedback_record_updated_successfully": "回饋記錄更新成功",
|
||||
"feedback_record_value_required": "所選欄位類型需要一個值",
|
||||
"feedback_records": "回饋紀錄",
|
||||
"feedback_records_refreshed": "回饋紀錄已更新",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
|
||||
"field_group_id": "字段組 ID",
|
||||
"field_group_label": "字段組標籤",
|
||||
"field_id": "欄位ID",
|
||||
"field_label": "欄位標籤",
|
||||
"field_type": "欄位類型",
|
||||
"formbricks_surveys": "Formbricks 問卷",
|
||||
"frd_cannot_be_changed": "意見回饋目錄在建立後無法變更。",
|
||||
"go_to_feedback_record_directories": "前往目錄設定",
|
||||
"historical_import_complete": "匯入完成:{successes} 筆成功,{failures} 筆失敗,{skipped} 筆略過(無資料)",
|
||||
"import_csv_data": "匯入 CSV 資料",
|
||||
"import_feedback": "匯入回饋",
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "匯入 {count} 筆資料",
|
||||
"import_via_source_name": "透過“{sourceName}”導入",
|
||||
"importing_data": "正在匯入資料…",
|
||||
"importing_historical_data": "正在匯入歷史資料…",
|
||||
"invalid_enum_values": "對應到 {field} 欄位的值無效",
|
||||
"invalid_values_found": "發現:{values}(列:{rows}){extra}",
|
||||
"load_sample_csv": "載入範例 CSV",
|
||||
"n_supported_questions": "{count} 個支援的問題",
|
||||
"manage_directories": "Manage directories",
|
||||
"manage_feedback_sources": "管理回饋來源",
|
||||
"metadata": "元數據",
|
||||
"metadata_key": "元資料鍵",
|
||||
"metadata_read_only_entries": "唯讀元資料值(非字串)",
|
||||
"metadata_value": "元資料值",
|
||||
"missing_feedback_source_title": "Missing feedback source?",
|
||||
"no_feedback_record_directory_available": "此工作區尚未指派意見回饋記錄目錄。請先建立或指派一個目錄。",
|
||||
"no_feedback_records": "目前尚無回饋紀錄。當你的連接器開始傳送資料時,紀錄會顯示在這裡。",
|
||||
"no_source_fields_loaded": "尚未載入來源欄位",
|
||||
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
|
||||
"no_surveys_found": "此環境中找不到問卷",
|
||||
"optional": "選填",
|
||||
"or_drag_and_drop": "或拖曳檔案",
|
||||
"question_selected": "已選擇 <strong>{count}</strong> 題。每份這些題目的回應都會建立一筆新的意見紀錄。",
|
||||
"question_type_not_supported": "不支援此題型",
|
||||
"questions_selected": "已選擇 <strong>{count}</strong> 題。每份這些題目的回應都會建立一筆新的意見紀錄。",
|
||||
"records_will_go_to": "記錄將傳送至",
|
||||
"refresh_feedback_records": "重新整理回饋紀錄",
|
||||
"refreshing_feedback_records": "正在更新回饋紀錄…",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "必填",
|
||||
"save_changes": "儲存變更",
|
||||
"select_a_survey_to_see_questions": "請選擇問卷以查看其問題",
|
||||
"select_a_value": "請選擇一個值...",
|
||||
"select_all": "全選",
|
||||
"select_feedback_record_directory": "選擇目錄",
|
||||
"select_feedback_record_source_type": "選擇來源類型",
|
||||
"select_questions": "選擇問題",
|
||||
"select_source_type_description": "請選擇你想要連接的回饋來源類型。",
|
||||
"select_source_type_prompt": "請選擇你想要連接的回饋來源類型:",
|
||||
"select_survey": "選擇問卷",
|
||||
"select_survey_and_questions": "選擇問卷與問題",
|
||||
"select_survey_questions_description": "請選擇哪些問卷問題要建立 FeedbackRecords。",
|
||||
@@ -3703,27 +3738,30 @@
|
||||
"showing_rows": "顯示 {count} 筆資料中的 3 筆",
|
||||
"source": "來源",
|
||||
"source_connect_csv_description": "從 CSV 檔案匯入回饋",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "連接來自你 Formbricks 問卷的回饋",
|
||||
"source_fields": "來源欄位",
|
||||
"source_id": "來源ID",
|
||||
"source_name": "來源名稱",
|
||||
"source_type": "來源類型",
|
||||
"source_type_cannot_be_changed": "來源類型無法變更",
|
||||
"sources": "來源",
|
||||
"status_active": "進行中",
|
||||
"status_completed": "已完成",
|
||||
"status_draft": "草稿",
|
||||
"status_error": "錯誤",
|
||||
"status_paused": "已暫停",
|
||||
"status_live_sync": "Live sync",
|
||||
"status_ready": "Ready",
|
||||
"submission_id": "提交ID",
|
||||
"survey_has_no_questions": "此問卷沒有任何題目",
|
||||
"survey_import_line": "{surveyName}:{responseCount} 份回應 × {questionCount} 題 = {total} 筆意見紀錄",
|
||||
"total_feedback_records": "總計:{surveyCount} 份問卷中已選擇 {checked} / {total} 筆意見紀錄",
|
||||
"topics_and_subtopics": "主題與子主題",
|
||||
"unify_feedback": "整合回饋",
|
||||
"update_mapping_description": "更新此來源的對應設定。",
|
||||
"updated_at": "更新時間",
|
||||
"upload_csv_data_description": "上傳 CSV 檔案以匯入回饋資料。",
|
||||
"upload_csv_file": "上傳 CSV 檔案",
|
||||
"user_identifier": "使用者",
|
||||
"value": "值"
|
||||
"value": "值",
|
||||
"value_boolean": "值(布林值)",
|
||||
"value_date": "值(日期)",
|
||||
"value_number": "值(數量)",
|
||||
"value_text": "值(文字)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
@@ -224,7 +224,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
// Fetch updated response with relations for pipeline
|
||||
const updatedResponseForPipeline = await getResponseForPipeline(params.responseId);
|
||||
if (updatedResponseForPipeline.ok) {
|
||||
sendToPipeline({
|
||||
await sendToPipeline({
|
||||
event: "responseUpdated",
|
||||
workspaceId: workspaceIdResult.data.workspaceId,
|
||||
surveyId: existingResponse.data.surveyId,
|
||||
@@ -232,7 +232,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
});
|
||||
|
||||
if (response.data.finished) {
|
||||
sendToPipeline({
|
||||
await sendToPipeline({
|
||||
event: "responseFinished",
|
||||
workspaceId: workspaceIdResult.data.workspaceId,
|
||||
surveyId: existingResponse.data.surveyId,
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockAuthenticatedApiClient,
|
||||
mockCreateResponseWithQuotaEvaluation,
|
||||
mockCreatedResponse,
|
||||
mockFormatValidationErrorsForV2Api,
|
||||
mockGetResponseForPipeline,
|
||||
mockGetSurveyQuestions,
|
||||
mockGetWorkspaceId,
|
||||
mockHandleApiError,
|
||||
mockHasPermission,
|
||||
mockSendToPipeline,
|
||||
mockValidateFileUploads,
|
||||
mockValidateOtherOptionLengthForMultipleChoice,
|
||||
mockValidateResponseData,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthenticatedApiClient: vi.fn(),
|
||||
mockCreateResponseWithQuotaEvaluation: vi.fn(),
|
||||
mockCreatedResponse: vi.fn(),
|
||||
mockFormatValidationErrorsForV2Api: vi.fn(),
|
||||
mockGetResponseForPipeline: vi.fn(),
|
||||
mockGetSurveyQuestions: vi.fn(),
|
||||
mockGetWorkspaceId: vi.fn(),
|
||||
mockHandleApiError: vi.fn(),
|
||||
mockHasPermission: vi.fn(),
|
||||
mockSendToPipeline: vi.fn(),
|
||||
mockValidateFileUploads: vi.fn(),
|
||||
mockValidateOtherOptionLengthForMultipleChoice: vi.fn(),
|
||||
mockValidateResponseData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/pipelines", () => ({
|
||||
sendToPipeline: mockSendToPipeline,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/validation", () => ({
|
||||
formatValidationErrorsForV2Api: mockFormatValidationErrorsForV2Api,
|
||||
validateResponseData: mockValidateResponseData,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/auth/authenticated-api-client", () => ({
|
||||
authenticatedApiClient: mockAuthenticatedApiClient,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/element", () => ({
|
||||
validateOtherOptionLengthForMultipleChoice: mockValidateOtherOptionLengthForMultipleChoice,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/response", () => ({
|
||||
responses: {
|
||||
createdResponse: mockCreatedResponse,
|
||||
successResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
handleApiError: mockHandleApiError,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/helper", () => ({
|
||||
getWorkspaceId: mockGetWorkspaceId,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/responses/[responseId]/lib/survey", () => ({
|
||||
getSurveyQuestions: mockGetSurveyQuestions,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/responses/[responseId]/lib/response", () => ({
|
||||
getResponseForPipeline: mockGetResponseForPipeline,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({
|
||||
hasPermission: mockHasPermission,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/storage/utils", () => ({
|
||||
resolveStorageUrlsInObject: vi.fn((value) => value),
|
||||
validateFileUploads: mockValidateFileUploads,
|
||||
}));
|
||||
|
||||
vi.mock("./lib/response", () => ({
|
||||
createResponseWithQuotaEvaluation: mockCreateResponseWithQuotaEvaluation,
|
||||
getResponses: vi.fn(),
|
||||
}));
|
||||
|
||||
const workspaceId = "cm9workspace000108l4abcz12";
|
||||
const surveyId = "cm9survey000108l4abcz12zz";
|
||||
const responseId = "cm9response000108l4abcz12";
|
||||
const createdAt = new Date("2026-04-13T10:00:00.000Z");
|
||||
|
||||
const createdResponse = {
|
||||
contactAttributes: null,
|
||||
contactId: null,
|
||||
createdAt,
|
||||
data: {},
|
||||
displayId: null,
|
||||
endingId: null,
|
||||
finished: true,
|
||||
id: responseId,
|
||||
language: null,
|
||||
meta: {},
|
||||
singleUseId: null,
|
||||
surveyId,
|
||||
ttc: {},
|
||||
updatedAt: createdAt,
|
||||
variables: {},
|
||||
};
|
||||
|
||||
const responseSnapshot = {
|
||||
contact: null,
|
||||
contactAttributes: null,
|
||||
createdAt,
|
||||
data: {},
|
||||
displayId: null,
|
||||
endingId: null,
|
||||
finished: true,
|
||||
id: responseId,
|
||||
language: null,
|
||||
meta: {},
|
||||
singleUseId: null,
|
||||
surveyId,
|
||||
tags: [],
|
||||
ttc: {},
|
||||
updatedAt: createdAt,
|
||||
variables: {},
|
||||
};
|
||||
|
||||
describe("POST /modules/api/v2/management/responses", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockAuthenticatedApiClient.mockImplementation(
|
||||
async ({ handler }) =>
|
||||
await handler({
|
||||
auditLog: undefined,
|
||||
authentication: {
|
||||
workspacePermissions: [{ workspaceId, actions: ["POST"] }],
|
||||
},
|
||||
parsedInput: {
|
||||
body: {
|
||||
data: {},
|
||||
finished: true,
|
||||
surveyId,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
mockGetWorkspaceId.mockResolvedValue({ data: { workspaceId }, ok: true });
|
||||
mockHasPermission.mockReturnValue(true);
|
||||
mockGetSurveyQuestions.mockResolvedValue({ data: { blocks: [], questions: [] }, ok: true });
|
||||
mockValidateFileUploads.mockReturnValue(true);
|
||||
mockValidateOtherOptionLengthForMultipleChoice.mockReturnValue(undefined);
|
||||
mockValidateResponseData.mockReturnValue(null);
|
||||
mockSendToPipeline.mockResolvedValue(undefined);
|
||||
mockCreateResponseWithQuotaEvaluation.mockResolvedValue({ data: createdResponse, ok: true });
|
||||
mockGetResponseForPipeline.mockResolvedValue({ data: responseSnapshot, ok: true });
|
||||
mockCreatedResponse.mockImplementation((body: unknown) => Response.json(body, { status: 201 }));
|
||||
mockHandleApiError.mockImplementation((_, error) => Response.json({ error }, { status: 400 }));
|
||||
});
|
||||
|
||||
test("passes the freshly hydrated response snapshot to the pipeline", async () => {
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/v2/management/responses", { method: "POST" })
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockGetResponseForPipeline).toHaveBeenCalledWith(responseId);
|
||||
expect(mockSendToPipeline).toHaveBeenNthCalledWith(1, {
|
||||
event: "responseCreated",
|
||||
response: responseSnapshot,
|
||||
surveyId,
|
||||
workspaceId,
|
||||
});
|
||||
expect(mockSendToPipeline).toHaveBeenNthCalledWith(2, {
|
||||
event: "responseFinished",
|
||||
response: responseSnapshot,
|
||||
surveyId,
|
||||
workspaceId,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 201 when loading the pipeline snapshot throws", async () => {
|
||||
mockGetResponseForPipeline.mockRejectedValueOnce(new Error("snapshot failed"));
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/v2/management/responses", { method: "POST" })
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockSendToPipeline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 201 when pipeline dispatch rejects", async () => {
|
||||
mockSendToPipeline.mockRejectedValueOnce(new Error("pipeline failed"));
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/v2/management/responses", { method: "POST" })
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockSendToPipeline).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("returns the create-response error payload when response creation fails", async () => {
|
||||
mockCreateResponseWithQuotaEvaluation.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "bad_request",
|
||||
details: [{ field: "surveyId", issue: "invalid" }],
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/v2/management/responses", { method: "POST" })
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockHandleApiError).toHaveBeenCalledWith(
|
||||
expect.any(Request),
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: "surveyId", issue: "invalid" }],
|
||||
},
|
||||
undefined
|
||||
);
|
||||
expect(mockSendToPipeline).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Response } from "@prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
@@ -16,31 +15,6 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util
|
||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
|
||||
|
||||
const queueResponsePipelineEvent = ({
|
||||
event,
|
||||
response,
|
||||
surveyId,
|
||||
workspaceId,
|
||||
}: Parameters<typeof sendToPipeline>[0]): void => {
|
||||
void sendToPipeline({
|
||||
event,
|
||||
response,
|
||||
surveyId,
|
||||
workspaceId,
|
||||
}).catch((error: unknown) => {
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
event,
|
||||
responseId: response.id,
|
||||
surveyId,
|
||||
workspaceId,
|
||||
},
|
||||
"Failed to send response event to pipeline"
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const GET = async (request: NextRequest) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
@@ -114,14 +88,13 @@ export const POST = async (request: Request) =>
|
||||
);
|
||||
}
|
||||
|
||||
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
|
||||
if (body.createdAt && !body.updatedAt) {
|
||||
body.updatedAt = body.createdAt;
|
||||
}
|
||||
|
||||
const surveyQuestions = await getSurveyQuestions(body.surveyId);
|
||||
if (!surveyQuestions.ok) {
|
||||
return handleApiError(request, surveyQuestions.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
|
||||
return handleApiError(request, surveyQuestions.error as ApiErrorResponseV2, auditLog); // NOSONAR
|
||||
}
|
||||
|
||||
if (!validateFileUploads(body.data, surveyQuestions.data.questions)) {
|
||||
@@ -135,7 +108,6 @@ export const POST = async (request: Request) =>
|
||||
);
|
||||
}
|
||||
|
||||
// Validate response data for "other" options exceeding character limit
|
||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||
responseData: body.data,
|
||||
surveyQuestions: surveyQuestions.data.questions,
|
||||
@@ -157,7 +129,6 @@ export const POST = async (request: Request) =>
|
||||
});
|
||||
}
|
||||
|
||||
// Validate response data against validation rules
|
||||
const validationErrors = validateResponseData(
|
||||
surveyQuestions.data.blocks,
|
||||
body.data,
|
||||
@@ -181,37 +152,27 @@ export const POST = async (request: Request) =>
|
||||
return handleApiError(request, createResponseResult.error, auditLog);
|
||||
}
|
||||
|
||||
// Fetch created response with relations for pipeline
|
||||
try {
|
||||
const createdResponseForPipeline = await getResponseForPipeline(createResponseResult.data.id);
|
||||
if (createdResponseForPipeline.ok) {
|
||||
queueResponsePipelineEvent({
|
||||
event: "responseCreated",
|
||||
workspaceId,
|
||||
surveyId: body.surveyId,
|
||||
response: createdResponseForPipeline.data,
|
||||
});
|
||||
|
||||
if (createResponseResult.data.finished) {
|
||||
queueResponsePipelineEvent({
|
||||
event: "responseFinished",
|
||||
getResponseForPipeline(createResponseResult.data.id)
|
||||
.then((createdResponseForPipeline) => {
|
||||
if (createdResponseForPipeline.ok) {
|
||||
sendToPipeline({
|
||||
event: "responseCreated",
|
||||
workspaceId,
|
||||
surveyId: body.surveyId,
|
||||
response: createdResponseForPipeline.data,
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
if (createResponseResult.data.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
workspaceId,
|
||||
surveyId: body.surveyId,
|
||||
response: createdResponseForPipeline.data,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
responseId: createResponseResult.data.id,
|
||||
surveyId: body.surveyId,
|
||||
workspaceId,
|
||||
},
|
||||
"Failed to load response data for pipeline dispatch"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.targetId = createResponseResult.data.id;
|
||||
|
||||
@@ -74,6 +74,7 @@ export const getFeedbackRecordDirectoryDetailsAction = authenticatedActionClient
|
||||
const ZUpdateFeedbackRecordDirectoryAction = z.object({
|
||||
directoryId: ZId,
|
||||
data: ZFeedbackRecordDirectoryUpdateInput,
|
||||
pauseConnectorsInRemovedWorkspaces: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const updateFeedbackRecordDirectoryAction = authenticatedActionClient
|
||||
@@ -99,7 +100,10 @@ export const updateFeedbackRecordDirectoryAction = authenticatedActionClient
|
||||
const result = await updateFeedbackRecordDirectory(
|
||||
parsedInput.directoryId,
|
||||
organizationId,
|
||||
parsedInput.data
|
||||
parsedInput.data,
|
||||
{
|
||||
pauseConnectorsInRemovedWorkspaces: parsedInput.pauseConnectorsInRemovedWorkspaces,
|
||||
}
|
||||
);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
|
||||
|
||||
+147
-27
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -17,6 +18,7 @@ import { ArchiveFeedbackRecordDirectory } from "@/modules/ee/feedback-record-dir
|
||||
import {
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TFeedbackRecordDirectoryUpdateInput,
|
||||
TWorkspaceFeedbackRecordDirectoryAccess,
|
||||
ZFeedbackRecordDirectoryUpdateInput,
|
||||
getTranslatedFeedbackRecordDirectoryError,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
@@ -43,6 +45,7 @@ interface FeedbackRecordDirectorySettingsModalProps {
|
||||
directory?: TFeedbackRecordDirectoryDetails;
|
||||
organizationId: string;
|
||||
orgWorkspaces: TOrganizationWorkspace[];
|
||||
workspaceAccessByWorkspace: TWorkspaceFeedbackRecordDirectoryAccess[];
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
|
||||
@@ -52,24 +55,47 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
directory,
|
||||
organizationId,
|
||||
orgWorkspaces,
|
||||
workspaceAccessByWorkspace,
|
||||
membershipRole,
|
||||
}: FeedbackRecordDirectorySettingsModalProps) => {
|
||||
}: Readonly<FeedbackRecordDirectorySettingsModalProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
const router = useRouter();
|
||||
const isEdit = !!directory;
|
||||
|
||||
const [confirmPauseDialogOpen, setConfirmPauseDialogOpen] = useState(false);
|
||||
const [pendingSubmitData, setPendingSubmitData] = useState<TFeedbackRecordDirectoryUpdateInput | null>(
|
||||
null
|
||||
);
|
||||
const [connectorsToPauseCount, setConnectorsToPauseCount] = useState(0);
|
||||
|
||||
const workspaceAccessMap = useMemo(
|
||||
() => new Map(workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])),
|
||||
[workspaceAccessByWorkspace]
|
||||
);
|
||||
|
||||
const workspaceOptions = useMemo(
|
||||
() =>
|
||||
orgWorkspaces
|
||||
.map((p) => ({ value: p.id, label: p.name }))
|
||||
.map((workspace) => {
|
||||
const assignment = workspaceAccessMap.get(workspace.id);
|
||||
const isAssignedToDifferentDirectory = Boolean(
|
||||
assignment && assignment.feedbackRecordDirectoryId !== directory?.id
|
||||
);
|
||||
|
||||
return {
|
||||
value: workspace.id,
|
||||
label: workspace.name,
|
||||
disabled: isAssignedToDifferentDirectory,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })),
|
||||
[orgWorkspaces]
|
||||
[orgWorkspaces, workspaceAccessMap, directory?.id]
|
||||
);
|
||||
|
||||
const initialWorkspaceIds = useMemo(
|
||||
() => directory?.workspaces.map((p) => p.workspaceId) ?? [],
|
||||
() => directory?.workspaces.map((workspace) => workspace.workspaceId) ?? [],
|
||||
[directory?.workspaces]
|
||||
);
|
||||
|
||||
@@ -91,21 +117,29 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
} = form;
|
||||
|
||||
const closeModal = () => {
|
||||
setConfirmPauseDialogOpen(false);
|
||||
setPendingSubmitData(null);
|
||||
setConnectorsToPauseCount(0);
|
||||
reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmitForm: SubmitHandler<TFeedbackRecordDirectoryUpdateInput> = async (data) => {
|
||||
const response = isEdit
|
||||
? await updateFeedbackRecordDirectoryAction({
|
||||
directoryId: directory.id,
|
||||
data: { name: data.name, workspaceIds: data.workspaceIds },
|
||||
})
|
||||
: await createFeedbackRecordDirectoryAction({
|
||||
organizationId,
|
||||
name: data.name ?? "",
|
||||
workspaceIds: data.workspaceIds,
|
||||
});
|
||||
const submitDirectory = async (
|
||||
data: TFeedbackRecordDirectoryUpdateInput,
|
||||
pauseConnectorsInRemovedWorkspaces: boolean
|
||||
) => {
|
||||
const response =
|
||||
isEdit && directory
|
||||
? await updateFeedbackRecordDirectoryAction({
|
||||
directoryId: directory.id,
|
||||
data: { name: data.name, workspaceIds: data.workspaceIds },
|
||||
pauseConnectorsInRemovedWorkspaces,
|
||||
})
|
||||
: await createFeedbackRecordDirectoryAction({
|
||||
organizationId,
|
||||
name: data.name ?? "",
|
||||
workspaceIds: data.workspaceIds,
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
toast.success(
|
||||
@@ -115,12 +149,54 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
);
|
||||
closeModal();
|
||||
router.refresh();
|
||||
return true;
|
||||
} else {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmPauseAndSubmit = async () => {
|
||||
if (!pendingSubmitData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasSuccessful = await submitDirectory(pendingSubmitData, true);
|
||||
if (wasSuccessful) {
|
||||
setConfirmPauseDialogOpen(false);
|
||||
setPendingSubmitData(null);
|
||||
setConnectorsToPauseCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitForm: SubmitHandler<TFeedbackRecordDirectoryUpdateInput> = async (data) => {
|
||||
if (!isEdit || !directory) {
|
||||
await submitDirectory(data, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedWorkspaceIds = data.workspaceIds ?? [];
|
||||
const removedWorkspaceIds = initialWorkspaceIds.filter(
|
||||
(workspaceId) => !updatedWorkspaceIds.includes(workspaceId)
|
||||
);
|
||||
|
||||
if (removedWorkspaceIds.length > 0) {
|
||||
const affectedConnectors = directory.connectors.filter((connector) =>
|
||||
removedWorkspaceIds.includes(connector.workspaceId)
|
||||
);
|
||||
|
||||
if (affectedConnectors.length > 0) {
|
||||
setPendingSubmitData(data);
|
||||
setConnectorsToPauseCount(affectedConnectors.length);
|
||||
setConfirmPauseDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await submitDirectory(data, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(newOpen) => (newOpen ? setOpen(true) : closeModal())}>
|
||||
<DialogContent>
|
||||
@@ -157,21 +233,17 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
disabled={!isOwnerOrManager}
|
||||
/>
|
||||
</FormControl>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
{error?.message && (
|
||||
<FormError className="text-left">
|
||||
{getTranslatedFeedbackRecordDirectoryError(error.message, t)}
|
||||
</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isEdit && (
|
||||
<IdBadge
|
||||
id={directory.id}
|
||||
label={t("workspace.settings.feedback_record_directories.directory_id")}
|
||||
variant="column"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("common.workspaces")}</FormLabel>
|
||||
<FormLabel>{t("workspace.settings.feedback_record_directories.workspace_access")}</FormLabel>
|
||||
<Muted className="block text-slate-500">
|
||||
{t("workspace.settings.feedback_record_directories.assign_workspaces_description")}
|
||||
</Muted>
|
||||
@@ -213,7 +285,7 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
</div>
|
||||
<a
|
||||
className="text-xs font-medium text-slate-700 hover:text-slate-900 hover:underline"
|
||||
href={`/workspaces/${c.workspaceId}/unify/sources`}>
|
||||
href={`/workspaces/${c.workspaceId}/feedback-sources`}>
|
||||
{t("common.view")}
|
||||
</a>
|
||||
</li>
|
||||
@@ -222,6 +294,14 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEdit && (
|
||||
<IdBadge
|
||||
id={directory.id}
|
||||
label={t("workspace.settings.feedback_record_directories.directory_id")}
|
||||
variant="column"
|
||||
/>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{isEdit && (
|
||||
@@ -243,6 +323,46 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
|
||||
{confirmPauseDialogOpen && (
|
||||
<Dialog open={confirmPauseDialogOpen} onOpenChange={setConfirmPauseDialogOpen}>
|
||||
<DialogContent width="narrow" hideCloseButton={true} disableCloseOnOutsideClick={true}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
<DialogTitle>
|
||||
{t("workspace.settings.feedback_record_directories.pause_connectors_confirmation_title")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<p>
|
||||
{t(
|
||||
"workspace.settings.feedback_record_directories.pause_connectors_confirmation_description",
|
||||
{
|
||||
count: connectorsToPauseCount,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setConfirmPauseDialogOpen(false);
|
||||
setPendingSubmitData(null);
|
||||
setConnectorsToPauseCount(0);
|
||||
}}
|
||||
disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmPauseAndSubmit} loading={isSubmitting}>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
+27
-1
@@ -15,6 +15,7 @@ import { FeedbackRecordDirectorySettingsModal } from "@/modules/ee/feedback-reco
|
||||
import {
|
||||
TFeedbackRecordDirectory,
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TWorkspaceFeedbackRecordDirectoryAccess,
|
||||
getTranslatedFeedbackRecordDirectoryError,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
import { TOrganizationWorkspace } from "@/modules/ee/teams/team-list/types/workspace";
|
||||
@@ -27,6 +28,7 @@ interface FeedbackRecordDirectoryTableProps {
|
||||
directories: TFeedbackRecordDirectory[];
|
||||
organizationId: string;
|
||||
orgWorkspaces: TOrganizationWorkspace[];
|
||||
workspaceAccessByWorkspace: TWorkspaceFeedbackRecordDirectoryAccess[];
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
|
||||
@@ -34,8 +36,9 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
directories,
|
||||
organizationId,
|
||||
orgWorkspaces,
|
||||
workspaceAccessByWorkspace,
|
||||
membershipRole,
|
||||
}: FeedbackRecordDirectoryTableProps) => {
|
||||
}: Readonly<FeedbackRecordDirectoryTableProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openSettingsModal, setOpenSettingsModal] = useState(false);
|
||||
@@ -67,6 +70,27 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
const handleUnarchiveDirectory = async (directoryId: string) => {
|
||||
setLoadingDirectoryId(directoryId);
|
||||
try {
|
||||
const directoryDetailsResponse = await getFeedbackRecordDirectoryDetailsAction({ directoryId });
|
||||
if (!directoryDetailsResponse?.data) {
|
||||
const errorCode = getFormattedErrorMessage(directoryDetailsResponse);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceAccessMap = new Map(
|
||||
workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])
|
||||
);
|
||||
|
||||
const hasConflicts = directoryDetailsResponse.data.workspaces.some((workspace) => {
|
||||
const assignment = workspaceAccessMap.get(workspace.workspaceId);
|
||||
return assignment && assignment.feedbackRecordDirectoryId !== directoryId;
|
||||
});
|
||||
|
||||
if (hasConflicts) {
|
||||
toast.error(t("workspace.settings.feedback_record_directories.unarchive_workspace_conflict"));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await updateFeedbackRecordDirectoryAction({
|
||||
directoryId,
|
||||
data: { isArchived: false },
|
||||
@@ -166,6 +190,7 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
setOpen={setOpenCreateModal}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
workspaceAccessByWorkspace={workspaceAccessByWorkspace}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
)}
|
||||
@@ -177,6 +202,7 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
directory={selectedDirectory}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
workspaceAccessByWorkspace={workspaceAccessByWorkspace}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
)}
|
||||
|
||||
+7
-2
@@ -2,7 +2,10 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { FeedbackRecordDirectoryTable } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-table";
|
||||
import { getFeedbackRecordDirectories } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import {
|
||||
getFeedbackRecordDirectories,
|
||||
getWorkspaceFeedbackRecordDirectoryAccess,
|
||||
} from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspacesByOrganizationId } from "@/modules/ee/teams/team-list/lib/workspace";
|
||||
|
||||
interface FeedbackRecordDirectoryViewProps {
|
||||
@@ -16,9 +19,10 @@ export const FeedbackRecordDirectoryView = async ({
|
||||
}: FeedbackRecordDirectoryViewProps) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const [directories, orgWorkspaces] = await Promise.all([
|
||||
const [directories, orgWorkspaces, workspaceAccessByWorkspace] = await Promise.all([
|
||||
getFeedbackRecordDirectories(organizationId),
|
||||
getWorkspacesByOrganizationId(organizationId),
|
||||
getWorkspaceFeedbackRecordDirectoryAccess(organizationId),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -29,6 +33,7 @@ export const FeedbackRecordDirectoryView = async ({
|
||||
directories={directories}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
workspaceAccessByWorkspace={workspaceAccessByWorkspace}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
+111
-2
@@ -8,6 +8,7 @@ import {
|
||||
getFeedbackRecordDirectoriesByWorkspaceId,
|
||||
getFeedbackRecordDirectoryDetails,
|
||||
getOrganizationIdFromDirectoryId,
|
||||
getWorkspaceFeedbackRecordDirectoryAccess,
|
||||
updateFeedbackRecordDirectory,
|
||||
} from "./feedback-record-directory";
|
||||
|
||||
@@ -33,6 +34,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
connector: {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -147,7 +149,7 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "My Connector",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: mockWorkspaceId1,
|
||||
workspace: { name: "Workspace A" },
|
||||
},
|
||||
@@ -161,7 +163,7 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "My Connector",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: mockWorkspaceId1,
|
||||
workspaceName: "Workspace A",
|
||||
},
|
||||
@@ -345,6 +347,34 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("pauses connectors in removed workspaces when requested", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(
|
||||
mockDirectoryDetailsDbRow as any
|
||||
);
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
|
||||
|
||||
const result = await updateFeedbackRecordDirectory(
|
||||
mockDirectoryId,
|
||||
mockOrganizationId,
|
||||
{
|
||||
workspaceIds: [mockWorkspaceId1],
|
||||
},
|
||||
{ pauseConnectorsInRemovedWorkspaces: true }
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.connector.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
feedbackRecordDirectoryId: mockDirectoryId,
|
||||
workspaceId: { in: [mockWorkspaceId2] },
|
||||
},
|
||||
data: {
|
||||
status: "paused",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when directory does not exist (P2025)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
@@ -446,6 +476,85 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorkspaceFeedbackRecordDirectoryAccess", () => {
|
||||
test("returns one active assignment per workspace with directory details", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
workspaceId: mockWorkspaceId1,
|
||||
feedbackRecordDirectory: { id: mockDirectoryId, name: "Directory A" },
|
||||
},
|
||||
{
|
||||
workspaceId: mockWorkspaceId1,
|
||||
feedbackRecordDirectory: { id: "clj28r6va000409j3ep7h8xy2", name: "Directory B" },
|
||||
},
|
||||
{
|
||||
workspaceId: mockWorkspaceId2,
|
||||
feedbackRecordDirectory: { id: "clj28r6va000409j3ep7h8xy3", name: "Directory C" },
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
workspaceId: mockWorkspaceId1,
|
||||
feedbackRecordDirectoryId: mockDirectoryId,
|
||||
feedbackRecordDirectoryName: "Directory A",
|
||||
},
|
||||
{
|
||||
workspaceId: mockWorkspaceId2,
|
||||
feedbackRecordDirectoryId: "clj28r6va000409j3ep7h8xy3",
|
||||
feedbackRecordDirectoryName: "Directory C",
|
||||
},
|
||||
]);
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
feedbackRecordDirectory: {
|
||||
organizationId: mockOrganizationId,
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
feedbackRecordDirectory: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ workspaceId: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no active access assignments exist", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2010",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("re-throws unexpected errors", async () => {
|
||||
const error = new Error("Unexpected");
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrganizationIdFromDirectoryId", () => {
|
||||
test("returns organization ID for a valid directory", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TFeedbackRecordDirectory,
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TFeedbackRecordDirectoryUpdateInput,
|
||||
TWorkspaceFeedbackRecordDirectoryAccess,
|
||||
ZFeedbackRecordDirectoryUpdateInput,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
|
||||
@@ -99,6 +100,55 @@ export const getFeedbackRecordDirectoriesByWorkspaceId = reactCache(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Lists active feedback directory access assignments by workspace for an organization.
|
||||
* Each workspace appears once with the first active directory assignment found.
|
||||
*/
|
||||
export const getWorkspaceFeedbackRecordDirectoryAccess = reactCache(
|
||||
async (organizationId: string): Promise<TWorkspaceFeedbackRecordDirectoryAccess[]> => {
|
||||
validateInputs([organizationId, ZId]);
|
||||
try {
|
||||
const rows = await prisma.feedbackRecordDirectoryWorkspace.findMany({
|
||||
where: {
|
||||
feedbackRecordDirectory: {
|
||||
organizationId,
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
feedbackRecordDirectory: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ workspaceId: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
const accessByWorkspaceId = new Map<string, TWorkspaceFeedbackRecordDirectoryAccess>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!accessByWorkspaceId.has(row.workspaceId)) {
|
||||
accessByWorkspaceId.set(row.workspaceId, {
|
||||
workspaceId: row.workspaceId,
|
||||
feedbackRecordDirectoryId: row.feedbackRecordDirectory.id,
|
||||
feedbackRecordDirectoryName: row.feedbackRecordDirectory.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(accessByWorkspaceId.values());
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getFeedbackRecordDirectoryDetails = reactCache(
|
||||
async (directoryId: string): Promise<TFeedbackRecordDirectoryDetails | null> => {
|
||||
validateInputs([directoryId, ZId]);
|
||||
@@ -238,7 +288,10 @@ const buildWorkspaceAssignmentPayload = async (
|
||||
workspaceIds: string[],
|
||||
organizationId: string,
|
||||
currentWorkspaceIds: string[]
|
||||
): Promise<Prisma.FeedbackRecordDirectoryWorkspaceUpdateManyWithoutFeedbackRecordDirectoryNestedInput> => {
|
||||
): Promise<{
|
||||
payload: Prisma.FeedbackRecordDirectoryWorkspaceUpdateManyWithoutFeedbackRecordDirectoryNestedInput;
|
||||
deletedWorkspaceIds: string[];
|
||||
}> => {
|
||||
if (workspaceIds.length > 0) {
|
||||
const orgWorkspacesCount = await prismaClient.workspace.count({
|
||||
where: {
|
||||
@@ -254,22 +307,94 @@ const buildWorkspaceAssignmentPayload = async (
|
||||
const deletedWorkspaceIds = currentWorkspaceIds.filter((id) => !workspaceIds.includes(id));
|
||||
|
||||
return {
|
||||
deleteMany: {
|
||||
workspaceId: { in: deletedWorkspaceIds },
|
||||
},
|
||||
upsert: workspaceIds.map((workspaceId) => ({
|
||||
where: {
|
||||
feedbackRecordDirectoryId_workspaceId: {
|
||||
feedbackRecordDirectoryId: directoryId,
|
||||
workspaceId,
|
||||
},
|
||||
payload: {
|
||||
deleteMany: {
|
||||
workspaceId: { in: deletedWorkspaceIds },
|
||||
},
|
||||
update: {},
|
||||
create: { workspaceId },
|
||||
})),
|
||||
upsert: workspaceIds.map((workspaceId) => ({
|
||||
where: {
|
||||
feedbackRecordDirectoryId_workspaceId: {
|
||||
feedbackRecordDirectoryId: directoryId,
|
||||
workspaceId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: { workspaceId },
|
||||
})),
|
||||
},
|
||||
deletedWorkspaceIds,
|
||||
};
|
||||
};
|
||||
|
||||
interface UpdateFeedbackRecordDirectoryOptions {
|
||||
pauseConnectorsInRemovedWorkspaces?: boolean;
|
||||
}
|
||||
|
||||
const getArchiveUpdate = async (
|
||||
directoryId: string,
|
||||
isArchived: boolean | undefined
|
||||
): Promise<Pick<Prisma.FeedbackRecordDirectoryUpdateInput, "isArchived">> => {
|
||||
if (isArchived === true) {
|
||||
const connectorCount = await prisma.connector.count({
|
||||
where: { feedbackRecordDirectoryId: directoryId },
|
||||
});
|
||||
if (connectorCount > 0) {
|
||||
throw new InvalidInputError("DIRECTORY_HAS_CONNECTORS");
|
||||
}
|
||||
return { isArchived: true };
|
||||
}
|
||||
|
||||
if (isArchived === false) {
|
||||
return { isArchived: false };
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const getWorkspaceAssignmentUpdate = async (
|
||||
directoryId: string,
|
||||
organizationId: string,
|
||||
workspaceIds: string[] | undefined
|
||||
): Promise<{
|
||||
workspaces?: Prisma.FeedbackRecordDirectoryWorkspaceUpdateManyWithoutFeedbackRecordDirectoryNestedInput;
|
||||
removedWorkspaceIds: string[];
|
||||
}> => {
|
||||
if (workspaceIds === undefined) {
|
||||
return { removedWorkspaceIds: [] };
|
||||
}
|
||||
|
||||
const currentDetails = await getFeedbackRecordDirectoryDetails(directoryId);
|
||||
const currentWorkspaceIds = currentDetails?.workspaces.map((workspace) => workspace.workspaceId) ?? [];
|
||||
const assignmentPayload = await buildWorkspaceAssignmentPayload(
|
||||
prisma,
|
||||
directoryId,
|
||||
workspaceIds,
|
||||
organizationId,
|
||||
currentWorkspaceIds
|
||||
);
|
||||
|
||||
return {
|
||||
workspaces: assignmentPayload.payload,
|
||||
removedWorkspaceIds: assignmentPayload.deletedWorkspaceIds,
|
||||
};
|
||||
};
|
||||
|
||||
const pauseConnectorsInWorkspaces = async (directoryId: string, workspaceIds: string[]): Promise<void> => {
|
||||
if (workspaceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.connector.updateMany({
|
||||
where: {
|
||||
feedbackRecordDirectoryId: directoryId,
|
||||
workspaceId: { in: workspaceIds },
|
||||
},
|
||||
data: {
|
||||
status: "paused",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a feedback record directory. Supports partial updates for name, workspace
|
||||
* assignments, and archive status.
|
||||
@@ -291,49 +416,36 @@ const buildWorkspaceAssignmentPayload = async (
|
||||
export const updateFeedbackRecordDirectory = async (
|
||||
directoryId: string,
|
||||
organizationId: string,
|
||||
data: TFeedbackRecordDirectoryUpdateInput
|
||||
data: TFeedbackRecordDirectoryUpdateInput,
|
||||
options?: UpdateFeedbackRecordDirectoryOptions
|
||||
): Promise<boolean> => {
|
||||
validateInputs([directoryId, ZId], [organizationId, ZId], [data, ZFeedbackRecordDirectoryUpdateInput]);
|
||||
|
||||
try {
|
||||
const { name, workspaceIds, isArchived } = data;
|
||||
|
||||
const payload: Prisma.FeedbackRecordDirectoryUpdateInput = {};
|
||||
const archiveUpdate = await getArchiveUpdate(directoryId, isArchived);
|
||||
const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate(
|
||||
directoryId,
|
||||
organizationId,
|
||||
workspaceIds
|
||||
);
|
||||
|
||||
if (name !== undefined) {
|
||||
payload.name = name;
|
||||
}
|
||||
|
||||
if (isArchived === true) {
|
||||
const connectorCount = await prisma.connector.count({
|
||||
where: { feedbackRecordDirectoryId: directoryId },
|
||||
});
|
||||
if (connectorCount > 0) {
|
||||
throw new InvalidInputError("DIRECTORY_HAS_CONNECTORS");
|
||||
}
|
||||
payload.isArchived = true;
|
||||
} else if (isArchived === false) {
|
||||
payload.isArchived = false;
|
||||
}
|
||||
|
||||
if (workspaceIds !== undefined) {
|
||||
const currentDetails = await getFeedbackRecordDirectoryDetails(directoryId);
|
||||
const currentWorkspaceIds = currentDetails?.workspaces.map((p) => p.workspaceId) ?? [];
|
||||
|
||||
payload.workspaces = await buildWorkspaceAssignmentPayload(
|
||||
prisma,
|
||||
directoryId,
|
||||
workspaceIds,
|
||||
organizationId,
|
||||
currentWorkspaceIds
|
||||
);
|
||||
}
|
||||
const payload: Prisma.FeedbackRecordDirectoryUpdateInput = {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...archiveUpdate,
|
||||
...(workspaceAssignmentUpdate.workspaces ? { workspaces: workspaceAssignmentUpdate.workspaces } : {}),
|
||||
};
|
||||
|
||||
await prisma.feedbackRecordDirectory.update({
|
||||
where: { id: directoryId },
|
||||
data: payload,
|
||||
});
|
||||
|
||||
if (options?.pauseConnectorsInRemovedWorkspaces) {
|
||||
await pauseConnectorsInWorkspaces(directoryId, workspaceAssignmentUpdate.removedWorkspaceIds);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -35,6 +35,12 @@ export const ZFeedbackRecordDirectoryDetails = z.object({
|
||||
|
||||
export type TFeedbackRecordDirectoryDetails = z.infer<typeof ZFeedbackRecordDirectoryDetails>;
|
||||
|
||||
export interface TWorkspaceFeedbackRecordDirectoryAccess {
|
||||
workspaceId: string;
|
||||
feedbackRecordDirectoryId: string;
|
||||
feedbackRecordDirectoryName: string;
|
||||
}
|
||||
|
||||
export const ZFeedbackRecordDirectoryCreateInput = z.object({
|
||||
name: z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED"),
|
||||
workspaceIds: z.array(ZId).optional(),
|
||||
|
||||
@@ -36,8 +36,6 @@ describe("updateWorkspaceBranding", () => {
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: { light: "#64748b" },
|
||||
questionColor: { light: "#2b2524" },
|
||||
inputColor: { light: "#ffffff" },
|
||||
inputBorderColor: { light: "#cbd5e1" },
|
||||
cardBackgroundColor: { light: "#ffffff" },
|
||||
cardBorderColor: { light: "#f8fafc" },
|
||||
|
||||
@@ -455,10 +455,10 @@ function EmailTemplateWrapper({
|
||||
const colors = {
|
||||
"brand-color": styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
"card-bg-color": styling.cardBackgroundColor?.light ?? COLOR_DEFAULTS.cardBackgroundColor,
|
||||
"input-color": styling.inputColor?.light ?? COLOR_DEFAULTS.inputColor,
|
||||
"input-color": styling.inputBgColor?.light ?? COLOR_DEFAULTS.inputBgColor,
|
||||
"input-border-color": styling.inputBorderColor?.light ?? COLOR_DEFAULTS.inputBorderColor,
|
||||
"card-border-color": styling.cardBorderColor?.light ?? COLOR_DEFAULTS.cardBorderColor,
|
||||
"question-color": styling.questionColor?.light ?? COLOR_DEFAULTS.questionColor,
|
||||
"question-color": styling.elementHeadlineColor?.light ?? COLOR_DEFAULTS.elementHeadlineColor,
|
||||
};
|
||||
|
||||
if (isLight(colors["question-color"])) {
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { createEmailChangeToken, createInviteToken, createToken, createTokenForLinkSurvey } from "@/lib/jwt";
|
||||
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
|
||||
import { getElementResponseMapping } from "@/lib/responses";
|
||||
import { TElementResponseMappingSurvey, getElementResponseMapping } from "@/lib/responses";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { buildVerificationLinks } from "@/modules/auth/lib/verification-links";
|
||||
import { resolveStorageUrl } from "@/modules/storage/utils";
|
||||
@@ -62,6 +62,9 @@ interface SendEmailDataProps {
|
||||
html: string;
|
||||
}
|
||||
|
||||
export type TResponseFinishedEmailSurvey = TElementResponseMappingSurvey &
|
||||
Pick<TSurvey, "id" | "name" | "variables" | "hiddenFields">;
|
||||
|
||||
export const sendEmail = async (emailData: SendEmailDataProps): Promise<boolean> => {
|
||||
if (!IS_SMTP_CONFIGURED) {
|
||||
logger.info("SMTP is not configured, skipping email sending");
|
||||
@@ -236,7 +239,7 @@ export const sendResponseFinishedEmail = async (
|
||||
email: string,
|
||||
locale: TUserLocale,
|
||||
workspaceId: string,
|
||||
survey: TSurvey,
|
||||
survey: TResponseFinishedEmailSurvey,
|
||||
response: TResponse,
|
||||
responseCount: number
|
||||
): Promise<void> => {
|
||||
|
||||
@@ -3,7 +3,9 @@ export {
|
||||
createFeedbackRecord,
|
||||
createFeedbackRecordsBatch,
|
||||
listFeedbackRecords,
|
||||
type CreateFeedbackRecordResult,
|
||||
retrieveFeedbackRecord,
|
||||
updateFeedbackRecord,
|
||||
type HubFeedbackRecordResult,
|
||||
type ListFeedbackRecordsResult,
|
||||
} from "./service";
|
||||
export type {
|
||||
@@ -11,4 +13,5 @@ export type {
|
||||
FeedbackRecordData,
|
||||
FeedbackRecordListParams,
|
||||
FeedbackRecordListResponse,
|
||||
FeedbackRecordUpdateParams,
|
||||
} from "./types";
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { createFeedbackRecord, createFeedbackRecordsBatch, listFeedbackRecords } from "./service";
|
||||
import {
|
||||
createFeedbackRecord,
|
||||
createFeedbackRecordsBatch,
|
||||
listFeedbackRecords,
|
||||
retrieveFeedbackRecord,
|
||||
updateFeedbackRecord,
|
||||
} from "./service";
|
||||
import type { FeedbackRecordCreateParams } from "./types";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
@@ -15,7 +21,7 @@ const { getHubClient } = await import("./hub-client");
|
||||
const sampleInput: FeedbackRecordCreateParams = {
|
||||
field_id: "el-1",
|
||||
field_type: "rating",
|
||||
source_type: "formbricks",
|
||||
source_type: "formbricks_survey",
|
||||
source_id: "survey-1",
|
||||
source_name: "Test Survey",
|
||||
field_label: "Question?",
|
||||
@@ -121,6 +127,62 @@ describe("hub service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("retrieveFeedbackRecord", () => {
|
||||
test("returns error when client is null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
const result = await retrieveFeedbackRecord("rec-1");
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error?.message).toContain("HUB_API_KEY");
|
||||
});
|
||||
|
||||
test("returns data on success", async () => {
|
||||
const record = { id: "rec-1", field_id: "f1" };
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { retrieve: vi.fn().mockResolvedValue(record) },
|
||||
} as any);
|
||||
const result = await retrieveFeedbackRecord("rec-1");
|
||||
expect(result.data).toEqual(record);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
test("returns error on throw", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { retrieve: vi.fn().mockRejectedValue(new Error("Not found")) },
|
||||
} as any);
|
||||
const result = await retrieveFeedbackRecord("rec-1");
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({ message: "Not found" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateFeedbackRecord", () => {
|
||||
test("returns error when client is null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
const result = await updateFeedbackRecord("rec-1", { value_text: "new" });
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error?.message).toContain("HUB_API_KEY");
|
||||
});
|
||||
|
||||
test("returns data on success", async () => {
|
||||
const updated = { id: "rec-1", value_text: "new" };
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { update: vi.fn().mockResolvedValue(updated) },
|
||||
} as any);
|
||||
const result = await updateFeedbackRecord("rec-1", { value_text: "new" });
|
||||
expect(result.data).toEqual(updated);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
test("returns error on throw", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { update: vi.fn().mockRejectedValue(new Error("Forbidden")) },
|
||||
} as any);
|
||||
const result = await updateFeedbackRecord("rec-1", { value_text: "new" });
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({ message: "Forbidden" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFeedbackRecordsBatch", () => {
|
||||
test("returns all errors when getHubClient returns null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
|
||||
@@ -7,11 +7,14 @@ import type {
|
||||
FeedbackRecordData,
|
||||
FeedbackRecordListParams,
|
||||
FeedbackRecordListResponse,
|
||||
FeedbackRecordUpdateParams,
|
||||
} from "./types";
|
||||
|
||||
export type CreateFeedbackRecordResult = {
|
||||
type HubError = { status: number; message: string; detail: string };
|
||||
|
||||
export type HubFeedbackRecordResult = {
|
||||
data: FeedbackRecordData | null;
|
||||
error: { status: number; message: string; detail: string } | null;
|
||||
error: HubError | null;
|
||||
};
|
||||
|
||||
const NO_CONFIG_ERROR = {
|
||||
@@ -20,7 +23,7 @@ const NO_CONFIG_ERROR = {
|
||||
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
} as const;
|
||||
|
||||
const createResultFromError = (err: unknown): CreateFeedbackRecordResult => {
|
||||
const createResultFromError = (err: unknown): HubFeedbackRecordResult => {
|
||||
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
@@ -32,7 +35,7 @@ const createResultFromError = (err: unknown): CreateFeedbackRecordResult => {
|
||||
*/
|
||||
export const createFeedbackRecord = async (
|
||||
input: FeedbackRecordCreateParams
|
||||
): Promise<CreateFeedbackRecordResult> => {
|
||||
): Promise<HubFeedbackRecordResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
@@ -46,9 +49,48 @@ export const createFeedbackRecord = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve a single feedback record from the Hub by id.
|
||||
*/
|
||||
export const retrieveFeedbackRecord = async (id: string): Promise<HubFeedbackRecordResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await client.feedbackRecords.retrieve(id);
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, id }, "Hub: retrieveFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a single feedback record in the Hub by id.
|
||||
*/
|
||||
export const updateFeedbackRecord = async (
|
||||
id: string,
|
||||
input: FeedbackRecordUpdateParams
|
||||
): Promise<HubFeedbackRecordResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await client.feedbackRecords.update(id, input);
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, id }, "Hub: updateFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
}
|
||||
};
|
||||
|
||||
export type ListFeedbackRecordsResult = {
|
||||
data: FeedbackRecordListResponse | null;
|
||||
error: { status: number; message: string; detail: string } | null;
|
||||
error: HubError | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -78,7 +120,7 @@ export const listFeedbackRecords = async (
|
||||
*/
|
||||
export const createFeedbackRecordsBatch = async (
|
||||
inputs: FeedbackRecordCreateParams[]
|
||||
): Promise<{ results: CreateFeedbackRecordResult[] }> => {
|
||||
): Promise<{ results: HubFeedbackRecordResult[] }> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return {
|
||||
@@ -90,7 +132,7 @@ export const createFeedbackRecordsBatch = async (
|
||||
inputs.map(async (input) => {
|
||||
try {
|
||||
const data = await client.feedbackRecords.create(input);
|
||||
return { data, error: null as CreateFeedbackRecordResult["error"] };
|
||||
return { data, error: null as HubFeedbackRecordResult["error"] };
|
||||
} catch (err) {
|
||||
logger.warn({ err, fieldId: input.field_id }, "Hub: createFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
|
||||
@@ -4,3 +4,4 @@ export type FeedbackRecordCreateParams = FormbricksHub.FeedbackRecordCreateParam
|
||||
export type FeedbackRecordData = FormbricksHub.FeedbackRecordData;
|
||||
export type FeedbackRecordListParams = FormbricksHub.FeedbackRecordListParams;
|
||||
export type FeedbackRecordListResponse = FormbricksHub.FeedbackRecordListResponse;
|
||||
export type FeedbackRecordUpdateParams = FormbricksHub.FeedbackRecordUpdateParams;
|
||||
|
||||
@@ -26,6 +26,7 @@ type TIntegrationPipelineData = {
|
||||
response: Pick<TResponse, "createdAt" | "data" | "meta" | "variables">;
|
||||
surveyId: string;
|
||||
};
|
||||
type TPipelineIntegrationSurvey = Pick<TSurvey, "blocks" | "hiddenFields" | "variables" | "name">;
|
||||
|
||||
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
||||
let result: string[] = [];
|
||||
@@ -67,7 +68,7 @@ const toIntegrationFieldSelection = (config: {
|
||||
const processDataForIntegration = async (
|
||||
integrationType: TIntegrationType,
|
||||
data: TIntegrationPipelineData,
|
||||
survey: TSurvey,
|
||||
survey: TPipelineIntegrationSurvey,
|
||||
selection: TIntegrationFieldSelection
|
||||
): Promise<{
|
||||
responses: string[];
|
||||
@@ -108,7 +109,7 @@ const processDataForIntegration = async (
|
||||
export const handleIntegrations = async (
|
||||
integrations: TIntegration[],
|
||||
data: TIntegrationPipelineData,
|
||||
survey: TSurvey
|
||||
survey: TPipelineIntegrationSurvey
|
||||
) => {
|
||||
for (const integration of integrations) {
|
||||
switch (integration.type) {
|
||||
@@ -155,7 +156,7 @@ export const handleIntegrations = async (
|
||||
const handleAirtableIntegration = async (
|
||||
integration: TIntegrationAirtable,
|
||||
data: TIntegrationPipelineData,
|
||||
survey: TSurvey
|
||||
survey: TPipelineIntegrationSurvey
|
||||
): Promise<Result<void, Error>> => {
|
||||
try {
|
||||
if (integration.config.data.length > 0) {
|
||||
@@ -187,7 +188,7 @@ const handleAirtableIntegration = async (
|
||||
const handleGoogleSheetsIntegration = async (
|
||||
integration: TIntegrationGoogleSheets,
|
||||
data: TIntegrationPipelineData,
|
||||
survey: TSurvey
|
||||
survey: TPipelineIntegrationSurvey
|
||||
): Promise<Result<void, Error>> => {
|
||||
try {
|
||||
if (integration.config.data.length > 0) {
|
||||
@@ -224,7 +225,7 @@ const handleGoogleSheetsIntegration = async (
|
||||
const handleSlackIntegration = async (
|
||||
integration: TIntegrationSlack,
|
||||
data: TIntegrationPipelineData,
|
||||
survey: TSurvey
|
||||
survey: TPipelineIntegrationSurvey
|
||||
): Promise<Result<void, Error>> => {
|
||||
try {
|
||||
if (integration.config.data.length > 0) {
|
||||
@@ -300,7 +301,7 @@ const extractResponses = async (
|
||||
integrationType: TIntegrationType,
|
||||
pipelineData: TIntegrationPipelineData,
|
||||
elementIds: string[],
|
||||
survey: TSurvey
|
||||
survey: TPipelineIntegrationSurvey
|
||||
): Promise<{
|
||||
responses: string[];
|
||||
elements: string[];
|
||||
@@ -345,7 +346,7 @@ const extractResponses = async (
|
||||
const handleNotionIntegration = async (
|
||||
integration: TIntegrationNotion,
|
||||
data: TIntegrationPipelineData,
|
||||
surveyData: TSurvey
|
||||
surveyData: TPipelineIntegrationSurvey
|
||||
): Promise<Result<void, Error>> => {
|
||||
try {
|
||||
if (integration.config.data.length > 0) {
|
||||
@@ -372,7 +373,7 @@ const handleNotionIntegration = async (
|
||||
const buildNotionPayloadProperties = (
|
||||
mapping: TIntegrationNotionConfigData["mapping"],
|
||||
data: TIntegrationPipelineData,
|
||||
surveyData: TSurvey
|
||||
surveyData: TPipelineIntegrationSurvey
|
||||
) => {
|
||||
const properties: any = {};
|
||||
const normalizedResponses = { ...data.response.data };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UnrecoverableError } from "bullmq";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TResponsePipelineJobData } from "@formbricks/jobs";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
@@ -6,12 +5,15 @@ import { processResponsePipelineJob } from "./process-response-pipeline-job";
|
||||
|
||||
const {
|
||||
mockFetch,
|
||||
mockCaptureSurveyResponsePostHogEvent,
|
||||
mockGetIntegrations,
|
||||
mockGetOrganizationByWorkspaceId,
|
||||
mockGetResponseCountBySurveyId,
|
||||
mockGetSurvey,
|
||||
mockHandleIntegrations,
|
||||
mockLoggerError,
|
||||
mockLoggerWarn,
|
||||
mockPrismaOrganizationFindFirst,
|
||||
mockPrismaSurveyFindUnique,
|
||||
mockPrismaSurveyUpdate,
|
||||
mockPrismaUserFindMany,
|
||||
mockPrismaWebhookFindMany,
|
||||
mockQueueAuditEventWithoutRequest,
|
||||
@@ -19,29 +21,41 @@ const {
|
||||
mockSendFollowUpsForResponse,
|
||||
mockSendResponseFinishedEmail,
|
||||
mockSendTelemetryEvents,
|
||||
mockUpdateSurvey,
|
||||
mockValidateWebhookUrl,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFetch: vi.fn(),
|
||||
mockGetIntegrations: vi.fn(),
|
||||
mockGetOrganizationByWorkspaceId: vi.fn(),
|
||||
mockGetResponseCountBySurveyId: vi.fn(),
|
||||
mockGetSurvey: vi.fn(),
|
||||
mockHandleIntegrations: vi.fn(),
|
||||
mockLoggerError: vi.fn(),
|
||||
mockPrismaUserFindMany: vi.fn(),
|
||||
mockPrismaWebhookFindMany: vi.fn(),
|
||||
mockQueueAuditEventWithoutRequest: vi.fn(),
|
||||
mockRecordResponseCreatedMeterEvent: vi.fn(),
|
||||
mockSendFollowUpsForResponse: vi.fn(),
|
||||
mockSendResponseFinishedEmail: vi.fn(),
|
||||
mockSendTelemetryEvents: vi.fn(),
|
||||
mockUpdateSurvey: vi.fn(),
|
||||
mockValidateWebhookUrl: vi.fn(),
|
||||
}));
|
||||
} = vi.hoisted(() => {
|
||||
process.env.HUB_API_URL ??= "https://hub.test";
|
||||
|
||||
return {
|
||||
mockFetch: vi.fn(),
|
||||
mockCaptureSurveyResponsePostHogEvent: vi.fn(),
|
||||
mockGetIntegrations: vi.fn(),
|
||||
mockGetResponseCountBySurveyId: vi.fn(),
|
||||
mockHandleIntegrations: vi.fn(),
|
||||
mockLoggerError: vi.fn(),
|
||||
mockLoggerWarn: vi.fn(),
|
||||
mockPrismaOrganizationFindFirst: vi.fn(),
|
||||
mockPrismaSurveyFindUnique: vi.fn(),
|
||||
mockPrismaSurveyUpdate: vi.fn(),
|
||||
mockPrismaUserFindMany: vi.fn(),
|
||||
mockPrismaWebhookFindMany: vi.fn(),
|
||||
mockQueueAuditEventWithoutRequest: vi.fn(),
|
||||
mockRecordResponseCreatedMeterEvent: vi.fn(),
|
||||
mockSendFollowUpsForResponse: vi.fn(),
|
||||
mockSendResponseFinishedEmail: vi.fn(),
|
||||
mockSendTelemetryEvents: vi.fn(),
|
||||
mockValidateWebhookUrl: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
organization: {
|
||||
findFirst: mockPrismaOrganizationFindFirst,
|
||||
},
|
||||
survey: {
|
||||
findUnique: mockPrismaSurveyFindUnique,
|
||||
update: mockPrismaSurveyUpdate,
|
||||
},
|
||||
webhook: {
|
||||
findMany: mockPrismaWebhookFindMany,
|
||||
},
|
||||
@@ -51,6 +65,24 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/jobs", () => ({
|
||||
UnrecoverableError: class UnrecoverableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "UnrecoverableError";
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(import("@/lib/constants"), async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
POSTHOG_KEY: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./handle-integrations", () => ({
|
||||
handleIntegrations: mockHandleIntegrations,
|
||||
}));
|
||||
@@ -59,10 +91,6 @@ vi.mock("./telemetry", () => ({
|
||||
sendTelemetryEvents: mockSendTelemetryEvents,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationByWorkspaceId: mockGetOrganizationByWorkspaceId,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integration/service", () => ({
|
||||
getIntegrations: mockGetIntegrations,
|
||||
}));
|
||||
@@ -71,9 +99,8 @@ vi.mock("@/lib/response/service", () => ({
|
||||
getResponseCountBySurveyId: mockGetResponseCountBySurveyId,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: mockGetSurvey,
|
||||
updateSurvey: mockUpdateSurvey,
|
||||
vi.mock("./posthog", () => ({
|
||||
captureSurveyResponsePostHogEvent: mockCaptureSurveyResponsePostHogEvent,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate-webhook-url", () => ({
|
||||
@@ -101,12 +128,12 @@ vi.mock("@formbricks/logger", () => ({
|
||||
debug: vi.fn(),
|
||||
error: mockLoggerError,
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
warn: mockLoggerWarn,
|
||||
},
|
||||
}));
|
||||
|
||||
const baseData: TResponsePipelineJobData = {
|
||||
workspaceId: "ws_123",
|
||||
workspaceId: "workspace_123",
|
||||
event: "responseCreated",
|
||||
response: {
|
||||
contact: null,
|
||||
@@ -149,14 +176,20 @@ const organization = {
|
||||
};
|
||||
|
||||
const survey = {
|
||||
blocks: [],
|
||||
autoComplete: null,
|
||||
createdAt: new Date("2026-04-01T10:00:00.000Z"),
|
||||
followUps: [],
|
||||
hiddenFields: {
|
||||
fieldIds: [],
|
||||
},
|
||||
id: "survey_123",
|
||||
languages: [],
|
||||
name: "Test survey",
|
||||
status: "inProgress",
|
||||
type: "app",
|
||||
updatedAt: new Date("2026-04-01T10:00:00.000Z"),
|
||||
variables: [],
|
||||
workspaceId: "workspace_123",
|
||||
};
|
||||
|
||||
@@ -165,8 +198,8 @@ const originalFetch = global.fetch;
|
||||
describe("processResponsePipelineJob", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetOrganizationByWorkspaceId.mockResolvedValue(organization);
|
||||
mockGetSurvey.mockResolvedValue(survey);
|
||||
mockPrismaOrganizationFindFirst.mockResolvedValue(organization);
|
||||
mockPrismaSurveyFindUnique.mockResolvedValue(survey);
|
||||
mockGetIntegrations.mockResolvedValue([]);
|
||||
mockPrismaWebhookFindMany.mockResolvedValue([]);
|
||||
mockPrismaUserFindMany.mockResolvedValue([]);
|
||||
@@ -178,7 +211,7 @@ describe("processResponsePipelineJob", () => {
|
||||
mockSendResponseFinishedEmail.mockResolvedValue(undefined);
|
||||
mockSendFollowUpsForResponse.mockResolvedValue({ ok: true, data: [] });
|
||||
mockSendTelemetryEvents.mockResolvedValue(undefined);
|
||||
mockUpdateSurvey.mockResolvedValue(undefined);
|
||||
mockPrismaSurveyUpdate.mockResolvedValue(undefined);
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -252,7 +285,7 @@ describe("processResponsePipelineJob", () => {
|
||||
|
||||
test("processes responseFinished jobs and preserves legacy side effects", async () => {
|
||||
mockGetIntegrations.mockResolvedValue([{ id: "integration_123", type: "slack" }]);
|
||||
mockGetSurvey.mockResolvedValue({
|
||||
mockPrismaSurveyFindUnique.mockResolvedValue({
|
||||
...survey,
|
||||
autoComplete: 1,
|
||||
followUps: [{ id: "followup_123" }],
|
||||
@@ -290,6 +323,59 @@ describe("processResponsePipelineJob", () => {
|
||||
followUps: [{ id: "followup_123" }],
|
||||
}
|
||||
);
|
||||
expect(mockPrismaUserFindMany).toHaveBeenCalledWith({
|
||||
select: { email: true, locale: true },
|
||||
where: {
|
||||
memberships: {
|
||||
some: {
|
||||
organization: {
|
||||
workspaces: {
|
||||
some: {
|
||||
id: "workspace_123",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
notificationSettings: {
|
||||
equals: true,
|
||||
path: ["alert", "survey_123"],
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
memberships: {
|
||||
some: {
|
||||
role: {
|
||||
in: ["owner", "manager"],
|
||||
},
|
||||
organization: {
|
||||
workspaces: {
|
||||
some: {
|
||||
id: "workspace_123",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
teamUsers: {
|
||||
some: {
|
||||
team: {
|
||||
workspaceTeams: {
|
||||
some: {
|
||||
workspace: {
|
||||
id: "workspace_123",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(mockSendFollowUpsForResponse).toHaveBeenCalledWith("response_123");
|
||||
expect(mockSendResponseFinishedEmail).toHaveBeenCalledWith(
|
||||
"owner@example.com",
|
||||
@@ -299,12 +385,14 @@ describe("processResponsePipelineJob", () => {
|
||||
baseData.response,
|
||||
1
|
||||
);
|
||||
expect(mockUpdateSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "survey_123",
|
||||
expect(mockPrismaSurveyUpdate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
status: "completed",
|
||||
})
|
||||
);
|
||||
},
|
||||
where: {
|
||||
id: "survey_123",
|
||||
},
|
||||
});
|
||||
expect(mockQueueAuditEventWithoutRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "updated",
|
||||
@@ -333,8 +421,8 @@ describe("processResponsePipelineJob", () => {
|
||||
message: "not allowed",
|
||||
},
|
||||
});
|
||||
mockUpdateSurvey.mockRejectedValue(new Error("update failed"));
|
||||
mockGetSurvey.mockResolvedValue({
|
||||
mockPrismaSurveyUpdate.mockRejectedValue(new Error("update failed"));
|
||||
mockPrismaSurveyFindUnique.mockResolvedValue({
|
||||
...survey,
|
||||
autoComplete: 1,
|
||||
followUps: [{ id: "followup_123" }],
|
||||
@@ -375,7 +463,7 @@ describe("processResponsePipelineJob", () => {
|
||||
test("fails the job before the final attempt when webhook delivery fails", async () => {
|
||||
const webhookError = new Error("invalid webhook");
|
||||
mockGetIntegrations.mockResolvedValue([{ id: "integration_123", type: "slack" }]);
|
||||
mockGetSurvey.mockResolvedValue({
|
||||
mockPrismaSurveyFindUnique.mockResolvedValue({
|
||||
...survey,
|
||||
autoComplete: 1,
|
||||
followUps: [{ id: "followup_123" }],
|
||||
@@ -408,7 +496,7 @@ describe("processResponsePipelineJob", () => {
|
||||
expect(mockHandleIntegrations).not.toHaveBeenCalled();
|
||||
expect(mockSendFollowUpsForResponse).not.toHaveBeenCalled();
|
||||
expect(mockSendResponseFinishedEmail).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSurvey).not.toHaveBeenCalled();
|
||||
expect(mockPrismaSurveyUpdate).not.toHaveBeenCalled();
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: webhookError,
|
||||
@@ -421,7 +509,7 @@ describe("processResponsePipelineJob", () => {
|
||||
test("continues responseFinished side effects on the final webhook attempt", async () => {
|
||||
const webhookError = new Error("invalid webhook");
|
||||
mockGetIntegrations.mockResolvedValue([{ id: "integration_123", type: "slack" }]);
|
||||
mockGetSurvey.mockResolvedValue({
|
||||
mockPrismaSurveyFindUnique.mockResolvedValue({
|
||||
...survey,
|
||||
autoComplete: 1,
|
||||
followUps: [{ id: "followup_123" }],
|
||||
@@ -454,7 +542,7 @@ describe("processResponsePipelineJob", () => {
|
||||
expect(mockHandleIntegrations).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendFollowUpsForResponse).toHaveBeenCalledWith("response_123");
|
||||
expect(mockSendResponseFinishedEmail).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateSurvey).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrismaSurveyUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attempt: 3,
|
||||
@@ -492,7 +580,7 @@ describe("processResponsePipelineJob", () => {
|
||||
|
||||
test("does not retry a successful webhook when later responseFinished side effects fail", async () => {
|
||||
const auditError = new Error("audit offline");
|
||||
mockGetSurvey.mockResolvedValue({
|
||||
mockPrismaSurveyFindUnique.mockResolvedValue({
|
||||
...survey,
|
||||
autoComplete: 1,
|
||||
});
|
||||
@@ -655,16 +743,16 @@ describe("processResponsePipelineJob", () => {
|
||||
});
|
||||
|
||||
test("fails fast when the workspace organization cannot be found", async () => {
|
||||
mockGetOrganizationByWorkspaceId.mockResolvedValue(null);
|
||||
mockPrismaOrganizationFindFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(processResponsePipelineJob(baseData, baseContext)).rejects.toThrow(
|
||||
new UnrecoverableError("Organization not found for workspace workspace_123")
|
||||
"Organization not found for workspace workspace_123"
|
||||
);
|
||||
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: "ws_123",
|
||||
err: expect.any(UnrecoverableError),
|
||||
workspaceId: "workspace_123",
|
||||
err: expect.any(Error),
|
||||
jobId: "job_123",
|
||||
responseId: "response_123",
|
||||
surveyId: "survey_123",
|
||||
@@ -672,4 +760,47 @@ describe("processResponsePipelineJob", () => {
|
||||
"Response pipeline job failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("fails fast when the survey cannot be found", async () => {
|
||||
mockPrismaSurveyFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(processResponsePipelineJob(baseData, baseContext)).rejects.toThrow(
|
||||
"Survey survey_123 not found"
|
||||
);
|
||||
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: "workspace_123",
|
||||
err: expect.any(Error),
|
||||
jobId: "job_123",
|
||||
responseId: "response_123",
|
||||
surveyId: "survey_123",
|
||||
}),
|
||||
"Response pipeline job failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("classifies database pool exhaustion as retryable and logs a warning", async () => {
|
||||
const poolExhaustionError = new Error("Timed out fetching a new connection from the connection pool");
|
||||
mockPrismaSurveyFindUnique.mockRejectedValue(poolExhaustionError);
|
||||
|
||||
await expect(processResponsePipelineJob(baseData, baseContext)).rejects.toThrow(poolExhaustionError);
|
||||
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: "workspace_123",
|
||||
err: poolExhaustionError,
|
||||
jobId: "job_123",
|
||||
responseId: "response_123",
|
||||
surveyId: "survey_123",
|
||||
}),
|
||||
"Response pipeline job hit database pool exhaustion and will be retried"
|
||||
);
|
||||
expect(mockLoggerError).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: poolExhaustionError,
|
||||
}),
|
||||
"Response pipeline job failed"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import "server-only";
|
||||
import { PipelineTriggers, type Webhook } from "@prisma/client";
|
||||
import { UnrecoverableError } from "bullmq";
|
||||
import { PipelineTriggers, Prisma, type Webhook } from "@prisma/client";
|
||||
import { createHash } from "node:crypto";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { JobHandler, TResponsePipelineJobData } from "@formbricks/jobs";
|
||||
import { type JobHandler, type TResponsePipelineJobData, UnrecoverableError } from "@formbricks/jobs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { type TUserLocale, ZUserLocale } from "@formbricks/types/user";
|
||||
import { POSTHOG_KEY } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { type TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
import { captureSurveyResponsePostHogEvent } from "@/modules/response-pipeline/lib/posthog";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
@@ -25,6 +25,69 @@ import { sendTelemetryEvents } from "./telemetry";
|
||||
const WEBHOOK_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_NOTIFICATION_LOCALE: TUserLocale = "en-US";
|
||||
|
||||
const pipelineOrganizationSelect = {
|
||||
id: true,
|
||||
billing: {
|
||||
select: {
|
||||
stripeCustomerId: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.OrganizationSelect;
|
||||
|
||||
const pipelineSurveySelect = {
|
||||
id: true,
|
||||
workspaceId: true,
|
||||
name: true,
|
||||
type: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
blocks: true,
|
||||
hiddenFields: true,
|
||||
variables: true,
|
||||
followUps: true,
|
||||
autoComplete: true,
|
||||
languages: {
|
||||
select: {
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
alias: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
workspaceId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.SurveySelect;
|
||||
|
||||
type TPipelineOrganization = Prisma.OrganizationGetPayload<{ select: typeof pipelineOrganizationSelect }>;
|
||||
type TPipelineSurvey = Prisma.SurveyGetPayload<{ select: typeof pipelineSurveySelect }>;
|
||||
|
||||
const getOrganizationForPipeline = async (workspaceId: string): Promise<TPipelineOrganization | null> =>
|
||||
prisma.organization.findFirst({
|
||||
where: {
|
||||
workspaces: {
|
||||
some: {
|
||||
id: workspaceId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: pipelineOrganizationSelect,
|
||||
});
|
||||
|
||||
const getSurveyForPipeline = async (surveyId: string): Promise<TPipelineSurvey | null> =>
|
||||
prisma.survey.findUnique({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
select: pipelineSurveySelect,
|
||||
});
|
||||
|
||||
const getPipelineLogContext = (
|
||||
data: TResponsePipelineJobData,
|
||||
context: Parameters<JobHandler<TResponsePipelineJobData>>[1]
|
||||
@@ -48,6 +111,20 @@ const toUserLocale = (locale: string): TUserLocale => {
|
||||
return parsedLocale.success ? parsedLocale.data : DEFAULT_NOTIFICATION_LOCALE;
|
||||
};
|
||||
|
||||
export const isPipelinePoolExhaustionError = (error: unknown): boolean => {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2024") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error instanceof DatabaseError || error instanceof Error) {
|
||||
return /Timed out fetching a new connection from the connection pool|connection pool timeout/i.test(
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const createWebhookMessageId = ({
|
||||
event,
|
||||
jobId,
|
||||
@@ -103,7 +180,7 @@ const createWebhookDeliveryTask = async ({
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
data: TResponsePipelineJobData;
|
||||
survey: Awaited<ReturnType<typeof getSurvey>>;
|
||||
survey: TPipelineSurvey;
|
||||
logContext: ReturnType<typeof getPipelineLogContext>;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
@@ -176,7 +253,7 @@ const deliverWebhooks = async ({
|
||||
}: {
|
||||
data: TResponsePipelineJobData;
|
||||
logContext: ReturnType<typeof getPipelineLogContext>;
|
||||
survey: NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
|
||||
survey: TPipelineSurvey;
|
||||
webhooks: Webhook[];
|
||||
}): Promise<void> => {
|
||||
const results = await Promise.allSettled(
|
||||
@@ -277,10 +354,17 @@ const getUsersWithNotifications = async ({
|
||||
OR: [
|
||||
{
|
||||
memberships: {
|
||||
every: {
|
||||
some: {
|
||||
role: {
|
||||
in: ["owner", "manager"],
|
||||
},
|
||||
organization: {
|
||||
workspaces: {
|
||||
some: {
|
||||
id: workspaceId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -332,7 +416,7 @@ const handleFollowUpsSafely = async ({
|
||||
}: {
|
||||
data: TResponsePipelineJobData;
|
||||
logContext: ReturnType<typeof getPipelineLogContext>;
|
||||
survey: NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
|
||||
survey: TPipelineSurvey;
|
||||
}): Promise<void> => {
|
||||
if (!survey.followUps?.length) {
|
||||
return;
|
||||
@@ -371,7 +455,7 @@ const sendNotificationEmailsSafely = async ({
|
||||
data: TResponsePipelineJobData;
|
||||
logContext: ReturnType<typeof getPipelineLogContext>;
|
||||
responseCount: number | null;
|
||||
survey: NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
|
||||
survey: TPipelineSurvey;
|
||||
usersWithNotifications: Array<{ email: string; locale: TUserLocale }>;
|
||||
workspaceId: string;
|
||||
}): Promise<void> => {
|
||||
@@ -423,7 +507,7 @@ const handleSurveyAutoCompleteSafely = async ({
|
||||
logContext: ReturnType<typeof getPipelineLogContext>;
|
||||
organizationId: string;
|
||||
responseCount: number | null;
|
||||
survey: NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
|
||||
survey: TPipelineSurvey;
|
||||
}): Promise<void> => {
|
||||
if (responseCount === null) {
|
||||
if (survey.autoComplete) {
|
||||
@@ -446,9 +530,13 @@ const handleSurveyAutoCompleteSafely = async ({
|
||||
let logStatus: TAuditStatus = "success";
|
||||
|
||||
try {
|
||||
await updateSurvey({
|
||||
...survey,
|
||||
status: "completed",
|
||||
await prisma.survey.update({
|
||||
where: {
|
||||
id: survey.id,
|
||||
},
|
||||
data: {
|
||||
status: "completed",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logStatus = "failure";
|
||||
@@ -500,14 +588,21 @@ const runResponseFinishedSideEffects = async ({
|
||||
data: TResponsePipelineJobData;
|
||||
logContext: ReturnType<typeof getPipelineLogContext>;
|
||||
organizationId: string;
|
||||
survey: NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
|
||||
survey: TPipelineSurvey;
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
const { integrations, responseCount } = await loadResponseFinishedContext({
|
||||
data,
|
||||
logContext,
|
||||
workspaceId,
|
||||
});
|
||||
const [{ integrations, responseCount }, usersWithNotifications] = await Promise.all([
|
||||
loadResponseFinishedContext({
|
||||
data,
|
||||
logContext,
|
||||
workspaceId,
|
||||
}),
|
||||
getUsersWithNotifications({
|
||||
data,
|
||||
logContext,
|
||||
workspaceId,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (integrations.length > 0) {
|
||||
try {
|
||||
@@ -523,12 +618,6 @@ const runResponseFinishedSideEffects = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const usersWithNotifications = await getUsersWithNotifications({
|
||||
data,
|
||||
logContext,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await handleFollowUpsSafely({
|
||||
data,
|
||||
logContext,
|
||||
@@ -555,10 +644,14 @@ const runResponseFinishedSideEffects = async ({
|
||||
const runResponseCreatedSideEffects = async ({
|
||||
data,
|
||||
logContext,
|
||||
organizationId,
|
||||
survey,
|
||||
stripeCustomerId,
|
||||
}: {
|
||||
data: TResponsePipelineJobData;
|
||||
logContext: ReturnType<typeof getPipelineLogContext>;
|
||||
organizationId: string;
|
||||
survey: TPipelineSurvey;
|
||||
stripeCustomerId: string | null | undefined;
|
||||
}) => {
|
||||
try {
|
||||
@@ -577,6 +670,27 @@ const runResponseCreatedSideEffects = async ({
|
||||
);
|
||||
}
|
||||
|
||||
if (POSTHOG_KEY) {
|
||||
try {
|
||||
const responseCount = await getResponseCountBySurveyId(data.surveyId);
|
||||
captureSurveyResponsePostHogEvent({
|
||||
organizationId,
|
||||
surveyId: data.surveyId,
|
||||
surveyType: survey.type,
|
||||
workspaceId: data.workspaceId,
|
||||
responseCount,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...logContext,
|
||||
err: error,
|
||||
},
|
||||
"Response pipeline PostHog capture failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await sendTelemetryEvents();
|
||||
} catch (error) {
|
||||
@@ -594,20 +708,26 @@ export const processResponsePipelineJob: JobHandler<TResponsePipelineJobData> =
|
||||
const logContext = getPipelineLogContext(data, context);
|
||||
|
||||
try {
|
||||
const survey = await getSurvey(data.surveyId);
|
||||
const [organization, survey, webhooks] = await Promise.all([
|
||||
getOrganizationForPipeline(data.workspaceId),
|
||||
getSurveyForPipeline(data.surveyId),
|
||||
getWebhooksForPipeline(data.workspaceId, data.event as PipelineTriggers, data.surveyId),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
throw new UnrecoverableError(`Survey ${data.surveyId} not found`);
|
||||
}
|
||||
|
||||
const workspaceId = survey.workspaceId;
|
||||
|
||||
const organization = await getOrganizationByWorkspaceId(workspaceId);
|
||||
if (!organization) {
|
||||
throw new UnrecoverableError(`Organization not found for workspace ${workspaceId}`);
|
||||
throw new UnrecoverableError(`Organization not found for workspace ${data.workspaceId}`);
|
||||
}
|
||||
|
||||
if (survey.workspaceId !== data.workspaceId) {
|
||||
throw new UnrecoverableError(
|
||||
`Survey ${data.surveyId} does not belong to workspace ${data.workspaceId}`
|
||||
);
|
||||
}
|
||||
|
||||
const event = data.event as PipelineTriggers;
|
||||
const webhooks = await getWebhooksForPipeline(workspaceId, event, data.surveyId);
|
||||
await deliverWebhooks({
|
||||
data,
|
||||
logContext,
|
||||
@@ -621,7 +741,7 @@ export const processResponsePipelineJob: JobHandler<TResponsePipelineJobData> =
|
||||
logContext,
|
||||
organizationId: organization.id,
|
||||
survey,
|
||||
workspaceId,
|
||||
workspaceId: data.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -629,10 +749,23 @@ export const processResponsePipelineJob: JobHandler<TResponsePipelineJobData> =
|
||||
await runResponseCreatedSideEffects({
|
||||
data,
|
||||
logContext,
|
||||
stripeCustomerId: organization.billing.stripeCustomerId,
|
||||
organizationId: organization.id,
|
||||
survey,
|
||||
stripeCustomerId: organization.billing?.stripeCustomerId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isPipelinePoolExhaustionError(error)) {
|
||||
logger.warn(
|
||||
{
|
||||
...logContext,
|
||||
err: error,
|
||||
},
|
||||
"Response pipeline job hit database pool exhaustion and will be retried"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{
|
||||
...logContext,
|
||||
|
||||
@@ -181,7 +181,7 @@ export const FormStylingSettings = ({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ColorField
|
||||
form={form}
|
||||
name="inputColor.light"
|
||||
name="inputBgColor.light"
|
||||
label={t("workspace.surveys.edit.input_color")}
|
||||
description={t("workspace.surveys.edit.input_color_description")}
|
||||
/>
|
||||
|
||||
@@ -9,12 +9,7 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { TWorkspaceStyling } from "@formbricks/types/workspace";
|
||||
import {
|
||||
COLOR_DEFAULTS,
|
||||
STYLE_DEFAULTS,
|
||||
deriveNewFieldsFromLegacy,
|
||||
getSuggestedColors,
|
||||
} from "@/lib/styling/constants";
|
||||
import { COLOR_DEFAULTS, STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
|
||||
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
|
||||
import { LogoSettingsCard } from "@/modules/survey/editor/components/logo-settings-card";
|
||||
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
||||
@@ -73,15 +68,10 @@ export const StylingView = ({
|
||||
? Object.fromEntries(Object.entries(localSurvey.styling).filter(([, v]) => v != null))
|
||||
: {};
|
||||
|
||||
const workspaceLegacyFills = deriveNewFieldsFromLegacy(cleanWorkspace);
|
||||
const surveyLegacyFills = deriveNewFieldsFromLegacy(cleanSurvey);
|
||||
|
||||
const form = useForm<TSurveyStyling>({
|
||||
defaultValues: {
|
||||
...STYLE_DEFAULTS,
|
||||
...workspaceLegacyFills,
|
||||
...cleanWorkspace,
|
||||
...surveyLegacyFills,
|
||||
...cleanSurvey,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { cn } from "@/modules/ui/lib/utils";
|
||||
interface TOption<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface MultiSelectProps<T extends string, K extends TOption<T>["value"][]> {
|
||||
@@ -225,17 +226,18 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
||||
{selectableOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
disabled={option.disabled}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (disabled) return;
|
||||
if (disabled || option.disabled) return;
|
||||
isUserInitiatedRef.current = true; // Mark as user-initiated
|
||||
setSelected((prev) => [...prev, option]);
|
||||
setInputValue("");
|
||||
}}
|
||||
className="cursor-pointer">
|
||||
className={option.disabled ? "cursor-not-allowed" : "cursor-pointer"}>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components
|
||||
|
||||
interface TSecondaryNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
label: React.ReactNode;
|
||||
href?: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
hidden?: boolean;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user