Compare commits

...

18 Commits

Author SHA1 Message Date
Dhruwang ceabaf0aff tweaks 2026-04-29 11:21:54 +05:30
Johannes 52dc64ffd2 fix: add missing analysis translation keys in PR5
Made-with: Cursor
2026-04-26 20:16:47 +02:00
Johannes bec3fa2dbd feat: make Add charts a primary dashboard action
Promote Add charts to a labeled primary button in the dashboard control bar and keep secondary actions in the icon toolbar for clearer chart-creation affordance.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes 18a3c4f0f7 fix: pass directories into dashboard detail chart dialogs
Load feedback record directories in dashboard detail page and thread them through dashboard detail and control bar components so chart dialogs no longer crash with an undefined directories reference.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes 6c61afec2f feat: refresh analysis charts and dashboard feedback gating
Unify chart create and edit flows, update dashboard chart interactions, and add feedback-record availability checks with dedicated empty-state handling across analysis entry points.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes ce4d9350e2 fix: add missing feedback record translation keys in PR4
Made-with: Cursor
2026-04-26 20:05:49 +02:00
Johannes 3e6f81268d fix: require frdId when refreshing feedback records
Made-with: Cursor
2026-04-26 19:54:30 +02:00
Johannes 8137de3c80 feat: integrate hub feedback records into unify workspace
Add hub-backed feedback record actions and UI flows under Unify so workspaces can list and manage feedback records from a dedicated drawer and table experience.

Made-with: Cursor
2026-04-26 19:54:30 +02:00
Johannes bf0ad45697 fix: add missing feedback-directory and unify translations
Made-with: Cursor
2026-04-26 19:54:23 +02:00
Johannes b1a4277ca8 feat: wire workspace settings to feedback record directories
Integrate feedback record directory selection into workspace settings and creation flows while updating workspace navigation components to expose the new workspace-level destinations.

Made-with: Cursor
2026-04-26 19:37:33 +02:00
Johannes 1876c13f52 fix: align unify locale keys and regenerate translations
Made-with: Cursor
2026-04-26 19:37:27 +02:00
Johannes 0623bb9ff5 fix: make feedback sources settings card compatible in PR2
Made-with: Cursor
2026-04-26 19:03:23 +02:00
Johannes d37cddaa7e feat: refactor feedback sources UI and routing
Rework the feedback sources flow with new source form helpers, question selection components, and a canonical feedback-sources route while retiring the legacy survey selector.

Made-with: Cursor
2026-04-26 18:55:25 +02:00
Johannes 24f632f9ce fix: align unify connector type literals with schema
Replace legacy formbricks type checks with formbricks_survey across source setup flows so TypeScript and connector creation paths stay consistent.

Made-with: Cursor
2026-04-26 17:14:13 +02:00
Johannes b041e3da86 fix: normalize connector response timestamps for Cube
Normalize imported response timestamps to ISO and fall back from createdAt to updatedAt so collected_at is always valid for time-series charts.

Made-with: Cursor
2026-04-24 12:57:00 +02:00
Johannes 8d91a3db62 fix: map csat and ces connector question types
Add missing hub field mappings for csat and ces survey elements and guard against unmappable selected elements so connector setup fails with a clear validation error instead of a Prisma crash.

Made-with: Cursor
2026-04-24 12:02:51 +02:00
Johannes c05e3f192d fix: accept legacy connector type in create action
Normalize legacy formbricks connector payloads to formbricks_survey during connector creation so client flows continue working while UI migrations roll out.

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

Made-with: Cursor
2026-04-24 10:56:57 +02:00
93 changed files with 5010 additions and 1984 deletions
@@ -0,0 +1 @@
export { WorkspaceSourcesPage 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,52 @@ 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")}>
<ul>
<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>
</ul>
</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>
@@ -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,197 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError } from "@formbricks/types/errors";
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 assertWorkspaceDirectoryAccess = (directoryIds: Set<string>, tenantId: string): void => {
if (!directoryIds.has(tenantId)) {
throw new AuthorizationError("Invalid feedback record directory for this workspace");
}
};
export const retrieveFeedbackRecordAction = authenticatedActionClient
.inputSchema(ZRetrieveFeedbackRecordAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZRetrieveFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "read");
const recordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!recordResult.data || recordResult.error) {
throw new Error(recordResult.error?.message || "Failed to retrieve feedback record");
}
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(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);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
const createResult = await createFeedbackRecord(
parsedInput.recordInput as unknown as FeedbackRecordCreateParams
);
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>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!currentRecordResult.data || currentRecordResult.error) {
throw new Error(currentRecordResult.error?.message || "Failed to retrieve feedback record");
}
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
const updatePayload = Object.fromEntries(
Object.entries(parsedInput.updateInput).filter(([, value]) => value !== undefined)
) as unknown as FeedbackRecordUpdateParams;
const updateResult = await updateFeedbackRecord(parsedInput.recordId, updatePayload);
if (!updateResult.data || updateResult.error) {
throw new Error(updateResult.error?.message || "Failed to update feedback record");
}
return updateResult.data;
}
);
@@ -0,0 +1,988 @@
"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 { v7 as uuidv7 } from "uuid";
import { z } from "zod";
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";
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;
}
const FIELD_TYPE_OPTIONS = [
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
] as const;
const SOURCE_TYPE_PRESET_OPTIONS = [
"survey",
"review",
"feedback_form",
"support",
"social",
"interview",
"usability_test",
"nps_campaign",
] as const;
const SOURCE_TYPE_CUSTOM_VALUE = "__custom__";
const ZMetadataEntry = z.object({
key: z.string().trim().min(1),
value: z.string(),
});
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),
});
type TFeedbackRecordFormValues = z.infer<typeof ZFeedbackRecordFormValues>;
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";
}
};
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}`;
};
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();
};
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: [],
};
};
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,
};
};
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),
}));
};
const parseNumberValue = (value: string): number | null => {
if (value.trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const formatSourceType = (sourceType: string, t: (key: string) => string): 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;
}
};
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));
setSourceTypeMode(
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never)
? result.data.source_type
: SOURCE_TYPE_CUSTOM_VALUE
);
setCustomSourceType(
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never) ? "" : 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: Record<string, unknown> = {
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 as never,
});
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}
/>
</>
);
};
@@ -9,33 +9,33 @@ 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>
);
@@ -3,13 +3,16 @@
import { TFunction } from "i18next";
import {
CalendarIcon,
ChevronDownIcon,
HashIcon,
MessageSquareTextIcon,
PlusIcon,
RefreshCwIcon,
ToggleLeftIcon,
TypeIcon,
} from "lucide-react";
import { useCallback, useState } from "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";
@@ -18,15 +21,16 @@ 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";
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 { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
const RECORDS_PER_PAGE = 50;
@@ -50,6 +54,18 @@ const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string):
return "—";
};
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;
}
};
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen) + "…";
@@ -57,96 +73,88 @@ function truncate(str: string, maxLen: number): string {
interface FeedbackRecordsTableProps {
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 const FeedbackRecordsTable = ({
workspaceId,
directories,
initialFrdId,
initialRecords,
initialNextCursor,
}: FeedbackRecordsTableProps) => {
frdMap,
csvSources,
canWrite,
}: Readonly<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 [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 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 directories = useMemo(
() =>
Object.entries(frdMap)
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name)),
[frdMap]
);
const feedbackDirectoryName = useMemo(() => {
const directoryNames = Array.from(
new Set(
records
.map((record) => frdMap[record.tenant_id])
.filter((directoryName): directoryName is string => Boolean(directoryName))
)
);
const handleFrdChange = (frdId: string) => {
setSelectedFrdId(frdId);
setRecords([]);
setNextCursor(undefined);
fetchRecords(frdId, undefined, false);
};
if (directoryNames.length > 0) {
return directoryNames.join(", ");
}
const handleLoadMore = () => {
if (!selectedFrdId) return;
fetchRecords(selectedFrdId, nextCursor, true);
};
return directories[0]?.name ?? "—";
}, [directories, frdMap, records]);
const handleRefresh = async () => {
if (!selectedFrdId || isRefreshing) return;
if (isRefreshing) return;
setIsRefreshing(true);
setError(null);
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
const errorMessage = await fetchRecords(selectedFrdId, undefined, false);
if (errorMessage) {
toast.error(errorMessage, { id: toastId });
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
.sort((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 });
};
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">
@@ -161,114 +169,195 @@ export const FeedbackRecordsTable = ({
);
}
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>
const isEmpty = records.length === 0 && !isRefreshing;
<div className="flex items-center gap-3">
{!isEmpty && (
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 })}
{t("workspace.unify.showing_count_loaded", {
count: records.length,
directoryName: feedbackDirectoryName,
})}
</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 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>
<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>
<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>
</tbody>
) : (
<tbody className="divide-y divide-slate-100">
{records.map((record) => (
<FeedbackRecordRow key={record.id} record={record} locale={locale} t={t} />
))}
</tbody>
)}
</table>
</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>
{hasMore && (
<div className="flex justify-center">
<Button variant="secondary" size="sm" onClick={handleLoadMore} loading={isLoadingMore}>
{t("common.load_more")}
</Button>
</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}
/>
)}
</div>
</>
);
};
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="text-sm text-slate-700 transition-colors hover:bg-slate-50">
<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={record.source_type} type="gray" size="tiny" />
<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}>
{record.source_name ?? "—"}
{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}
@@ -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";
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 ?? [])
.sort((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`);
}
@@ -0,0 +1,26 @@
"use client";
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
import { TConnectorType } from "@formbricks/types/connector";
export const getConnectorIcon = (type: TConnectorType, className: string) => {
switch (type) {
case "formbricks_survey":
return <FormIcon className={className} />;
case "csv":
return <FileSpreadsheetIcon className={className} />;
default:
return <FormIcon className={className} />;
}
};
export const getConnectorTypeLabelKey = (type: TConnectorType): string => {
switch (type) {
case "formbricks_survey":
return "workspace.unify.formbricks_surveys";
case "csv":
return "workspace.unify.csv_import";
default:
return type;
}
};
@@ -0,0 +1,21 @@
import { FEEDBACK_RECORD_FIELDS, TFieldMapping } from "../types";
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
const requiredFieldIds = new Set(
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
);
for (const mapping of mappings) {
if (!requiredFieldIds.has(mapping.targetFieldId)) {
continue;
}
if (mapping.sourceFieldId || mapping.staticValue) {
requiredFieldIds.delete(mapping.targetFieldId);
}
}
return requiredFieldIds.size === 0;
};
@@ -2,6 +2,7 @@
import {
CopyIcon,
EyeIcon,
FileSpreadsheetIcon,
MoreVertical,
PauseIcon,
@@ -9,6 +10,7 @@ import {
SquarePenIcon,
TrashIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
@@ -39,12 +41,15 @@ export function ConnectorRowDropdown({
onToggleStatus,
onDelete,
}: ConnectorRowDropdownProps) {
const router = useRouter();
const { t } = useTranslation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isActive = connector.status === "active";
const linkedSurveyId =
connector.type === "formbricks_survey" ? connector.formbricksMappings[0]?.surveyId : undefined;
const handleDelete = async () => {
setIsDeleting(true);
@@ -89,6 +94,25 @@ export function ConnectorRowDropdown({
</>
)}
{linkedSurveyId && (
<>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
router.push(`/workspaces/${connector.workspaceId}/surveys/${linkedSurveyId}/summary`);
}}>
<EyeIcon className="mr-2 h-4 w-4" />
{`${t("common.view")} ${t("common.survey")}`}
</button>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem>
<button
type="button"
@@ -1,56 +1,82 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TConnectorType } from "@formbricks/types/connector";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { getConnectorOptions } from "../utils";
import { TConnectorOptionId, getConnectorOptions } from "../utils";
interface ConnectorTypeSelectorProps {
selectedType: TConnectorType | null;
onSelectType: (type: TConnectorType) => void;
selectedType: TConnectorOptionId | null;
onSelectType: (type: TConnectorOptionId) => void;
}
export function ConnectorTypeSelector({ selectedType, onSelectType }: ConnectorTypeSelectorProps) {
const getOptionClassName = (
selectedType: TConnectorOptionId | null,
optionId: TConnectorOptionId,
disabled: boolean
): string => {
if (selectedType === optionId) {
return "border-brand-dark bg-slate-50";
}
if (disabled) {
return "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60";
}
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
};
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
const { t } = useTranslation();
const connectorOptions = getConnectorOptions(t);
return (
<div className="space-y-3">
<p className="text-sm text-slate-600">{t("workspace.unify.select_source_type_prompt")}</p>
<div className="space-y-2">
{connectorOptions.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id as TConnectorType)}
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
selectedType === option.id
? "border-brand-dark bg-slate-50"
: option.disabled
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
}`}>
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
selectedType,
option.id,
option.disabled
)}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{option.name}</span>
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
</div>
<div
className={`ml-4 h-5 w-5 rounded-full border-2 ${
className={`ml-3 h-4 w-4 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-2 w-2 rounded-full bg-white" />
<div className="h-1.5 w-1.5 rounded-full bg-white" />
</div>
)}
</div>
</button>
))}
</div>
<Alert variant="outbound" size="small">
<AlertTitle>{t("workspace.unify.missing_feedback_source_title")}</AlertTitle>
<AlertButton asChild>
<Link
href="https://app.formbricks.com/s/cmob8tub9s2ndu5010ei4it0g"
target="_blank"
rel="noopener noreferrer"
className="text-slate-900 hover:underline">
{t("workspace.unify.request_feedback_source")}
</Link>
</AlertButton>
</Alert>
</div>
);
}
@@ -1,10 +1,12 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TConnectorType, TConnectorWithMappings, THubTargetField } from "@formbricks/types/connector";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import {
createConnectorWithMappingsAction,
deleteConnectorAction,
@@ -12,9 +14,10 @@ import {
updateConnectorWithMappingsAction,
} from "@/lib/connector/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
import { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
import { TFieldMapping, TUnifySurvey } from "../types";
import { ConnectorsTable } from "./connectors-table";
import { CreateConnectorModal } from "./create-connector-modal";
@@ -33,12 +36,21 @@ export function ConnectorsSection({
initialConnectors,
initialSurveys,
directories,
}: ConnectorsSectionProps) {
}: Readonly<ConnectorsSectionProps>) {
const { t } = useTranslation();
const router = useRouter();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingConnector, setEditingConnector] = useState<TConnectorWithMappings | null>(null);
const [csvImportConnector, setCsvImportConnector] = useState<TConnectorWithMappings | null>(null);
const directoryNames = directories.map((directory) => directory.name).join(", ");
const feedbackDirectoryAccessText =
directories.length === 1
? t("workspace.unify.feedback_sources_directory_access_single", {
directoryNames,
})
: t("workspace.unify.feedback_sources_directory_access_multiple", {
directoryNames,
});
const handleCreateConnector = async (data: {
name: string;
@@ -55,9 +67,9 @@ export function ConnectorsSection({
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
},
formbricksMappings:
data.type === "formbricks" && data.surveyMappings?.length ? data.surveyMappings : undefined,
data.type === "formbricks_survey" && data.surveyMappings?.length ? data.surveyMappings : undefined,
fieldMappings:
data.type !== "formbricks" && data.fieldMappings?.length
data.type !== "formbricks_survey" && data.fieldMappings?.length
? data.fieldMappings.map((m) => ({
sourceFieldId: m.sourceFieldId || "",
targetFieldId: m.targetFieldId as THubTargetField,
@@ -154,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);
@@ -1,9 +1,9 @@
"use client";
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TConnectorStatus, TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
import { Badge } from "@/modules/ui/components/badge";
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
import { ConnectorRowDropdown } from "./connector-row-dropdown";
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
@@ -39,17 +39,6 @@ interface ConnectorsTableDataRowProps {
onDelete: () => Promise<void>;
}
function getConnectorIcon(type: TConnectorType) {
switch (type) {
case "formbricks":
return <FormIcon className="h-4 w-4 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
default:
return <FormIcon className="h-4 w-4 text-slate-500" />;
}
}
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
active: "success",
paused: "warning",
@@ -63,13 +52,24 @@ export function ConnectorsTableDataRow({
onDuplicate,
onToggleStatus,
onDelete,
}: ConnectorsTableDataRowProps) {
}: Readonly<ConnectorsTableDataRowProps>) {
const { t, i18n } = useTranslation();
const handleRowClick = () => {
if (connector.type === "csv" && onCsvImport) {
onCsvImport();
return;
}
const getStatusLabel = (s: TConnectorStatus) => {
onEdit();
};
const getStatusLabel = (s: TConnectorStatus, connectorType: TConnectorType) => {
switch (s) {
case "active":
return t("workspace.unify.status_active");
if (connectorType === "csv") {
return t("workspace.unify.status_ready");
}
return t("workspace.unify.status_live_sync");
case "paused":
return t("workspace.unify.status_paused");
case "error":
@@ -77,44 +77,32 @@ export function ConnectorsTableDataRow({
}
};
const getConnectorTypeLabel = (connectorType: TConnectorType) => {
switch (connectorType) {
case "formbricks":
return t("workspace.unify.formbricks_surveys");
case "csv":
return t("workspace.unify.csv_import");
default:
return connectorType;
}
};
return (
<div
role="button"
tabIndex={0}
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
onClick={onEdit}
onClick={handleRowClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onEdit();
handleRowClick();
}
}}>
<div className="col-span-1 flex items-center gap-2 pl-4" title={getConnectorTypeLabel(connector.type)}>
{getConnectorIcon(connector.type)}
<div
className="col-span-1 flex items-center gap-2 pl-4"
title={t(getConnectorTypeLabelKey(connector.type))}>
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
</div>
<div className="col-span-3 flex items-center">
<div className="col-span-5 flex items-center">
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
</div>
<div className="col-span-1 hidden items-center justify-center sm:flex">
<Badge
text={getStatusLabel(connector.status)}
text={getStatusLabel(connector.status, connector.type)}
type={STATUS_BADGE_TYPE[connector.status]}
size="tiny"
/>
</div>
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{getRelativeTime(connector.createdAt, i18n.language)}
</div>
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{getRelativeTime(connector.updatedAt, i18n.language)}
</div>
@@ -23,16 +23,15 @@ export function ConnectorsTable({
onToggleStatus,
onDelete,
isLoading = false,
}: ConnectorsTableProps) {
}: Readonly<ConnectorsTableProps>) {
const { t } = useTranslation();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">{t("common.type")}</div>
<div className="col-span-3">{t("common.name")}</div>
<div className="col-span-5">{t("common.name")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.created")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
<div className="col-span-1" />
@@ -1,9 +1,13 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2Icon, PlusIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TConnectorType, UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
import {
getResponseCountAction,
@@ -21,8 +25,15 @@ import {
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
@@ -30,21 +41,23 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Switch } from "@/modules/ui/components/switch";
import { TCreateConnectorStep, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
import {
FEEDBACK_RECORD_FIELDS,
TCreateConnectorStep,
TFieldMapping,
TSourceField,
TUnifySurvey,
} from "../types";
import { TEnumValidationError, parseCSVColumnsToFields, validateEnumMappings } from "../utils";
TConnectorOptionId,
TEnumValidationError,
parseCSVColumnsToFields,
validateEnumMappings,
} from "../utils";
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
import { ConnectorTypeSelector } from "./connector-type-selector";
import { CsvConnectorUI } from "./csv-connector-ui";
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
import { FormbricksQuestionList } from "./formbricks-question-list";
interface CreateConnectorModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
showTrigger?: boolean;
onCreateConnector: (data: {
name: string;
type: TConnectorType;
@@ -59,139 +72,105 @@ interface CreateConnectorModalProps {
const getDialogTitle = (
step: TCreateConnectorStep,
type: TConnectorType | null,
type: TConnectorOptionId | null,
t: (key: string) => string
): string => {
if (step === "selectType") return t("workspace.unify.add_feedback_source");
if (type === "formbricks") return t("workspace.unify.select_survey_and_questions");
if (type === "formbricks_survey") return t("workspace.unify.select_survey_and_questions");
if (type === "csv") return t("workspace.unify.import_csv_data");
return t("workspace.unify.configure_mapping");
};
const getDialogDescription = (
step: TCreateConnectorStep,
type: TConnectorType | null,
type: TConnectorOptionId | null,
t: (key: string) => string
): string => {
if (step === "selectType") return t("workspace.unify.select_source_type_description");
if (type === "formbricks") return t("workspace.unify.select_survey_questions_description");
if (type === "formbricks_survey") return t("workspace.unify.select_survey_questions_description");
if (type === "csv") return t("workspace.unify.upload_csv_data_description");
return t("workspace.unify.configure_mapping");
};
const getNextStepButtonLabel = (type: TConnectorType | null, t: (key: string) => string): string => {
if (type === "formbricks") return t("workspace.unify.select_questions");
const getNextStepButtonLabel = (type: TConnectorOptionId | null, t: (key: string) => string): string => {
if (type === "formbricks_survey") return t("workspace.unify.select_questions");
if (type === "csv") return t("workspace.unify.configure_import");
if (type === "api_ingestion") return t("workspace.unify.api_ingestion_manage_api_keys");
if (type === "feedback_record_mcp") return t("common.learn_more");
return t("workspace.unify.create_mapping");
};
const getCreateDisabled = (
type: TConnectorType | null,
isFormbricksValid: boolean,
isCsvValid: boolean,
allRequiredMapped: boolean
): boolean => {
if (type === "formbricks") return !isFormbricksValid;
if (type === "csv") return !isCsvValid || !allRequiredMapped;
return !allRequiredMapped;
};
const ZFormbricksConnectorForm = z.object({
sourceName: z.string().trim().min(1),
surveyId: z.string().min(1),
selectedQuestionIds: z.array(z.string()).min(1),
importHistorical: z.boolean(),
});
interface AggregateImportSectionProps {
surveyEntries: {
surveyId: string;
surveyName: string;
responseCount: number;
elementCount: number;
importHistorical: boolean;
}[];
onImportHistoricalChange: (surveyId: string, checked: boolean) => void;
t: (key: string, options?: Record<string, unknown>) => string;
}
type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
const AggregateImportSection = ({
surveyEntries,
onImportHistoricalChange,
t,
}: AggregateImportSectionProps) => {
const totalRecords = surveyEntries.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
const checkedCount = surveyEntries.filter((e) => e.importHistorical).length;
const checkedTotal = surveyEntries
.filter((e) => e.importHistorical)
.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="space-y-2">
{surveyEntries.map((entry) => (
<label key={entry.surveyId} className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={entry.importHistorical}
onChange={(e) => onImportHistoricalChange(entry.surveyId, e.target.checked)}
className="h-4 w-4 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
/>
<span className="text-xs text-amber-800">
{t("workspace.unify.survey_import_line", {
surveyName: entry.surveyName,
responseCount: entry.responseCount,
questionCount: entry.elementCount,
total: entry.responseCount * entry.elementCount,
})}
</span>
</label>
))}
</div>
{surveyEntries.length > 1 && (
<p className="mt-3 border-t border-amber-200 pt-2 text-xs font-medium text-amber-900">
{t("workspace.unify.total_feedback_records", {
checked: checkedTotal,
total: totalRecords,
surveyCount: checkedCount,
})}
</p>
)}
</div>
);
};
const getSelectableQuestionIds = (survey: TUnifySurvey): string[] =>
survey.elements
.filter((element) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(element.type))
.map((element) => element.id);
export const CreateConnectorModal = ({
open,
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,41 @@ export const CreateConnectorModal = ({
}
};
const handleCreate = async () => {
if (!selectedType || !connectorName.trim() || !selectedDirectoryId) return;
const handleFormbricksQuestionToggle = (questionId: string) => {
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
const isSelected = currentSelection.includes(questionId);
const nextSelection = isSelected
? currentSelection.filter((id) => id !== questionId)
: [...currentSelection, questionId];
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
shouldDirty: true,
shouldValidate: true,
});
};
if (selectedType === "csv" && csvParsedData.length > 0) {
const handleCreateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
if (!selectedDirectoryId) return;
setIsCreating(true);
const connectorId = await onCreateConnector({
name: values.sourceName.trim(),
type: "formbricks_survey",
feedbackRecordDirectoryId: selectedDirectoryId,
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
});
if (connectorId && values.importHistorical) {
await handleHistoricalImport(connectorId, values.surveyId);
}
setIsCreating(false);
resetForm();
onOpenChange(false);
};
const handleCreateCsvConnector = async () => {
if (!selectedDirectoryId || !isConnectorNameValid(csvConnectorName)) return;
if (csvParsedData.length > 0) {
const errors = validateEnumMappings(mappings, csvParsedData);
if (errors.length > 0) {
setEnumValidationErrors(errors);
@@ -375,21 +365,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 +381,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 +393,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 +423,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 +593,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 +619,22 @@ export const CreateConnectorModal = ({
);
};
interface FrdPickerProps {
directories: { id: string; name: string }[];
selectedDirectoryId: string | null;
onChange: (id: string) => void;
interface NoFeedbackRecordDirectoryAlertProps {
workspaceId: string;
t: (key: string) => string;
}
const FrdPicker = ({ directories, selectedDirectoryId, onChange, workspaceId, t }: FrdPickerProps) => {
if (directories.length === 0) {
return (
<Alert variant="error" size="small">
<div>
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
<a
className="mt-1 inline-block font-medium underline"
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.unify.go_to_feedback_record_directories")}
</a>
</div>
</Alert>
);
}
if (directories.length === 1) {
return (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
{t("workspace.unify.records_will_go_to")}{" "}
<span className="font-medium text-slate-900">{directories[0].name}</span>
</div>
);
}
const NoFeedbackRecordDirectoryAlert = ({ workspaceId, t }: NoFeedbackRecordDirectoryAlertProps) => {
return (
<div className="space-y-2">
<Label htmlFor="feedbackRecordDirectory">{t("workspace.unify.feedback_record_directory")}</Label>
<Select value={selectedDirectoryId ?? ""} onValueChange={onChange}>
<SelectTrigger id="feedbackRecordDirectory">
<SelectValue placeholder={t("workspace.unify.select_feedback_record_directory")} />
</SelectTrigger>
<SelectContent>
{directories.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Alert variant="error" size="small">
<div>
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
<a
className="mt-1 inline-block font-medium underline"
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.unify.go_to_feedback_record_directories")}
</a>
</div>
</Alert>
);
};
@@ -1,9 +1,11 @@
"use client";
import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
import { z } from "zod";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -13,17 +15,27 @@ import {
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
FEEDBACK_RECORD_FIELDS,
SAMPLE_CSV_COLUMNS,
TFieldMapping,
TSourceField,
TUnifySurvey,
} from "../types";
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
import { parseCSVColumnsToFields } from "../utils";
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
import { FormbricksQuestionList } from "./formbricks-question-list";
import { MappingUI } from "./mapping-ui";
interface EditConnectorModalProps {
@@ -38,42 +50,17 @@ interface EditConnectorModalProps {
fieldMappings?: TFieldMapping[];
}) => Promise<void>;
surveys: TUnifySurvey[];
directories: { id: string; name: string }[];
onOpenCsvImport?: () => void;
}
const getConnectorIcon = (type: TConnectorType) => {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-5 w-5 text-slate-500" />;
default:
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
}
};
const ZFormbricksEditConnectorForm = z.object({
sourceName: z.string().trim().min(1),
surveyId: z.string().min(1),
selectedQuestionIds: z.array(z.string()).min(1),
importHistorical: z.boolean(),
});
const getConnectorTypeLabelKey = (type: TConnectorType): string => {
switch (type) {
case "formbricks":
return "workspace.unify.formbricks_surveys";
case "csv":
return "workspace.unify.csv_import";
default:
return type;
}
};
const groupMappingsBySurvey = (
mappings: { surveyId: string; elementId: string }[]
): Record<string, string[]> => {
const grouped: Record<string, string[]> = {};
for (const m of mappings) {
if (!grouped[m.surveyId]) grouped[m.surveyId] = [];
grouped[m.surveyId].push(m.elementId);
}
return grouped;
};
type TFormbricksEditConnectorForm = z.infer<typeof ZFormbricksEditConnectorForm>;
export const EditConnectorModal = ({
connector,
@@ -81,35 +68,52 @@ export const EditConnectorModal = ({
onOpenChange,
onUpdateConnector,
surveys,
directories,
onOpenCsvImport,
}: EditConnectorModalProps) => {
const { t } = useTranslation();
const [connectorName, setConnectorName] = useState("");
const [csvConnectorName, setCsvConnectorName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [isUpdating, setIsUpdating] = useState(false);
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
const formbricksForm = useForm<TFormbricksEditConnectorForm>({
resolver: zodResolver(ZFormbricksEditConnectorForm),
defaultValues: {
sourceName: "",
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
},
mode: "onChange",
});
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const allRequiredMapped = requiredFields.every((field) =>
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
const formbricksValues = formbricksForm.watch();
const selectedSurveyId = formbricksValues.surveyId;
const selectedQuestionIds = formbricksValues.selectedQuestionIds ?? [];
const selectedSurvey = useMemo(
() => surveys.find((survey) => survey.id === selectedSurveyId) ?? null,
[surveys, selectedSurveyId]
);
useEffect(() => {
if (connector) {
setConnectorName(connector.name);
if (connector.type === "formbricks_survey") {
const mappedSurveyId = connector.formbricksMappings[0]?.surveyId ?? "";
const mappedQuestionIds = connector.formbricksMappings
.filter((mapping) => mapping.surveyId === mappedSurveyId)
.map((mapping) => mapping.elementId);
if (connector.type === "formbricks") {
const fbMappings = connector.formbricksMappings;
setSelectedSurveyId(fbMappings.length > 0 ? fbMappings[0].surveyId : null);
setElementIdsBySurvey(groupMappingsBySurvey(fbMappings));
formbricksForm.reset({
sourceName: connector.name,
surveyId: mappedSurveyId,
selectedQuestionIds: mappedQuestionIds,
importHistorical: true,
});
setCsvConnectorName("");
setSourceFields([]);
setMappings([]);
} else if (connector.type === "csv") {
setCsvConnectorName(connector.name);
const columnsFromMappings = [
...new Set(connector.fieldMappings.map((m) => m.sourceFieldId).filter(Boolean)),
];
@@ -125,23 +129,37 @@ export const EditConnectorModal = ({
staticValue: m.staticValue ?? undefined,
}))
);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
formbricksForm.reset({
sourceName: "",
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
} else {
setCsvConnectorName("");
setSourceFields([]);
setMappings([]);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
formbricksForm.reset({
sourceName: "",
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
}
}
}, [connector]);
}, [connector, formbricksForm]);
const resetForm = () => {
setConnectorName("");
setCsvConnectorName("");
setMappings([]);
setSourceFields([]);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
formbricksForm.reset({
sourceName: "",
surveyId: "",
selectedQuestionIds: [],
importHistorical: true,
});
setIsUpdating(false);
};
const handleOpenChange = (newOpen: boolean) => {
@@ -151,76 +169,64 @@ export const EditConnectorModal = ({
onOpenChange(newOpen);
};
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
};
const handleElementToggle = (elementId: string) => {
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => {
const current = prev[selectedSurveyId] ?? [];
return {
...prev,
[selectedSurveyId]: current.includes(elementId)
? current.filter((id) => id !== elementId)
: [...current, elementId],
};
});
};
const handleSelectAllElements = (surveyId: string) => {
const survey = surveys.find((s) => s.id === surveyId);
if (survey) {
setElementIdsBySurvey((prev) => ({
...prev,
[surveyId]: survey.elements.map((e) => e.id),
}));
}
};
const handleDeselectAllElements = () => {
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => ({
...prev,
[selectedSurveyId]: [],
}));
};
const handleUpdate = async () => {
if (!connector || !connectorName.trim()) return;
const surveyMappings = Object.entries(elementIdsBySurvey)
.filter(([, ids]) => ids.length > 0)
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
const handleUpdateFormbricksConnector = async (values: TFormbricksEditConnectorForm) => {
if (connector?.type !== "formbricks_survey") return;
setIsUpdating(true);
await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: connectorName.trim(),
surveyMappings:
connector.type === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
fieldMappings: connector.type !== "formbricks" && mappings.length > 0 ? mappings : undefined,
name: values.sourceName.trim(),
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
fieldMappings: undefined,
});
setIsUpdating(false);
handleOpenChange(false);
};
const assignedDirectoryName =
directories.find((d) => d.id === connector?.feedbackRecordDirectoryId)?.name ??
connector?.feedbackRecordDirectoryId ??
"—";
const handleUpdateCsvConnector = async () => {
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
setIsUpdating(true);
await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: csvConnectorName.trim(),
surveyMappings: undefined,
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
setIsUpdating(false);
handleOpenChange(false);
};
const saveChangesDisbaled = useMemo(() => {
const handleFormbricksQuestionToggle = (questionId: string) => {
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
const isSelected = currentSelection.includes(questionId);
const nextSelection = isSelected
? currentSelection.filter((id) => id !== questionId)
: [...currentSelection, questionId];
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
shouldDirty: true,
shouldValidate: true,
});
};
const saveChangesDisabled = useMemo(() => {
if (!connector) return true;
if (!connectorName.trim()) return true;
if (isUpdating) return true;
if (connector.type === "formbricks") {
return !Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
if (connector.type === "formbricks_survey") {
return (
!isConnectorNameValid(formbricksValues.sourceName ?? "") ||
!formbricksValues.surveyId ||
!formbricksValues.selectedQuestionIds?.length
);
}
if (connector.type === "csv") {
return !allRequiredMapped;
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredFieldsMapped(mappings);
}
}, [allRequiredMapped, connector, connectorName, elementIdsBySurvey]);
return true;
}, [connector, csvConnectorName, formbricksValues, isUpdating, mappings]);
if (!connector) return null;
@@ -233,53 +239,111 @@ export const EditConnectorModal = ({
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
{getConnectorIcon(connector.type)}
<div>
<p className="text-sm font-medium text-slate-900">
{t(getConnectorTypeLabelKey(connector.type))}
</p>
<p className="text-xs text-slate-500">{t("workspace.unify.source_type_cannot_be_changed")}</p>
</div>
</div>
{connector.type === "formbricks_survey" ? (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
<Input
value={field.value}
onChange={field.onChange}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<div className="space-y-2">
<Label htmlFor="editConnectorName">{t("workspace.unify.source_name")}</Label>
<Input
id="editConnectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</div>
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange} disabled>
<SelectTrigger>
<SelectValue placeholder={t("workspace.unify.select_survey")} />
</SelectTrigger>
<SelectContent>
{selectedSurvey && (
<SelectItem key={selectedSurvey.id} value={selectedSurvey.id}>
{selectedSurvey.name}
</SelectItem>
)}
{!selectedSurvey && field.value && (
<SelectItem value={field.value}>{field.value}</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormError />
</FormItem>
)}
/>
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
{t("workspace.unify.records_will_go_to")}{" "}
<span className="font-medium text-slate-900">{assignedDirectoryName}</span>
<p className="mt-1 text-xs text-slate-400">{t("workspace.unify.frd_cannot_be_changed")}</p>
</div>
{connector.type === "formbricks" ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<FormbricksSurveySelector
surveys={surveys}
selectedSurveyId={selectedSurveyId}
selectedElementIds={selectedElementIds}
onSurveySelect={handleSurveySelect}
onElementToggle={handleElementToggle}
onSelectAllElements={handleSelectAllElements}
onDeselectAllElements={handleDeselectAllElements}
/>
</div>
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
<div>
<FormbricksQuestionList
survey={selectedSurvey}
selectedQuestionIds={selectedQuestionIds}
onQuestionToggle={handleFormbricksQuestionToggle}
/>
</div>
</FormControl>
<FormError />
</FormItem>
)}
/>
</form>
</FormProvider>
) : (
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
connectorType={connector.type}
/>
</div>
<>
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
{getConnectorIcon(connector.type, "h-5 w-5 text-slate-500")}
<div>
<p className="text-sm font-medium text-slate-900">
{t(getConnectorTypeLabelKey(connector.type))}
</p>
<p className="text-xs text-slate-500">
{t("workspace.unify.source_type_cannot_be_changed")}
</p>
</div>
</div>
<div className="space-y-2">
<label htmlFor="editConnectorName" className="text-sm font-medium text-slate-700">
{t("workspace.unify.source_name")}
</label>
<Input
id="editConnectorName"
value={csvConnectorName}
onChange={(event) => setCsvConnectorName(event.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</div>
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
connectorType={connector.type}
/>
</div>
</>
)}
</div>
@@ -294,7 +358,13 @@ export const EditConnectorModal = ({
{t("workspace.unify.import_feedback")}
</Button>
)}
<Button onClick={handleUpdate} disabled={saveChangesDisbaled}>
<Button
onClick={
connector.type === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
: handleUpdateCsvConnector
}
disabled={saveChangesDisabled}>
{t("workspace.unify.save_changes")}
</Button>
</DialogFooter>
@@ -0,0 +1,80 @@
"use client";
import { useTranslation } from "react-i18next";
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { Label } from "@/modules/ui/components/label";
import { TUnifySurvey } from "../types";
interface FormbricksQuestionListProps {
survey: TUnifySurvey | null;
selectedQuestionIds: string[];
onQuestionToggle: (questionId: string) => void;
}
const isUnsupportedElementType = (type: string): boolean =>
(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(type);
export const FormbricksQuestionList = ({
survey,
selectedQuestionIds,
onQuestionToggle,
}: Readonly<FormbricksQuestionListProps>) => {
const { t } = useTranslation();
if (!survey) {
return (
<div className="rounded-md border border-dashed border-slate-300 p-3">
<p className="text-sm text-slate-500">{t("workspace.unify.select_a_survey_to_see_questions")}</p>
</div>
);
}
if (survey.elements.length === 0) {
return (
<div className="rounded-md border border-dashed border-slate-300 p-3">
<p className="text-sm text-slate-500">{t("workspace.unify.survey_has_no_questions")}</p>
</div>
);
}
return (
<div className="max-h-64 space-y-2 overflow-y-auto rounded-md border border-slate-200 p-3">
{survey.elements.map((element) => {
const unsupported = isUnsupportedElementType(element.type);
const isChecked = selectedQuestionIds.includes(element.id);
const elementTypeLabel = getTSurveyElementTypeEnumName(element.type, t) ?? element.type;
const inputId = `connector-question-${element.id}`;
return (
<div
key={element.id}
className={`flex items-start gap-3 rounded-md border border-slate-100 p-2 ${
unsupported ? "opacity-60" : ""
}`}>
<Checkbox
id={inputId}
checked={!unsupported && isChecked}
disabled={unsupported}
onCheckedChange={() => {
if (!unsupported) {
onQuestionToggle(element.id);
}
}}
/>
<div className="space-y-0.5">
<Label htmlFor={inputId} className={unsupported ? "cursor-not-allowed" : "cursor-pointer"}>
{element.headline}
</Label>
<p className="text-xs text-slate-500">{elementTypeLabel}</p>
{unsupported && (
<p className="text-xs text-slate-500">{t("workspace.unify.question_type_not_supported")}</p>
)}
</div>
</div>
);
})}
</div>
);
};
@@ -1,233 +0,0 @@
"use client";
import { CheckIcon, ChevronRightIcon, FileTextIcon, MessageSquareTextIcon, StarIcon } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
import { Badge } from "@/modules/ui/components/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { TUnifySurvey } from "../types";
interface FormbricksSurveySelectorProps {
surveys: TUnifySurvey[];
selectedSurveyId: string | null;
selectedElementIds: string[];
onSurveySelect: (surveyId: string | null) => void;
onElementToggle: (elementId: string) => void;
onSelectAllElements: (surveyId: string) => void;
onDeselectAllElements: () => void;
}
const getElementIcon = (type: TSurveyElementTypeEnum) => {
switch (type) {
case "openText":
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
case "rating":
case "nps":
return <StarIcon className="h-4 w-4 text-amber-500" />;
default:
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
}
};
const isUnsupportedType = (type: TSurveyElementTypeEnum): boolean => {
return UNSUPPORTED_CONNECTOR_ELEMENT_TYPES.includes(type);
};
export const FormbricksSurveySelector = ({
surveys,
selectedSurveyId,
selectedElementIds,
onSurveySelect,
onElementToggle,
onSelectAllElements,
onDeselectAllElements,
}: FormbricksSurveySelectorProps) => {
const { t } = useTranslation();
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
const supportedElements = selectedSurvey?.elements.filter((e) => !isUnsupportedType(e.type)) ?? [];
const allSupportedSelected =
supportedElements.length > 0 && supportedElements.every((e) => selectedElementIds.includes(e.id));
const handleSurveyClick = (survey: TUnifySurvey) => {
if (selectedSurveyId !== survey.id) {
onSurveySelect(survey.id);
}
};
const handleSelectAllSupported = (surveyId: string) => {
onSelectAllElements(surveyId);
};
const getStatusBadge = (status: TUnifySurvey["status"]) => {
switch (status) {
case "active":
return <Badge text={t("workspace.unify.status_active")} type="success" size="tiny" />;
case "paused":
return <Badge text={t("workspace.unify.status_paused")} type="warning" size="tiny" />;
case "draft":
return <Badge text={t("workspace.unify.status_draft")} type="gray" size="tiny" />;
case "completed":
return <Badge text={t("workspace.unify.status_completed")} type="gray" size="tiny" />;
default:
return null;
}
};
const getSupportedElementCount = (survey: TUnifySurvey) =>
survey.elements.filter((e) => !isUnsupportedType(e.type)).length;
const getElementButtonClassName = (unsupported: boolean, isSelected: boolean): string => {
if (unsupported) return "cursor-not-allowed border-slate-100 bg-slate-50 opacity-50";
if (isSelected) return "border-green-300 bg-green-50";
return "border-slate-200 bg-white hover:border-slate-300";
};
const getCheckboxClassName = (unsupported: boolean, isSelected: boolean): string => {
if (unsupported) return "border border-slate-200 bg-slate-100";
if (isSelected) return "bg-green-500 text-white";
return "border border-slate-300 bg-white";
};
const renderElementPanel = () => {
if (!selectedSurvey) {
return (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("workspace.unify.select_a_survey_to_see_questions")}</p>
</div>
);
}
if (selectedSurvey.elements.length === 0) {
return (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("workspace.unify.survey_has_no_questions")}</p>
</div>
);
}
return (
<div className="space-y-2 overflow-y-auto pr-1">
<TooltipProvider delayDuration={200}>
{selectedSurvey.elements.map((element) => {
const isSelected = selectedElementIds.includes(element.id);
const unsupported = isUnsupportedType(element.type);
const button = (
<button
key={element.id}
type="button"
disabled={unsupported}
onClick={() => onElementToggle(element.id)}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${getElementButtonClassName(unsupported, isSelected)}`}>
<div
className={`flex h-5 w-5 items-center justify-center rounded ${getCheckboxClassName(unsupported, isSelected)}`}>
{isSelected && !unsupported && <CheckIcon className="h-3 w-3" />}
</div>
<div className="flex items-center gap-2">{getElementIcon(element.type)}</div>
<div className="flex-1">
<p className={`text-sm ${unsupported ? "text-slate-400" : "text-slate-900"}`}>
{element.headline}
</p>
<span className={`text-xs ${unsupported ? "text-slate-300" : "text-slate-500"}`}>
{getTSurveyElementTypeEnumName(element.type, t) ?? element.type}
</span>
</div>
</button>
);
if (unsupported) {
return (
<Tooltip key={element.id}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{t("workspace.unify.question_type_not_supported")}</TooltipContent>
</Tooltip>
);
}
return button;
})}
</TooltipProvider>
{selectedElementIds.length > 0 && (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
<p className="text-xs text-blue-700">
<Trans
i18nKey={
selectedElementIds.length === 1
? "workspace.unify.question_selected"
: "workspace.unify.questions_selected"
}
values={{ count: selectedElementIds.length }}
components={{ strong: <strong /> }}
/>
</p>
</div>
)}
</div>
);
};
return (
<div className="grid h-[50vh] grid-cols-2 gap-6">
{/* Left: Survey List */}
<div className="flex flex-col gap-3 overflow-hidden">
<h4 className="shrink-0 text-sm font-medium text-slate-700">{t("workspace.unify.select_survey")}</h4>
<div className="space-y-2 overflow-y-auto pr-1">
{surveys.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("workspace.unify.no_surveys_found")}</p>
</div>
) : (
surveys.map((survey) => {
const isSelected = selectedSurveyId === survey.id;
return (
<div key={survey.id}>
<button
type="button"
onClick={() => handleSurveyClick(survey)}
className={`flex w-full items-center gap-3 rounded-lg border bg-white p-3 text-left transition-colors ${
isSelected ? "border-brand-dark bg-slate-50" : "border-slate-200 hover:border-slate-300"
}`}>
<div className="min-w-0 flex-1 space-y-1">
<div>{getStatusBadge(survey.status)}</div>
<span className="block truncate text-sm font-medium text-slate-900">{survey.name}</span>
<p className="text-xs text-slate-500">
{t("workspace.unify.n_supported_questions", {
count: getSupportedElementCount(survey),
})}
</p>
</div>
{isSelected && <ChevronRightIcon className="h-5 w-5 shrink-0 text-brand-dark" />}
</button>
</div>
);
})
)}
</div>
</div>
{/* Right: Element Selection */}
<div className="flex flex-col gap-3 overflow-hidden">
<div className="flex shrink-0 items-center justify-between">
<h4 className="text-sm font-medium text-slate-700">{t("workspace.unify.select_questions")}</h4>
{selectedSurvey && supportedElements.length > 0 && (
<button
type="button"
onClick={() =>
allSupportedSelected ? onDeselectAllElements() : handleSelectAllSupported(selectedSurvey.id)
}
className="text-xs text-slate-500 hover:text-slate-700">
{allSupportedSelected ? t("workspace.unify.deselect_all") : t("workspace.unify.select_all")}
</button>
)}
</div>
{renderElementPanel()}
</div>
</div>
);
};
@@ -1,42 +1,6 @@
import { notFound } from "next/navigation";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getSurveys } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { ConnectorsSection } from "./components/connectors-page-client";
import { transformToUnifySurvey } from "./lib";
import { redirect } from "next/navigation";
export default async function UnifySourcesPage(props: { params: Promise<{ workspaceId: string }> }) {
const t = await getTranslate();
const params = await props.params;
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
await getWorkspaceAuth(params.workspaceId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
if (!hasAccess) {
return notFound();
}
const [connectors, surveys, directories] = await Promise.all([
getConnectorsWithMappings(params.workspaceId),
getSurveys(params.workspaceId),
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
]);
const unifySurveys = surveys.map(transformToUnifySurvey);
return (
<ConnectorsSection
workspaceId={params.workspaceId}
initialConnectors={connectors}
initialSurveys={unifySurveys}
directories={directories}
/>
);
redirect(`/workspaces/${params.workspaceId}/feedback-sources`);
}
@@ -5,11 +5,13 @@ import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } from ".
const mockT = (key: string) => key;
describe("getConnectorOptions", () => {
test("returns formbricks and csv options", () => {
test("returns formbricks, csv, api ingestion, and mcp options", () => {
const options = getConnectorOptions(mockT as never);
expect(options).toHaveLength(2);
expect(options[0].id).toBe("formbricks");
expect(options).toHaveLength(4);
expect(options[0].id).toBe("formbricks_survey");
expect(options[1].id).toBe("csv");
expect(options[2].id).toBe("api_ingestion");
expect(options[3].id).toBe("feedback_record_mcp");
});
test("both options are enabled by default", () => {
@@ -23,6 +25,10 @@ describe("getConnectorOptions", () => {
expect(options[0].description).toBe("workspace.unify.source_connect_formbricks_description");
expect(options[1].name).toBe("workspace.unify.csv_import");
expect(options[1].description).toBe("workspace.unify.source_connect_csv_description");
expect(options[2].name).toBe("workspace.unify.api_ingestion");
expect(options[2].description).toBe("workspace.unify.api_ingestion_settings_description");
expect(options[3].name).toBe("workspace.unify.feedback_record_mcp");
expect(options[3].description).toBe("workspace.unify.source_connect_feedback_record_mcp_description");
});
});
@@ -1,9 +1,11 @@
import { TFunction } from "i18next";
import { THubFieldType } from "@formbricks/types/connector";
import { TConnectorType, THubFieldType } from "@formbricks/types/connector";
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
export type TConnectorOptionId = TConnectorType | "api_ingestion" | "feedback_record_mcp";
export interface TConnectorOption {
id: string;
id: TConnectorOptionId;
name: string;
description: string;
disabled: boolean;
@@ -12,7 +14,7 @@ export interface TConnectorOption {
export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
{
id: "formbricks",
id: "formbricks_survey",
name: t("workspace.unify.formbricks_surveys"),
description: t("workspace.unify.source_connect_formbricks_description"),
disabled: false,
@@ -23,6 +25,18 @@ export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
description: t("workspace.unify.source_connect_csv_description"),
disabled: false,
},
{
id: "api_ingestion",
name: t("workspace.unify.api_ingestion"),
description: t("workspace.unify.api_ingestion_settings_description"),
disabled: false,
},
{
id: "feedback_record_mcp",
name: t("workspace.unify.feedback_record_mcp"),
description: t("workspace.unify.source_connect_feedback_record_mcp_description"),
disabled: false,
},
];
export const parseCSVColumnsToFields = (columns: string): TSourceField[] => {
+61 -18
View File
@@ -98,6 +98,7 @@ checksums:
common/activity: 1948763de8e531483a798b68195e297e
common/add: 87c4a663507f2bcbbf79934af8164e13
common/add_action: 66fefc4dd6a7b939c2224272cf0d2669
common/add_chart: 0c8539d3ccc83fce87bb1e0dc3e30005
common/add_charts: c377a42e165e8ab67bfbb8ad72026dd8
common/add_existing_chart_description: b1292a1d6df2e03ad7b399689312c37f
common/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
@@ -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
@@ -363,6 +365,7 @@ checksums:
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_trial_license: 560df1240ef621f7c60d3f7d65422ccd
common/reset_to_default: 68ee98b46677392f44b505b268053b26
common/resize: 20887e5af5294f08bc72cdedeee6e7a8
common/response: c7a9d88269d8ff117abcbc0d97f88b2c
common/response_id: 73375099cc976dc7203b8e27f5f709e0
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
@@ -404,6 +407,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
@@ -1716,8 +1720,10 @@ checksums:
workspace/analysis/charts/please_select_dashboard: 8f062db96f815ed8268584dd8d292fa6
workspace/analysis/charts/predefined_measures: 7651141f62c991954edcff70899b2a8b
workspace/analysis/charts/preset: a17bb0bf56f3326c9567be3ea896ee19
workspace/analysis/charts/preview_chart: 8db30f87ba44165401f340a1ee7f549b
workspace/analysis/charts/query_executed_successfully: 9d6f9dad526fcfe0161757c2d2fe2c69
workspace/analysis/charts/reset_to_ai_suggestion: 51ced8dd7c0eea8b7fc4e08b35cfbf30
workspace/analysis/charts/save_and_add_to_dashboard: a76ed91c62dae10c5f8a9d48cbacd566
workspace/analysis/charts/save_chart: 2e4505f7bf3d1c35b0b37b1e9d3dc566
workspace/analysis/charts/save_chart_dialog_title: 2e4505f7bf3d1c35b0b37b1e9d3dc566
workspace/analysis/charts/select_data_source: 983394bc0182b65ec68f713a46b97302
@@ -1731,6 +1737,7 @@ checksums:
workspace/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84
workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
workspace/analysis/charts/update_chart: 7d9223335d9f0c5938ec30356d7034a9
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
@@ -1739,6 +1746,7 @@ checksums:
workspace/analysis/dashboards/create_dashboard: bedb308708fe9c576e161a2fa16d3439
workspace/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
workspace/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
workspace/analysis/dashboards/create_new_chart: e03c0fdf4b861454c09707d66fb9bf4c
workspace/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
workspace/analysis/dashboards/dashboard: c9380ea68c8c76ea451bd9613329a07c
workspace/analysis/dashboards/dashboard_delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
@@ -1753,11 +1761,13 @@ checksums:
workspace/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
workspace/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
workspace/analysis/dashboards/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
workspace/analysis/dashboards/no_charts_available_description: 796ed01bcb53f770e5f627002839dcb4
workspace/analysis/dashboards/no_charts_to_add_message: ad4cec703aa7d59c407bbb021dce4273
workspace/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
workspace/analysis/dashboards/no_data_message: 464d50cf30281a5b6af2726846eb14b4
workspace/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
workspace/analysis/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8
workspace/analysis/no_feedback_records_message: 67d6ebb9c040304789017d795ca474fc
workspace/analysis/no_feedback_records_with_sources_message: 4b72636a55afb4dcf977161ad5a15467
workspace/analysis/setup_feedback_source: 7cc5855a2b0c762fe2ae13b4921f3e92
workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da
workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985
workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c
@@ -2441,10 +2451,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 +3467,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 +3500,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 +3515,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 +3582,20 @@ 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_live_sync: 7e794257419414f57d34845ef38d0939
workspace/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
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 +3603,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
+47 -22
View File
@@ -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,
+1 -1
View File
@@ -57,7 +57,7 @@ describe("importCsvData", () => {
});
test("throws InvalidInputError for non-csv connector", async () => {
const connector = makeConnector({ type: "formbricks" });
const connector = makeConnector({ type: "formbricks_survey" });
await expect(importCsvData(connector, [])).rejects.toThrow(InvalidInputError);
});
+1 -1
View File
@@ -30,7 +30,7 @@ const mockConnector: TConnectorWithMappings = {
createdAt: NOW,
updatedAt: NOW,
name: "Test Connector",
type: "formbricks",
type: "formbricks_survey",
status: "active",
workspaceId: ENV_ID,
lastSyncAt: null,
+1 -1
View File
@@ -37,7 +37,7 @@ export const importHistoricalResponses = async (
connector: TConnectorWithMappings,
survey: TSurvey
): Promise<TImportResult> => {
if (connector.type !== "formbricks") {
if (connector.type !== "formbricks_survey") {
throw new InvalidInputError("Historical import is only supported for Formbricks connectors");
}
@@ -53,7 +53,7 @@ function createConnector(
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Connector",
type: "formbricks",
type: "formbricks_survey",
status: "active",
workspaceId: "env-1",
feedbackRecordDirectoryId: "frd-1",
@@ -79,7 +79,7 @@ const oneFeedbackRecord = [
{
field_id: "el-1",
field_type: "rating" as const,
source_type: "formbricks",
source_type: "formbricks_survey",
source_id: "survey-1",
source_name: "Test Survey",
field_label: "Question?",
+13 -8
View File
@@ -47,7 +47,7 @@ const mockConnector = {
createdAt: NOW,
updatedAt: NOW,
name: "Test Connector",
type: "formbricks" as const,
type: "formbricks_survey" as const,
status: "active" as const,
workspaceId: ENV_ID,
lastSyncAt: null,
@@ -144,7 +144,7 @@ describe("getConnectorsBySurveyId", () => {
expect(prisma.connector.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
type: "formbricks",
type: "formbricks_survey",
status: "active",
formbricksMappings: { some: { surveyId: SURVEY_ID } },
},
@@ -303,13 +303,18 @@ describe("createConnectorWithMappings", () => {
const result = await createConnectorWithMappings(ENV_ID, {
name: "New",
type: "formbricks",
type: "formbricks_survey",
feedbackRecordDirectoryId: FRD_ID,
});
expect(tx.connector.create).toHaveBeenCalledWith(
expect.objectContaining({
data: { name: "New", type: "formbricks", workspaceId: ENV_ID, feedbackRecordDirectoryId: FRD_ID },
data: {
name: "New",
type: "formbricks_survey",
workspaceId: ENV_ID,
feedbackRecordDirectoryId: FRD_ID,
},
})
);
expect(tx.connectorFormbricksMapping.create).not.toHaveBeenCalled();
@@ -325,9 +330,9 @@ describe("createConnectorWithMappings", () => {
await createConnectorWithMappings(
ENV_ID,
{ name: "FB", type: "formbricks", feedbackRecordDirectoryId: FRD_ID },
{ name: "FB", type: "formbricks_survey", feedbackRecordDirectoryId: FRD_ID },
{
type: "formbricks",
type: "formbricks_survey",
mappings: [
{ surveyId: SURVEY_ID, elementId: "el-1", hubFieldType: "text" },
{ surveyId: SURVEY_ID, elementId: "el-2", hubFieldType: "nps" },
@@ -392,7 +397,7 @@ describe("createConnectorWithMappings", () => {
await expect(
createConnectorWithMappings(ENV_ID, {
name: "Dup",
type: "formbricks",
type: "formbricks_survey",
feedbackRecordDirectoryId: FRD_ID,
})
).rejects.toThrow(InvalidInputError);
@@ -470,7 +475,7 @@ describe("updateConnectorWithMappings", () => {
ENV_ID,
{ name: "Updated" },
{
type: "formbricks",
type: "formbricks_survey",
mappings: [{ surveyId: SURVEY_ID, elementId: "el-new", hubFieldType: "nps" }],
}
);
+4 -4
View File
@@ -132,7 +132,7 @@ export const getConnectorsBySurveyId = reactCache(
try {
const connectors = await prisma.connector.findMany({
where: {
type: "formbricks",
type: "formbricks_survey",
status: "active",
formbricksMappings: {
some: {
@@ -213,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
export type TFormbricksMappingsInput = {
type: "formbricks";
type: "formbricks_survey";
mappings: TConnectorFormbricksMappingCreateInput[];
};
@@ -243,7 +243,7 @@ export const createConnectorWithMappings = async (
},
});
if (mappingsInput?.type === "formbricks") {
if (mappingsInput?.type === "formbricks_survey") {
await Promise.all(
mappingsInput.mappings.map((mapping) =>
tx.connectorFormbricksMapping.create({
@@ -311,7 +311,7 @@ export const updateConnectorWithMappings = async (
},
});
if (mappingsInput?.type === "formbricks") {
if (mappingsInput?.type === "formbricks_survey") {
await tx.connectorFormbricksMapping.deleteMany({
where: { connectorId, workspaceId },
});
+19 -1
View File
@@ -123,7 +123,7 @@ describe("transformResponseToFeedbackRecords", () => {
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
source_type: "formbricks",
source_type: "formbricks_survey",
field_id: "el-text",
field_type: "text",
field_label: "How can we improve?",
@@ -185,6 +185,24 @@ describe("transformResponseToFeedbackRecords", () => {
expect(result[0].collected_at).toBe(NOW.toISOString());
});
test("falls back to updatedAt when createdAt is missing", () => {
const updatedAt = new Date("2026-02-25T10:00:00.000Z");
const response = { ...mockResponse, createdAt: undefined, updatedAt } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].collected_at).toBe(updatedAt.toISOString());
});
test("parses string createdAt values for collected_at", () => {
const response = {
...mockResponse,
createdAt: "2026-02-26T10:00:00.000Z",
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].collected_at).toBe("2026-02-26T10:00:00.000Z");
});
test("includes tenant_id when provided", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, "tenant-abc");
+19 -3
View File
@@ -14,6 +14,23 @@ const getHeadlineFromElement = (element?: TSurveyElement): string => {
return getTextContent(raw) || "Untitled";
};
const toIsoTimestamp = (value: unknown): string | null => {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "string" || typeof value === "number") {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
return null;
};
const getCollectedAt = (response: TResponse): string => {
return toIsoTimestamp(response.createdAt) ?? toIsoTimestamp(response.updatedAt) ?? new Date().toISOString();
};
function extractResponseValue(responseData: TResponseData, elementId: string): TResponseDataValue {
if (!responseData || typeof responseData !== "object") return undefined;
return responseData[elementId];
@@ -99,9 +116,8 @@ export function transformResponseToFeedbackRecords(
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
const feedbackRecord = {
collected_at:
response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt),
source_type: "formbricks",
collected_at: getCollectedAt(response),
source_type: "formbricks_survey",
submission_id: response.id,
tenant_id: tenantId,
field_id: mapping.elementId,
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "Aktivität",
"add": "Hinzufügen",
"add_action": "Aktion hinzufügen",
"add_chart": "Diagramm hinzufügen",
"add_charts": "Diagramme hinzufügen",
"add_existing_chart_description": "Suche und wähle Diagramme aus, um sie zu diesem Dashboard hinzuzufügen.",
"add_filter": "Filter hinzufügen",
@@ -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",
@@ -390,6 +392,7 @@
"report_survey": "Umfrage melden",
"request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen",
"resize": "Größe ändern",
"response": "Antwort",
"response_id": "Antwort-ID",
"responses": "Antworten",
@@ -431,6 +434,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",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "Bitte wähle ein Dashboard aus",
"predefined_measures": "Vordefinierte Kennzahlen",
"preset": "Vorlage",
"preview_chart": "Vorschaudiagramm",
"query_executed_successfully": "Abfrage erfolgreich ausgeführt",
"reset_to_ai_suggestion": "Auf KI-Vorschlag zurücksetzen",
"save_and_add_to_dashboard": "Speichern und zum Dashboard hinzufügen",
"save_chart": "Diagramm speichern",
"save_chart_dialog_title": "Diagramm speichern",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "Startdatum",
"time_dimension": "Zeitdimension",
"time_dimension_title": "Zeitbasierte Gruppierung hinzufügen",
"time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf."
"time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf.",
"update_chart": "Diagramm aktualisieren"
},
"dashboards": {
"add_count_charts": "{count} Diagramm(e) hinzufügen",
@@ -1806,6 +1813,7 @@
"create_dashboard": "Dashboard erstellen",
"create_dashboard_description": "Gib einen Namen für dein neues Dashboard ein.",
"create_failed": "Dashboard konnte nicht erstellt werden",
"create_new_chart": "Neues Diagramm erstellen",
"create_success": "Dashboard erfolgreich erstellt!",
"dashboard": "Dashboard",
"dashboard_delete_confirmation": "Bist du sicher, dass du dieses Dashboard löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "Dashboard konnte nicht dupliziert werden",
"duplicate_success": "Dashboard erfolgreich dupliziert!",
"failed_to_load_chart_data": "Diagrammdaten konnten nicht geladen werden",
"no_charts_available_description": "Es gibt keine Diagramme, die zu diesem Dashboard hinzugefügt werden können. Entweder existieren noch keine Diagramme oder alle vorhandenen Diagramme wurden bereits hinzugefügt. Gehe zur Diagramm-Seite, um neue Diagramme zu erstellen.",
"no_charts_to_add_message": "Keine Diagramme zum Hinzufügen zu diesem Dashboard vorhanden.",
"no_dashboards_found": "Keine Dashboards gefunden.",
"no_data_message": "Keine Daten. Es gibt derzeit keine Informationen zum Anzeigen. Füge Diagramme hinzu, um dein Dashboard zu erstellen.",
"please_enter_name": "Bitte gib einen Dashboard-Namen ein"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Sie haben keine Feedback-Datensätze, über die Sie berichten können. Richten Sie Feedbackquellen ein, um Daten in das System einzuspeisen.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Richten Sie Feedbackquellen ein"
},
"api_keys": {
"add_api_key": "API-Key hinzufügen",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Live-Synchronisierung",
"status_paused": "Pausiert",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "Activity",
"add": "Add",
"add_action": "Add action",
"add_chart": "Add chart",
"add_charts": "Add charts",
"add_existing_chart_description": "Search and select charts to add to this dashboard.",
"add_filter": "Add filter",
@@ -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",
@@ -390,6 +392,7 @@
"report_survey": "Report Survey",
"request_trial_license": "Request trial license",
"reset_to_default": "Reset to default",
"resize": "Resize",
"response": "Response",
"response_id": "Response ID",
"responses": "Responses",
@@ -431,6 +434,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",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "Please select a dashboard",
"predefined_measures": "Predefined Measures",
"preset": "Preset",
"preview_chart": "Preview chart",
"query_executed_successfully": "Query executed successfully",
"reset_to_ai_suggestion": "Reset to AI suggestion",
"save_and_add_to_dashboard": "Save & add to dashboard",
"save_chart": "Save Chart",
"save_chart_dialog_title": "Save Chart",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "Start date",
"time_dimension": "Time Dimension",
"time_dimension_title": "Add time-based grouping",
"time_dimension_toggle_description": "Monitor trends over time."
"time_dimension_toggle_description": "Monitor trends over time.",
"update_chart": "Update chart"
},
"dashboards": {
"add_count_charts": "Add {count} chart(s)",
@@ -1806,6 +1813,7 @@
"create_dashboard": "Create dashboard",
"create_dashboard_description": "Enter a name for your new dashboard.",
"create_failed": "Failed to create dashboard",
"create_new_chart": "Create new chart",
"create_success": "Dashboard created successfully!",
"dashboard": "Dashboard",
"dashboard_delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "Failed to duplicate dashboard",
"duplicate_success": "Dashboard duplicated successfully!",
"failed_to_load_chart_data": "Failed to load chart data",
"no_charts_available_description": "There are no charts that can be added to this dashboard. Either no charts exist yet, or all existing charts have already been added. Go to the Charts page to create new charts.",
"no_charts_to_add_message": "No charts to add to this dashboard.",
"no_dashboards_found": "No dashboards found.",
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
"please_enter_name": "Please enter a dashboard name"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "You don't have Feedback Records to report on. Setup Feedback Sources to feed data into the system.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Setup feedback sources"
},
"api_keys": {
"add_api_key": "Add API Key",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Live sync",
"status_paused": "Paused",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "Actividad",
"add": "Añadir",
"add_action": "Añadir acción",
"add_chart": "Agregar gráfico",
"add_charts": "Añadir gráficos",
"add_existing_chart_description": "Busca y selecciona gráficos para añadir a este panel.",
"add_filter": "Añadir filtro",
@@ -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",
@@ -390,6 +392,7 @@
"report_survey": "Reportar encuesta",
"request_trial_license": "Solicitar licencia de prueba",
"reset_to_default": "Restablecer a valores predeterminados",
"resize": "Cambiar tamaño",
"response": "Respuesta",
"response_id": "ID de respuesta",
"responses": "Respuestas",
@@ -431,6 +434,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",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "Selecciona un panel de control",
"predefined_measures": "Medidas predefinidas",
"preset": "Preajuste",
"preview_chart": "Vista previa del gráfico",
"query_executed_successfully": "Consulta ejecutada correctamente",
"reset_to_ai_suggestion": "Restablecer a sugerencia de IA",
"save_and_add_to_dashboard": "Guardar y agregar al panel",
"save_chart": "Guardar gráfico",
"save_chart_dialog_title": "Guardar gráfico",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "Fecha de inicio",
"time_dimension": "Dimensión temporal",
"time_dimension_title": "Añadir agrupación temporal",
"time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo."
"time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo.",
"update_chart": "Cuadro de actualización"
},
"dashboards": {
"add_count_charts": "Añadir {count} gráfico(s)",
@@ -1806,6 +1813,7 @@
"create_dashboard": "Crear panel",
"create_dashboard_description": "Introduce un nombre para tu panel de control nuevo.",
"create_failed": "Error al crear el panel de control",
"create_new_chart": "Crear nuevo gráfico",
"create_success": "Panel de control creado correctamente",
"dashboard": "Panel",
"dashboard_delete_confirmation": "¿Estás seguro de que quieres eliminar este panel? Esta acción no se puede deshacer.",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "Error al duplicar el panel de control",
"duplicate_success": "Panel de control duplicado correctamente",
"failed_to_load_chart_data": "Error al cargar los datos del gráfico",
"no_charts_available_description": "No hay gráficos que se puedan añadir a este panel. O bien no existen gráficos todavía, o todos los gráficos existentes ya se han añadido. Ve a la página de Gráficos para crear nuevos gráficos.",
"no_charts_to_add_message": "No hay gráficos para añadir a este panel.",
"no_dashboards_found": "No se han encontrado paneles de control.",
"no_data_message": "Sin datos. Actualmente no hay información que mostrar. Añade gráficos para crear tu panel.",
"please_enter_name": "Por favor, introduce un nombre para el panel de control"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "No tienes registros de comentarios sobre los que informar. Configure fuentes de comentarios para introducir datos en el sistema.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurar fuentes de comentarios"
},
"api_keys": {
"add_api_key": "Añadir clave API",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Sincronización en vivo",
"status_paused": "Pausado",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "Activité",
"add": "Ajouter",
"add_action": "Ajouter une action",
"add_chart": "Ajouter un graphique",
"add_charts": "Ajouter des graphiques",
"add_existing_chart_description": "Recherchez et sélectionnez des graphiques à ajouter à ce tableau de bord.",
"add_filter": "Ajouter un filtre",
@@ -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",
@@ -390,6 +392,7 @@
"report_survey": "Rapport d'enquête",
"request_trial_license": "Demander une licence d'essai",
"reset_to_default": "Réinitialiser par défaut",
"resize": "Redimensionner",
"response": "Réponse",
"response_id": "ID de réponse",
"responses": "Réponses",
@@ -431,6 +434,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",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "Veuillez sélectionner un tableau de bord",
"predefined_measures": "Mesures prédéfinies",
"preset": "Préréglage",
"preview_chart": "Aperçu du graphique",
"query_executed_successfully": "Requête exécutée avec succès",
"reset_to_ai_suggestion": "Réinitialiser à la suggestion IA",
"save_and_add_to_dashboard": "Enregistrer et ajouter au tableau de bord",
"save_chart": "Enregistrer le graphique",
"save_chart_dialog_title": "Enregistrer le graphique",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "Date de début",
"time_dimension": "Dimension temporelle",
"time_dimension_title": "Ajouter un groupement temporel",
"time_dimension_toggle_description": "Surveille les tendances dans le temps."
"time_dimension_toggle_description": "Surveille les tendances dans le temps.",
"update_chart": "Mettre à jour le graphique"
},
"dashboards": {
"add_count_charts": "Ajouter {count} graphique(s)",
@@ -1806,6 +1813,7 @@
"create_dashboard": "Créer un tableau de bord",
"create_dashboard_description": "Saisissez un nom pour votre nouveau tableau de bord.",
"create_failed": "Échec de la création du tableau de bord",
"create_new_chart": "Créer un nouveau graphique",
"create_success": "Tableau de bord créé avec succès!",
"dashboard": "Tableau de bord",
"dashboard_delete_confirmation": "Es-tu sûr(e) de vouloir supprimer ce tableau de bord ? Cette action est irréversible.",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "Échec de la duplication du tableau de bord",
"duplicate_success": "Tableau de bord dupliqué avec succès!",
"failed_to_load_chart_data": "Échec du chargement des données du graphique",
"no_charts_available_description": "Il n'y a aucun graphique pouvant être ajouté à ce tableau de bord. Soit aucun graphique n'existe encore, soit tous les graphiques existants ont déjà été ajoutés. Rendez-vous sur la page Graphiques pour créer de nouveaux graphiques.",
"no_charts_to_add_message": "Aucun graphique à ajouter à ce tableau de bord.",
"no_dashboards_found": "Aucun tableau de bord trouvé.",
"no_data_message": "Aucune donnée. Il n'y a actuellement aucune information à afficher. Ajoute des graphiques pour construire ton tableau de bord.",
"please_enter_name": "Veuillez saisir un nom de tableau de bord"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Vous n'avez pas d'enregistrements de commentaires sur lesquels créer des rapports. Configurez des sources de commentaires pour introduire des données dans le système.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurer les sources de commentaires"
},
"api_keys": {
"add_api_key": "Ajouter une clé API",
@@ -2548,10 +2558,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 à lespace de travail"
},
"general": {
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
@@ -3613,16 +3627,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 +3660,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 +3675,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 denregistrement 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 +3742,31 @@
"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_live_sync": "Synchronisation en direct",
"status_paused": "En pause",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "Tevékenység",
"add": "Hozzáadás",
"add_action": "Művelet hozzáadása",
"add_chart": "Diagram hozzáadása",
"add_charts": "Diagramok hozzáadása",
"add_existing_chart_description": "Keressen és válasszon diagramokat a műszerfalhoz való hozzáadáshoz.",
"add_filter": "Szűrő hozzáadása",
@@ -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",
@@ -390,6 +392,7 @@
"report_survey": "Kérdőív jelentése",
"request_trial_license": "Próbaidőszaki licenc kérése",
"reset_to_default": "Visszaállítás az alapértelmezettre",
"resize": "Átméretezés",
"response": "Válasz",
"response_id": "Válaszazonosító",
"responses": "Válaszok",
@@ -431,6 +434,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",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "Kérjük, válassz egy vezérlőpultot",
"predefined_measures": "Előre definiált mérőszámok",
"preset": "Előbeállítás",
"preview_chart": "Előnézet diagram",
"query_executed_successfully": "Lekérdezés sikeresen végrehajtva",
"reset_to_ai_suggestion": "Visszaállítás AI javaslatra",
"save_and_add_to_dashboard": "Mentés és hozzáadása az irányítópulthoz",
"save_chart": "Diagram mentése",
"save_chart_dialog_title": "Diagram mentése",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "Kezdési dátum",
"time_dimension": "Időbeli dimenzió",
"time_dimension_title": "Időalapú csoportosítás hozzáadása",
"time_dimension_toggle_description": "Trendek figyelemmel kísérése az idő függvényében."
"time_dimension_toggle_description": "Trendek figyelemmel kísérése az idő függvényében.",
"update_chart": "Frissítse a diagramot"
},
"dashboards": {
"add_count_charts": "{count} diagram hozzáadása",
@@ -1806,6 +1813,7 @@
"create_dashboard": "Műszerfal létrehozása",
"create_dashboard_description": "Adjon nevet az új vezérlőpultnak.",
"create_failed": "A vezérlőpult létrehozása sikertelen",
"create_new_chart": "Új diagram létrehozása",
"create_success": "A vezérlőpult sikeresen létrehozva!",
"dashboard": "Műszerfal",
"dashboard_delete_confirmation": "Biztos benne, hogy törölni kívánja ezt a műszerfalat? Ez a művelet nem vonható vissza.",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "A vezérlőpult másolása sikertelen",
"duplicate_success": "A vezérlőpult sikeresen lemásolva!",
"failed_to_load_chart_data": "A diagram adatainak betöltése sikertelen",
"no_charts_available_description": "Nincsenek diagramok, amelyek hozzáadhatók ehhez az irányítópulthoz. Vagy még nem léteznek diagramok, vagy az összes meglévő diagram már hozzá lett adva. Látogassa meg a Diagramok oldalt új diagramok létrehozásához.",
"no_charts_to_add_message": "Nincsenek hozzáadható diagramok ehhez az irányítópulthoz.",
"no_dashboards_found": "Nem található vezérlőpult.",
"no_data_message": "Nincsenek adatok. Jelenleg nincsenek megjeleníthető információk. Adjon hozzá diagramokat az irányítópult felépítéséhez.",
"please_enter_name": "Kérjük, adjon nevet a vezérlőpultnak"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Nincsenek visszajelzési rekordjai, amelyekről jelentést tehetne. Állítsa be a visszacsatolási forrásokat, hogy adatokat tápláljon be a rendszerbe.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Visszajelzési források beállítása"
},
"api_keys": {
"add_api_key": "API-kulcs hozzáadása",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Élő szinkronizálás",
"status_paused": "Szüneteltetve",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "アクティビティ",
"add": "追加",
"add_action": "アクションを追加",
"add_chart": "チャートを追加",
"add_charts": "グラフを追加",
"add_existing_chart_description": "このダッシュボードに追加するグラフを検索して選択してください。",
"add_filter": "フィルターを追加",
@@ -331,6 +332,7 @@
"not_authenticated": "このアクションを実行するための認証がされていません。",
"not_authorized": "権限がありません",
"not_connected": "未接続",
"not_set": "未設定",
"note": "メモ",
"notifications": "通知",
"number": "数値",
@@ -390,6 +392,7 @@
"report_survey": "フォームを報告",
"request_trial_license": "トライアルライセンスをリクエスト",
"reset_to_default": "デフォルトにリセット",
"resize": "サイズ変更",
"response": "回答",
"response_id": "回答ID",
"responses": "回答",
@@ -431,6 +434,7 @@
"some_files_failed_to_upload": "一部のファイルのアップロードに失敗しました",
"something_went_wrong": "問題が発生しました",
"something_went_wrong_please_try_again": "問題が発生しました。もう一度お試しください。",
"soon": "近日公開",
"sort_by": "並び替え",
"start_free_trial": "無料トライアルを開始",
"status": "ステータス",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "ダッシュボードを選択してください",
"predefined_measures": "事前定義されたメジャー",
"preset": "プリセット",
"preview_chart": "グラフのプレビュー",
"query_executed_successfully": "クエリが正常に実行されました",
"reset_to_ai_suggestion": "AIの提案にリセット",
"save_and_add_to_dashboard": "保存してダッシュボードに追加",
"save_chart": "チャートを保存",
"save_chart_dialog_title": "チャートを保存",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "開始日",
"time_dimension": "時間ディメンション",
"time_dimension_title": "時間ベースのグループ化を追加",
"time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。"
"time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。",
"update_chart": "チャートを更新"
},
"dashboards": {
"add_count_charts": "{count}個のグラフを追加",
@@ -1806,6 +1813,7 @@
"create_dashboard": "ダッシュボードを作成",
"create_dashboard_description": "新しいダッシュボードの名前を入力してください。",
"create_failed": "ダッシュボードの作成に失敗しました",
"create_new_chart": "新しいチャートを作成する",
"create_success": "ダッシュボードを正常に作成しました!",
"dashboard": "ダッシュボード",
"dashboard_delete_confirmation": "このダッシュボードを削除してもよろしいですか?この操作は元に戻せません。",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "ダッシュボードの複製に失敗しました",
"duplicate_success": "ダッシュボードを正常に複製しました!",
"failed_to_load_chart_data": "チャートデータの読み込みに失敗しました",
"no_charts_available_description": "このダッシュボードに追加できるチャートがありません。チャートがまだ存在しないか、既存のチャートがすべて追加済みです。新しいチャートを作成するには、チャートページに移動してください。",
"no_charts_to_add_message": "このダッシュボードに追加するチャートがありません。",
"no_dashboards_found": "ダッシュボードが見つかりません。",
"no_data_message": "データがありません。現在表示する情報がありません。ダッシュボードを構築するにはチャートを追加してください。",
"please_enter_name": "ダッシュボード名を入力してください"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "レポートするフィードバック レコードがありません。データをシステムにフィードするためのフィードバック ソースをセットアップします。",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "フィードバックソースのセットアップ"
},
"api_keys": {
"add_api_key": "APIキーを追加",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "リアルタイム同期",
"status_paused": "一時停止",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "Activiteit",
"add": "Toevoegen",
"add_action": "Actie toevoegen",
"add_chart": "Diagram toevoegen",
"add_charts": "Grafieken toevoegen",
"add_existing_chart_description": "Zoek en selecteer grafieken om toe te voegen aan dit dashboard.",
"add_filter": "Filter toevoegen",
@@ -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",
@@ -390,6 +392,7 @@
"report_survey": "Enquête melden",
"request_trial_license": "Proeflicentie aanvragen",
"reset_to_default": "Resetten naar standaard",
"resize": "Formaat wijzigen",
"response": "Antwoord",
"response_id": "Antwoord-ID",
"responses": "Reacties",
@@ -431,6 +434,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",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "Selecteer een dashboard",
"predefined_measures": "Vooraf gedefinieerde metingen",
"preset": "Voorinstelling",
"preview_chart": "Voorbeeldgrafiek",
"query_executed_successfully": "Query succesvol uitgevoerd",
"reset_to_ai_suggestion": "Herstel naar AI-suggestie",
"save_and_add_to_dashboard": "Opslaan en toevoegen aan dashboard",
"save_chart": "Diagram opslaan",
"save_chart_dialog_title": "Diagram opslaan",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "Startdatum",
"time_dimension": "Tijdsdimensie",
"time_dimension_title": "Tijdgebaseerde groepering toevoegen",
"time_dimension_toggle_description": "Volg trends over tijd."
"time_dimension_toggle_description": "Volg trends over tijd.",
"update_chart": "Diagram bijwerken"
},
"dashboards": {
"add_count_charts": "{count} grafiek(en) toevoegen",
@@ -1806,6 +1813,7 @@
"create_dashboard": "Dashboard maken",
"create_dashboard_description": "Voer een naam in voor je nieuwe dashboard.",
"create_failed": "Dashboard creëren mislukt",
"create_new_chart": "Maak een nieuw diagram",
"create_success": "Dashboard succesvol aangemaakt!",
"dashboard": "Dashboard",
"dashboard_delete_confirmation": "Weet je zeker dat je dit dashboard wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "Dashboard dupliceren mislukt",
"duplicate_success": "Dashboard succesvol gedupliceerd!",
"failed_to_load_chart_data": "Grafiekgegevens laden mislukt",
"no_charts_available_description": "Er zijn geen grafieken die aan dit dashboard kunnen worden toegevoegd. Er bestaan nog geen grafieken, of alle bestaande grafieken zijn al toegevoegd. Ga naar de pagina Grafieken om nieuwe grafieken te maken.",
"no_charts_to_add_message": "Geen grafieken om toe te voegen aan dit dashboard.",
"no_dashboards_found": "Geen dashboards gevonden.",
"no_data_message": "Geen gegevens. Er is momenteel geen informatie om weer te geven. Voeg grafieken toe om je dashboard op te bouwen.",
"please_enter_name": "Voer een dashboardnaam in"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "U heeft geen feedbackrecords om over te rapporteren. Stel feedbackbronnen in om gegevens in het systeem in te voeren.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Feedbackbronnen instellen"
},
"api_keys": {
"add_api_key": "API-sleutel toevoegen",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Live synchronisatie",
"status_paused": "Gepauzeerd",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "Atividade",
"add": "Adicionar",
"add_action": "Adicionar ação",
"add_chart": "Adicionar gráfico",
"add_charts": "Adicionar gráficos",
"add_existing_chart_description": "Pesquise e selecione gráficos para adicionar a este painel.",
"add_filter": "Adicionar filtro",
@@ -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",
@@ -390,6 +392,7 @@
"report_survey": "Relatório de Pesquisa",
"request_trial_license": "Pedir licença de teste",
"reset_to_default": "Restaurar para o padrão",
"resize": "Redimensionar",
"response": "Resposta",
"response_id": "ID da resposta",
"responses": "Respostas",
@@ -431,6 +434,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",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "Por favor, selecione um painel",
"predefined_measures": "Medidas predefinidas",
"preset": "Predefinição",
"preview_chart": "Visualizar gráfico",
"query_executed_successfully": "Consulta executada com sucesso",
"reset_to_ai_suggestion": "Redefinir para sugestão da IA",
"save_and_add_to_dashboard": "Salvar e adicionar ao painel",
"save_chart": "Salvar gráfico",
"save_chart_dialog_title": "Salvar gráfico",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "Data inicial",
"time_dimension": "Dimensão temporal",
"time_dimension_title": "Adicionar agrupamento por tempo",
"time_dimension_toggle_description": "Monitore tendências ao longo do tempo."
"time_dimension_toggle_description": "Monitore tendências ao longo do tempo.",
"update_chart": "Atualizar gráfico"
},
"dashboards": {
"add_count_charts": "Adicionar {count} gráfico(s)",
@@ -1806,6 +1813,7 @@
"create_dashboard": "Criar painel",
"create_dashboard_description": "Digite um nome para o seu novo painel.",
"create_failed": "Falha ao criar painel",
"create_new_chart": "Criar novo gráfico",
"create_success": "Painel criado com sucesso!",
"dashboard": "Painel",
"dashboard_delete_confirmation": "Tem certeza de que deseja excluir este painel? Esta ação não pode ser desfeita.",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "Falha ao duplicar painel",
"duplicate_success": "Painel duplicado com sucesso!",
"failed_to_load_chart_data": "Falha ao carregar os dados do gráfico",
"no_charts_available_description": "Não há gráficos que possam ser adicionados a este painel. Ou nenhum gráfico existe ainda, ou todos os gráficos existentes já foram adicionados. Vá para a página de Gráficos para criar novos gráficos.",
"no_charts_to_add_message": "Nenhum gráfico para adicionar a este painel.",
"no_dashboards_found": "Nenhum painel encontrado.",
"no_data_message": "Sem Dados. Não há informações para exibir no momento. Adicione gráficos para construir seu painel.",
"please_enter_name": "Por favor, digite um nome para o painel"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Você não tem registros de feedback para relatar. Configure fontes de feedback para alimentar dados no sistema.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurar fontes de feedback"
},
"api_keys": {
"add_api_key": "Adicionar chave de API",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Sincronização ao vivo",
"status_paused": "Pausado",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "Atividade",
"add": "Adicionar",
"add_action": "Adicionar ação",
"add_chart": "Adicionar gráfico",
"add_charts": "Adicionar gráficos",
"add_existing_chart_description": "Pesquisa e seleciona gráficos para adicionar a este painel.",
"add_filter": "Adicionar filtro",
@@ -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",
@@ -390,6 +392,7 @@
"report_survey": "Relatório de Inquérito",
"request_trial_license": "Solicitar licença de teste",
"reset_to_default": "Repor para o padrão",
"resize": "Redimensionar",
"response": "Resposta",
"response_id": "ID de resposta",
"responses": "Respostas",
@@ -431,6 +434,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",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "Por favor, seleciona um painel",
"predefined_measures": "Medidas predefinidas",
"preset": "Predefinição",
"preview_chart": "Visualizar gráfico",
"query_executed_successfully": "Consulta executada com sucesso",
"reset_to_ai_suggestion": "Repor sugestão da IA",
"save_and_add_to_dashboard": "Salvar e adicionar ao painel",
"save_chart": "Guardar gráfico",
"save_chart_dialog_title": "Guardar gráfico",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "Data de início",
"time_dimension": "Dimensão temporal",
"time_dimension_title": "Adicionar agrupamento temporal",
"time_dimension_toggle_description": "Monitoriza tendências ao longo do tempo."
"time_dimension_toggle_description": "Monitoriza tendências ao longo do tempo.",
"update_chart": "Atualizar gráfico"
},
"dashboards": {
"add_count_charts": "Adicionar {count} gráfico(s)",
@@ -1806,6 +1813,7 @@
"create_dashboard": "Criar painel",
"create_dashboard_description": "Introduza um nome para o seu novo painel.",
"create_failed": "Falha ao criar painel",
"create_new_chart": "Criar novo gráfico",
"create_success": "Painel criado com sucesso!",
"dashboard": "Painel",
"dashboard_delete_confirmation": "Tens a certeza de que queres eliminar este painel? Esta ação não pode ser revertida.",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "Falha ao duplicar painel",
"duplicate_success": "Painel duplicado com sucesso!",
"failed_to_load_chart_data": "Falha ao carregar os dados do gráfico",
"no_charts_available_description": "Não há gráficos que possam ser adicionados a este painel. Ou ainda não existem gráficos, ou todos os gráficos existentes já foram adicionados. Vai à página de Gráficos para criar novos gráficos.",
"no_charts_to_add_message": "Não há gráficos para adicionar a este painel.",
"no_dashboards_found": "Nenhum painel encontrado.",
"no_data_message": "Sem Dados. Atualmente não há informação para apresentar. Adiciona gráficos para construir o teu painel.",
"please_enter_name": "Por favor, introduza um nome para o painel"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Você não tem registros de feedback para relatar. Configure fontes de feedback para alimentar dados no sistema.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurar fontes de feedback"
},
"api_keys": {
"add_api_key": "Adicionar chave API",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Sincronização em direto",
"status_paused": "Em pausa",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "Activitate",
"add": "Adaugă",
"add_action": "Adăugați acțiune",
"add_chart": "Adăugați diagramă",
"add_charts": "Adaugă grafice",
"add_existing_chart_description": "Caută și selectează grafice pentru a le adăuga la acest panou de control.",
"add_filter": "Adăugați filtru",
@@ -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",
@@ -390,6 +392,7 @@
"report_survey": "Raportează chestionarul",
"request_trial_license": "Solicitați o licență de încercare",
"reset_to_default": "Revino la implicit",
"resize": "Redimensionați",
"response": "Răspuns",
"response_id": "ID răspuns",
"responses": "Răspunsuri",
@@ -431,6 +434,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",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "Te rugăm să selectezi un tablou de bord",
"predefined_measures": "Măsurători predefinite",
"preset": "Presetare",
"preview_chart": "Previzualizare diagramă",
"query_executed_successfully": "Interogarea a fost executată cu succes",
"reset_to_ai_suggestion": "Resetează la sugestia AI",
"save_and_add_to_dashboard": "Salvați și adăugați în tabloul de bord",
"save_chart": "Salvează graficul",
"save_chart_dialog_title": "Salvează graficul",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "Data de început",
"time_dimension": "Dimensiune temporală",
"time_dimension_title": "Adaugă grupare pe bază de timp",
"time_dimension_toggle_description": "Monitorizează tendințele în timp."
"time_dimension_toggle_description": "Monitorizează tendințele în timp.",
"update_chart": "Actualizați diagrama"
},
"dashboards": {
"add_count_charts": "Adaugă {count} grafic(e)",
@@ -1806,6 +1813,7 @@
"create_dashboard": "Creează panou de control",
"create_dashboard_description": "Introdu un nume pentru noul tău tablou de bord.",
"create_failed": "Crearea tabloului de bord a eșuat",
"create_new_chart": "Creați o nouă diagramă",
"create_success": "Tablou de bord creat cu succes!",
"dashboard": "Panou de control",
"dashboard_delete_confirmation": "Ești sigur că vrei să ștergi acest panou de control? Această acțiune nu poate fi anulată.",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "Duplicarea tabloului de bord a eșuat",
"duplicate_success": "Tablou de bord duplicat cu succes!",
"failed_to_load_chart_data": "Încărcarea datelor graficului a eșuat",
"no_charts_available_description": "Nu există grafice care pot fi adăugate la acest tablou de bord. Fie nu există încă grafice, fie toate graficele existente au fost deja adăugate. Mergi la pagina Grafice pentru a crea grafice noi.",
"no_charts_to_add_message": "Nu există grafice de adăugat la acest tablou de bord.",
"no_dashboards_found": "Nu s-a găsit niciun tablou de bord.",
"no_data_message": "Fără date. În prezent nu există informații de afișat. Adaugă grafice pentru a-ți construi tabloul de bord.",
"please_enter_name": "Te rugăm să introduci un nume pentru tablou de bord"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Nu aveți înregistrări de feedback despre care să raportați. Configurați sursele de feedback pentru a introduce date în sistem.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurați sursele de feedback"
},
"api_keys": {
"add_api_key": "Adaugă cheie API",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Sincronizare în timp real",
"status_paused": "Pauzat",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "Активность",
"add": "Добавить",
"add_action": "Добавить действие",
"add_chart": "Добавить диаграмму",
"add_charts": "Добавить графики",
"add_existing_chart_description": "Найдите и выберите графики для добавления на этот дашборд.",
"add_filter": "Добавить фильтр",
@@ -331,6 +332,7 @@
"not_authenticated": "У вас нет прав для выполнения этого действия.",
"not_authorized": "Нет доступа",
"not_connected": "Нет подключения",
"not_set": "Не установлено",
"note": "Примечание",
"notifications": "Уведомления",
"number": "Номер",
@@ -390,6 +392,7 @@
"report_survey": "Пожаловаться на опрос",
"request_trial_license": "Запросить пробную лицензию",
"reset_to_default": "Сбросить по умолчанию",
"resize": "Изменить размер",
"response": "Ответ",
"response_id": "ID ответа",
"responses": "Ответы",
@@ -431,6 +434,7 @@
"some_files_failed_to_upload": "Не удалось загрузить некоторые файлы",
"something_went_wrong": "Что-то пошло не так",
"something_went_wrong_please_try_again": "Что-то пошло не так. Пожалуйста, попробуйте ещё раз.",
"soon": "Скоро",
"sort_by": "Сортировать по",
"start_free_trial": "Начать бесплатный пробный период",
"status": "Статус",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "Пожалуйста, выбери панель управления",
"predefined_measures": "Предустановленные показатели",
"preset": "Пресет",
"preview_chart": "Предварительный просмотр диаграммы",
"query_executed_successfully": "Запрос успешно выполнен",
"reset_to_ai_suggestion": "Сбросить к предложению ИИ",
"save_and_add_to_dashboard": "Сохранить и добавить на панель управления",
"save_chart": "Сохранить график",
"save_chart_dialog_title": "Сохранить график",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "Дата начала",
"time_dimension": "Временное измерение",
"time_dimension_title": "Добавить группировку по времени",
"time_dimension_toggle_description": "Отслеживайте тренды с течением времени."
"time_dimension_toggle_description": "Отслеживайте тренды с течением времени.",
"update_chart": "Обновить диаграмму"
},
"dashboards": {
"add_count_charts": "Добавить {count} график(ов)",
@@ -1806,6 +1813,7 @@
"create_dashboard": "Создать дашборд",
"create_dashboard_description": "Введите название для новой панели управления.",
"create_failed": "Не удалось создать панель управления",
"create_new_chart": "Создать новую диаграмму",
"create_success": "Панель управления успешно создана!",
"dashboard": "Дашборд",
"dashboard_delete_confirmation": "Вы уверены, что хотите удалить этот дашборд? Это действие нельзя отменить.",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "Не удалось дублировать панель управления",
"duplicate_success": "Панель управления успешно продублирована!",
"failed_to_load_chart_data": "Не удалось загрузить данные графика",
"no_charts_available_description": "Нет графиков, которые можно добавить к этому дашборду. Либо графики ещё не созданы, либо все существующие графики уже добавлены. Перейдите на страницу «Графики», чтобы создать новые графики.",
"no_charts_to_add_message": "Нет графиков для добавления к этому дашборду.",
"no_dashboards_found": "Панели управления не найдены.",
"no_data_message": "Нет данных. В настоящее время нет информации для отображения. Добавьте графики, чтобы построить свой дашборд.",
"please_enter_name": "Пожалуйста, введите название панели управления"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "У вас нет записей обратной связи, о которых можно сообщить. Настройте источники обратной связи для подачи данных в систему.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Настройка источников обратной связи"
},
"api_keys": {
"add_api_key": "Добавить API-ключ",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Синхронизация в реальном времени",
"status_paused": "Приостановлен",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "Aktivitet",
"add": "Lägg till",
"add_action": "Lägg till åtgärd",
"add_chart": "Lägg till diagram",
"add_charts": "Lägg till diagram",
"add_existing_chart_description": "Sök och välj diagram att lägga till i den här instrumentpanelen.",
"add_filter": "Lägg till filter",
@@ -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",
@@ -390,6 +392,7 @@
"report_survey": "Rapportera enkät",
"request_trial_license": "Begär provlicens",
"reset_to_default": "Återställ till standard",
"resize": "Ändra storlek",
"response": "Svar",
"response_id": "Svar-ID",
"responses": "Svar",
@@ -431,6 +434,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",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "Välj en instrumentpanel",
"predefined_measures": "Fördefinierade mått",
"preset": "Förinställning",
"preview_chart": "Förhandsgranska diagram",
"query_executed_successfully": "Frågan kördes utan problem",
"reset_to_ai_suggestion": "Återställ till AI-förslag",
"save_and_add_to_dashboard": "Spara och lägg till i instrumentpanelen",
"save_chart": "Spara diagram",
"save_chart_dialog_title": "Spara diagram",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "Startdatum",
"time_dimension": "Tidsdimension",
"time_dimension_title": "Lägg till tidsbaserad gruppering",
"time_dimension_toggle_description": "Övervaka trender över tid."
"time_dimension_toggle_description": "Övervaka trender över tid.",
"update_chart": "Uppdatera diagram"
},
"dashboards": {
"add_count_charts": "Lägg till {count} diagram",
@@ -1806,6 +1813,7 @@
"create_dashboard": "Skapa instrumentpanel",
"create_dashboard_description": "Ange ett namn för din nya instrumentpanel.",
"create_failed": "Det gick inte att skapa instrumentpanelen",
"create_new_chart": "Skapa nytt diagram",
"create_success": "Instrumentpanelen har skapats!",
"dashboard": "Instrumentpanel",
"dashboard_delete_confirmation": "Är du säker på att du vill ta bort den här instrumentpanelen? Den här åtgärden kan inte ångras.",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "Det gick inte att duplicera instrumentpanelen",
"duplicate_success": "Instrumentpanelen har duplicerats!",
"failed_to_load_chart_data": "Det gick inte att ladda diagramdata",
"no_charts_available_description": "Det finns inga diagram som kan läggas till på den här instrumentpanelen. Antingen finns inga diagram än, eller så har alla befintliga diagram redan lagts till. Gå till sidan Diagram för att skapa nya diagram.",
"no_charts_to_add_message": "Inga diagram att lägga till på den här instrumentpanelen.",
"no_dashboards_found": "Inga instrumentpaneler hittades.",
"no_data_message": "Ingen data. Det finns för närvarande ingen information att visa. Lägg till diagram för att bygga din instrumentpanel.",
"please_enter_name": "Ange ett namn på instrumentpanelen"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Du har inga feedbackposter att rapportera om. Ställ in återkopplingskällor för att mata in data i systemet.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Ställ in feedbackkällor"
},
"api_keys": {
"add_api_key": "Lägg till API-nyckel",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Live sync",
"status_paused": "Pausad",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "Etkinlik",
"add": "Ekle",
"add_action": "Eylem ekle",
"add_chart": "Grafik ekle",
"add_charts": "Grafik ekle",
"add_existing_chart_description": "Bu panoya eklemek için grafikleri ara ve seç.",
"add_filter": "Filtre ekle",
@@ -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ı",
@@ -390,6 +392,7 @@
"report_survey": "Anketi Raporla",
"request_trial_license": "Deneme lisansı iste",
"reset_to_default": "Varsayılana sıfırla",
"resize": "Yeniden boyutlandır",
"response": "Yanıt",
"response_id": "Yanıt ID",
"responses": "Yanıtlar",
@@ -431,6 +434,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",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "Lütfen bir kontrol paneli seç",
"predefined_measures": "Önceden Tanımlanmış Ölçümler",
"preset": "Ön Ayar",
"preview_chart": "Grafiği önizleyin",
"query_executed_successfully": "Sorgu başarıyla çalıştırıldı",
"reset_to_ai_suggestion": "Yapay zeka önerisine sıfırla",
"save_and_add_to_dashboard": "Kaydet ve kontrol paneline ekle",
"save_chart": "Grafiği Kaydet",
"save_chart_dialog_title": "Grafiği Kaydet",
"select_data_source": "Bir veri kaynağı seç",
@@ -1795,7 +1801,8 @@
"start_date": "Başlangıç tarihi",
"time_dimension": "Zaman Boyutu",
"time_dimension_title": "Zaman tabanlı gruplama ekle",
"time_dimension_toggle_description": "Zaman içindeki eğilimleri izle."
"time_dimension_toggle_description": "Zaman içindeki eğilimleri izle.",
"update_chart": "Grafiği güncelle"
},
"dashboards": {
"add_count_charts": "{count} grafik ekle",
@@ -1806,6 +1813,7 @@
"create_dashboard": "Pano oluştur",
"create_dashboard_description": "Yeni panon için bir isim gir.",
"create_failed": "Pano oluşturulamadı",
"create_new_chart": "Yeni grafik oluştur",
"create_success": "Pano başarıyla oluşturuldu!",
"dashboard": "Pano",
"dashboard_delete_confirmation": "Bu gösterge panelini silmek istediğinden emin misin? Bu işlem geri alınamaz.",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "Gösterge paneli kopyalanamadı",
"duplicate_success": "Gösterge paneli başarıyla kopyalandı!",
"failed_to_load_chart_data": "Grafik verileri yüklenemedi",
"no_charts_available_description": "Bu gösterge paneline eklenebilecek grafik bulunmuyor. Ya henüz hiç grafik oluşturulmadı ya da mevcut tüm grafikler zaten eklendi. Yeni grafikler oluşturmak için Grafikler sayfasına git.",
"no_charts_to_add_message": "Bu gösterge paneline eklenecek grafik yok.",
"no_dashboards_found": "Gösterge paneli bulunamadı.",
"no_data_message": "Veri Yok. Şu anda görüntülenecek bilgi bulunmuyor. Gösterge panelini oluşturmak için grafik ekle.",
"please_enter_name": "Lütfen bir gösterge paneli adı gir"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Raporlayabileceğiniz Geri Bildirim Kayıtlarınız yok. Verileri sisteme beslemek için Geri Bildirim Kaynaklarını ayarlayın.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Geri bildirim kaynaklarını ayarlayın"
},
"api_keys": {
"add_api_key": "API Anahtarı Ekle",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Live sync",
"status_paused": "Duraklatıldı",
"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",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "活动",
"add": "添加",
"add_action": "添加 操作",
"add_chart": "添加图表",
"add_charts": "添加图表",
"add_existing_chart_description": "搜索并选择要添加到此仪表板的图表。",
"add_filter": "添加 过滤器",
@@ -331,6 +332,7 @@
"not_authenticated": "您 未 认证 以 执行 该 操作。",
"not_authorized": "未授权",
"not_connected": "未连接",
"not_set": "未设置",
"note": "注释",
"notifications": "通知",
"number": "数字",
@@ -390,6 +392,7 @@
"report_survey": "报告调查",
"request_trial_license": "申请试用许可证",
"reset_to_default": "重置为 默认",
"resize": "调整大小",
"response": "响应",
"response_id": "响应 ID",
"responses": "反馈",
@@ -431,6 +434,7 @@
"some_files_failed_to_upload": "某些文件上传失败",
"something_went_wrong": "出错了",
"something_went_wrong_please_try_again": "出错了 。请 尝试 再次 操作 。",
"soon": "即将推出",
"sort_by": "排序 依据",
"start_free_trial": "开始免费试用",
"status": "状态",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "请选择一个 Dashboard",
"predefined_measures": "预设度量",
"preset": "预设",
"preview_chart": "预览图表",
"query_executed_successfully": "查询执行成功",
"reset_to_ai_suggestion": "重置为 AI 建议",
"save_and_add_to_dashboard": "保存并添加到仪表板",
"save_chart": "保存图表",
"save_chart_dialog_title": "保存图表",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "开始日期",
"time_dimension": "时间维度",
"time_dimension_title": "添加基于时间的分组",
"time_dimension_toggle_description": "监控随时间变化的趋势。"
"time_dimension_toggle_description": "监控随时间变化的趋势。",
"update_chart": "更新图表"
},
"dashboards": {
"add_count_charts": "添加 {count} 个图表",
@@ -1806,6 +1813,7 @@
"create_dashboard": "创建仪表板",
"create_dashboard_description": "请输入新 Dashboard 的名称。",
"create_failed": "创建 Dashboard 失败",
"create_new_chart": "创建新图表",
"create_success": "Dashboard 创建成功!",
"dashboard": "仪表板",
"dashboard_delete_confirmation": "你确定要删除此仪表板吗?此操作无法撤销。",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "复制 Dashboard 失败",
"duplicate_success": "Dashboard 复制成功!",
"failed_to_load_chart_data": "加载图表数据失败",
"no_charts_available_description": "没有可以添加到此仪表板的图表。要么还没有创建任何图表,要么所有现有图表都已添加。请前往图表页面创建新图表。",
"no_charts_to_add_message": "没有可添加到此仪表板的图表。",
"no_dashboards_found": "未找到 Dashboard。",
"no_data_message": "暂无数据。当前没有可显示的信息。请添加图表来构建你的仪表板。",
"please_enter_name": "请输入 Dashboard 名称"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "您没有可供报告的反馈记录。设置反馈源以将数据输入系统。",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "设置反馈源"
},
"api_keys": {
"add_api_key": "添加 API 密钥",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Live sync",
"status_paused": "已暂停",
"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": "客户努力评分",
+65 -22
View File
@@ -125,6 +125,7 @@
"activity": "活動",
"add": "新增",
"add_action": "新增操作",
"add_chart": "新增圖表",
"add_charts": "新增圖表",
"add_existing_chart_description": "搜尋並選擇要新增至此儀表板的圖表。",
"add_filter": "新增篩選器",
@@ -331,6 +332,7 @@
"not_authenticated": "您未經授權執行此操作。",
"not_authorized": "未授權",
"not_connected": "未連線",
"not_set": "未設定",
"note": "筆記",
"notifications": "通知",
"number": "數字",
@@ -390,6 +392,7 @@
"report_survey": "報告問卷",
"request_trial_license": "請求試用授權",
"reset_to_default": "重設為預設值",
"resize": "調整大小",
"response": "回應",
"response_id": "回應 ID",
"responses": "回應",
@@ -431,6 +434,7 @@
"some_files_failed_to_upload": "部分檔案上傳失敗",
"something_went_wrong": "發生錯誤",
"something_went_wrong_please_try_again": "發生錯誤。請再試一次。",
"soon": "即將推出",
"sort_by": "排序方式",
"start_free_trial": "開始免費試用",
"status": "狀態",
@@ -1781,8 +1785,10 @@
"please_select_dashboard": "請選擇一個儀表板",
"predefined_measures": "預設指標",
"preset": "預設",
"preview_chart": "預覽圖表",
"query_executed_successfully": "查詢執行成功",
"reset_to_ai_suggestion": "重設為 AI 建議",
"save_and_add_to_dashboard": "儲存並新增到儀表板",
"save_chart": "儲存圖表",
"save_chart_dialog_title": "儲存圖表",
"select_data_source": "Select a data source",
@@ -1795,7 +1801,8 @@
"start_date": "開始日期",
"time_dimension": "時間維度",
"time_dimension_title": "新增基於時間的分組",
"time_dimension_toggle_description": "監控隨時間變化的趨勢。"
"time_dimension_toggle_description": "監控隨時間變化的趨勢。",
"update_chart": "更新圖表"
},
"dashboards": {
"add_count_charts": "新增 {count} 個圖表",
@@ -1806,6 +1813,7 @@
"create_dashboard": "建立儀表板",
"create_dashboard_description": "請輸入新儀表板的名稱。",
"create_failed": "建立儀表板失敗",
"create_new_chart": "建立新圖表",
"create_success": "儀表板建立成功!",
"dashboard": "儀表板",
"dashboard_delete_confirmation": "確定要刪除此儀表板嗎?此操作無法復原。",
@@ -1820,12 +1828,14 @@
"duplicate_failed": "複製儀表板失敗",
"duplicate_success": "儀表板複製成功!",
"failed_to_load_chart_data": "載入圖表資料失敗",
"no_charts_available_description": "目前沒有可以新增到此儀表板的圖表。可能是尚未建立任何圖表,或所有現有圖表都已新增。請前往圖表頁面建立新的圖表。",
"no_charts_to_add_message": "沒有可新增到此儀表板的圖表。",
"no_dashboards_found": "找不到儀表板。",
"no_data_message": "無資料。目前沒有可顯示的資訊。請新增圖表來建立你的儀表板。",
"please_enter_name": "請輸入儀表板名稱"
}
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "您沒有可供報告的回饋記錄。設定回饋來源以將資料輸入系統。",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "設定反饋源"
},
"api_keys": {
"add_api_key": "新增 API 金鑰",
@@ -2548,10 +2558,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 +3627,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 +3660,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 +3675,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 +3742,31 @@
"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_live_sync": "Live sync",
"status_paused": "已暫停",
"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",
@@ -31,6 +31,7 @@ interface AddToDashboardDialogProps {
onDashboardSelect: (id: string) => void;
onConfirm: () => void;
isSaving: boolean;
showChartNameField?: boolean;
}
export function AddToDashboardDialog({
@@ -43,6 +44,7 @@ export function AddToDashboardDialog({
onDashboardSelect,
onConfirm,
isSaving,
showChartNameField = true,
}: Readonly<AddToDashboardDialogProps>) {
const { t } = useTranslation();
@@ -57,17 +59,19 @@ export function AddToDashboardDialog({
</DialogHeader>
<DialogBody>
<div className="space-y-4">
<div>
<Label htmlFor="chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
<Input
id="chart-name"
className="mt-2"
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
maxLength={255}
/>
</div>
{showChartNameField && (
<div>
<Label htmlFor="chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
<Input
id="chart-name"
className="mt-2"
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
maxLength={255}
/>
</div>
)}
<div>
<Label htmlFor="dashboard-select">{t("workspace.analysis.charts.dashboard")}</Label>
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
@@ -103,7 +107,10 @@ export function AddToDashboardDialog({
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
{t("common.cancel")}
</Button>
<Button onClick={onConfirm} loading={isSaving} disabled={!selectedDashboardId || !chartName.trim()}>
<Button
onClick={onConfirm}
loading={isSaving}
disabled={!selectedDashboardId || (showChartNameField && !chartName.trim())}>
{t("workspace.analysis.charts.add_to_dashboard")}
</Button>
</DialogFooter>
@@ -31,6 +31,7 @@ interface AdvancedChartBuilderProps {
hidePreview?: boolean;
onChartGenerated?: (data: AnalyticsResponse) => void;
feedbackRecordDirectoryId: string | null;
runQueryCtaLabel?: string;
}
const ACTION = {
@@ -84,6 +85,7 @@ export function AdvancedChartBuilder({
hidePreview = false,
onChartGenerated,
feedbackRecordDirectoryId,
runQueryCtaLabel,
}: Readonly<AdvancedChartBuilderProps>) {
const { t } = useTranslation();
const parsedInitial = initialQuery ? parseQueryToState(initialQuery) : null;
@@ -151,11 +153,7 @@ export function AdvancedChartBuilder({
return (
<div className={hidePreview ? "space-y-2" : "grid gap-4 lg:grid-cols-2"}>
<div className="mx-1 space-y-2">
{!hidePreview && (
<>
<ChartTypeSelector selectedChartType={chartType} onChartTypeSelect={() => {}} />
</>
)}
{!hidePreview && <ChartTypeSelector selectedChartType={chartType} onChartTypeSelect={() => {}} />}
<div className="mt-4 flex w-full flex-col gap-3 overflow-hidden rounded-lg border bg-slate-50 p-4">
<MeasuresPanel
@@ -249,7 +247,11 @@ export function AdvancedChartBuilder({
<div className="flex justify-end">
<Button onClick={handleRunQuery} disabled={isLoading || !hasConfigChanged}>
{isLoading ? <LoadingSpinner /> : t("workspace.analysis.charts.create_chart")}
{isLoading ? (
<LoadingSpinner />
) : (
(runQueryCtaLabel ?? t("workspace.analysis.charts.create_chart"))
)}
</Button>
</div>
</div>
@@ -7,25 +7,31 @@ import { DialogFooter } from "@/modules/ui/components/dialog";
interface ChartDialogFooterProps {
onSaveClick: () => void;
onAddToDashboardClick: () => void;
onAddToDashboardClick?: () => void;
isSaving: boolean;
saveLabel?: string;
showAddToDashboard?: boolean;
}
export function ChartDialogFooter({
onSaveClick,
onAddToDashboardClick,
isSaving,
saveLabel,
showAddToDashboard = true,
}: Readonly<ChartDialogFooterProps>) {
const { t } = useTranslation();
return (
<DialogFooter>
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.charts.add_to_dashboard")}
</Button>
{showAddToDashboard && onAddToDashboardClick && (
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.charts.add_to_dashboard")}
</Button>
)}
<Button onClick={onSaveClick} disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.charts.save_chart")}
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
</Button>
</DialogFooter>
);
@@ -1,12 +1,14 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import { CopyIcon, MoreVertical, PlusIcon, SquarePenIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteChartAction, duplicateChartAction } from "@/modules/ee/analysis/charts/actions";
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -31,6 +33,36 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
const [isDeleting, setIsDeleting] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [isAddingToDashboard, setIsAddingToDashboard] = useState(false);
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>();
useEffect(() => {
let cancelled = false;
if (!isAddToDashboardDialogOpen) {
return () => {
cancelled = true;
};
}
void getDashboardsAction({ workspaceId }).then((result) => {
if (cancelled) {
return;
}
if (result?.data) {
setDashboards(result.data.map((dashboard) => ({ id: dashboard.id, name: dashboard.name })));
} else {
toast.error(getFormattedErrorMessage(result));
}
});
return () => {
cancelled = true;
};
}, [isAddToDashboardDialogOpen, workspaceId]);
const handleDeleteChart = async () => {
setIsDeleting(true);
@@ -70,6 +102,37 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
}
};
const handleAddChartToDashboard = async () => {
if (!selectedDashboardId) {
toast.error(t("workspace.analysis.charts.please_select_dashboard"));
return;
}
setIsAddingToDashboard(true);
try {
const result = await addChartToDashboardAction({
workspaceId,
chartId: chart.id,
dashboardId: selectedDashboardId,
});
if (!result?.data) {
toast.error(
getFormattedErrorMessage(result) || t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
);
return;
}
toast.success(t("workspace.analysis.charts.chart_added_to_dashboard"));
setIsAddToDashboardDialogOpen(false);
setSelectedDashboardId(undefined);
router.refresh();
} finally {
setIsAddingToDashboard(false);
}
};
return (
<div id={`chart-${chart.id}-actions`} data-testid="chart-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
@@ -102,6 +165,15 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
{t("common.duplicate")}
</DropdownMenuItem>
<DropdownMenuItem
icon={<PlusIcon className="size-4" />}
onClick={() => {
setIsDropDownOpen(false);
setIsAddToDashboardDialogOpen(true);
}}>
{t("workspace.analysis.charts.add_to_dashboard")}
</DropdownMenuItem>
<DropdownMenuItem
icon={<TrashIcon className="size-4" />}
onClick={() => {
@@ -123,6 +195,23 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
text={t("workspace.analysis.charts.delete_chart_confirmation")}
isDeleting={isDeleting}
/>
<AddToDashboardDialog
isOpen={isAddToDashboardDialogOpen}
onOpenChange={(open) => {
setIsAddToDashboardDialogOpen(open);
if (!open) {
setSelectedDashboardId(undefined);
}
}}
chartName={chart.name}
onChartNameChange={() => {}}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onConfirm={handleAddChartToDashboard}
isSaving={isAddingToDashboard}
showChartNameField={false}
/>
</div>
);
}
@@ -1,9 +1,12 @@
import { use } from "react";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getTranslate } from "@/lingodotdev/server";
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
import { hasFeedbackRecordsInDirectories } from "@/modules/ee/analysis/lib/feedback-records";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
@@ -35,22 +38,38 @@ interface ChartsListPageProps {
export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPageProps>) {
const t = await getTranslate();
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
const chartsPromise = getChartsWithCreator(workspaceId);
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
const [directories, connectors] = await Promise.all([
getFeedbackRecordDirectoriesByWorkspaceId(workspaceId),
getConnectorsWithMappings(workspaceId),
]);
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
directories.map((directory) => directory.id)
);
const chartsPromise = hasFeedbackRecords ? getChartsWithCreator(workspaceId) : null;
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
workspaceId={workspaceId}
cta={
isReadOnly ? undefined : <CreateChartButton workspaceId={workspaceId} directories={directories} />
isReadOnly ? undefined : (
<CreateChartButton
workspaceId={workspaceId}
directories={directories}
buttonProps={{ disabled: !hasFeedbackRecords }}
/>
)
}>
<ChartsListContent
chartsPromise={chartsPromise}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
directories={directories}
/>
{hasFeedbackRecords && chartsPromise ? (
<ChartsListContent
chartsPromise={chartsPromise}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
directories={directories}
/>
) : (
<NoFeedbackRecordsState workspaceId={workspaceId} hasFeedbackSources={connectors.length > 0} />
)}
</AnalysisPageLayout>
);
}
@@ -4,28 +4,43 @@ import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import { Button } from "@/modules/ui/components/button";
import { Button, type ButtonProps } from "@/modules/ui/components/button";
interface CreateChartButtonProps {
workspaceId: string;
directories: { id: string; name: string }[];
autoAddToDashboardId?: string;
label?: string;
onSuccess?: () => void;
showIcon?: boolean;
buttonProps?: Omit<ButtonProps, "onClick" | "children">;
}
export function CreateChartButton({ workspaceId, directories }: Readonly<CreateChartButtonProps>) {
export function CreateChartButton({
workspaceId,
directories,
autoAddToDashboardId,
label,
onSuccess,
showIcon = true,
buttonProps,
}: Readonly<CreateChartButtonProps>) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { t } = useTranslation();
return (
<>
<Button size="sm" onClick={() => setIsDialogOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.charts.create_chart")}
<Button size="sm" onClick={() => setIsDialogOpen(true)} {...buttonProps}>
{showIcon && <PlusIcon className="mr-2 h-4 w-4" />}
{label ?? t("workspace.analysis.charts.create_chart")}
</Button>
<CreateChartDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
workspaceId={workspaceId}
autoAddToDashboardId={autoAddToDashboardId}
directories={directories}
onSuccess={onSuccess}
/>
</>
);
@@ -1,7 +1,6 @@
"use client";
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
import { EditChartView } from "@/modules/ee/analysis/charts/components/edit-chart-view";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
export interface CreateChartDialogProps {
@@ -9,6 +8,7 @@ export interface CreateChartDialogProps {
onOpenChange: (open: boolean) => void;
workspaceId: string;
chartId?: string;
autoAddToDashboardId?: string;
initialChart?: TChartWithCreator;
onSuccess?: () => void;
directories: { id: string; name: string }[];
@@ -19,29 +19,19 @@ export function CreateChartDialog({
onOpenChange,
workspaceId,
chartId,
autoAddToDashboardId,
initialChart,
onSuccess,
directories,
}: Readonly<CreateChartDialogProps>) {
if (chartId) {
return (
<EditChartView
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
chartId={chartId}
initialChart={initialChart}
onSuccess={onSuccess}
directories={directories}
/>
);
}
return (
<CreateChartView
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
chartId={chartId}
initialChart={initialChart}
autoAddToDashboardId={autoAddToDashboardId}
onSuccess={onSuccess}
directories={directories}
/>
@@ -1,16 +1,19 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { SaveChartDialog } from "@/modules/ee/analysis/charts/components/save-chart-dialog";
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
import { FrdPicker } from "@/modules/ee/feedback-record-directory/components/frd-picker";
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Alert } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
@@ -19,11 +22,16 @@ import {
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
interface CreateChartViewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string;
chartId?: string;
initialChart?: TChartWithCreator;
autoAddToDashboardId?: string;
onSuccess?: () => void;
directories: { id: string; name: string }[];
}
@@ -32,32 +40,39 @@ export function CreateChartView({
open,
onOpenChange,
workspaceId,
chartId,
initialChart,
autoAddToDashboardId,
onSuccess,
directories,
}: Readonly<CreateChartViewProps>) {
const { t } = useTranslation();
const isEditing = !!chartId;
const {
chartData,
initialQuery,
isLoadingChart,
chartLoadError,
chartName,
setChartName,
selectedChartType,
handleChartTypeChange,
handleChartGenerated,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
handleAddToDashboard,
handleSaveChart,
isSaving,
isSaveDialogOpen,
setIsSaveDialogOpen,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
selectedDirectoryId,
setSelectedDirectoryId,
handleClose,
} = useChartDialog({ open, onOpenChange, workspaceId, onSuccess, directories });
} = useChartDialog({
open,
onOpenChange,
workspaceId,
chartId,
initialChart,
autoAddToDashboardId,
onSuccess,
directories,
});
const chartPreviewRef = useRef<HTMLDivElement>(null);
@@ -67,96 +82,139 @@ export function CreateChartView({
}
}, [chartData]);
if (isLoadingChart && isEditing && !initialChart) {
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
}
if (isEditing && !isLoadingChart && !chartData && !initialChart && chartLoadError) {
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent width="wide">
<DialogHeader>
<DialogTitle>{t("common.error")}</DialogTitle>
<DialogDescription />
</DialogHeader>
<DialogBody>
<div className="flex flex-col items-center justify-center gap-4 py-8">
<p className="text-sm text-red-600">{chartLoadError}</p>
<Button variant="outline" onClick={handleClose}>
{t("common.close")}
</Button>
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
}
const chartType = selectedChartType ?? (isEditing ? DEFAULT_CHART_TYPE : undefined);
const hasSelectedDirectory = !!selectedDirectoryId;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide" disableCloseOnOutsideClick>
<DialogContent
className="max-h-[90vh] overflow-y-auto"
width="wide"
disableCloseOnOutsideClick={!isEditing}>
<DialogHeader>
<DialogTitle>{t("workspace.analysis.charts.create_chart")}</DialogTitle>
<DialogDescription>{t("workspace.analysis.charts.create_chart_description")}</DialogDescription>
<DialogTitle>
{isEditing
? t("workspace.analysis.charts.edit_chart_title")
: t("workspace.analysis.charts.create_chart")}
</DialogTitle>
<DialogDescription>
{isEditing
? t("workspace.analysis.charts.edit_chart_description")
: t("workspace.analysis.charts.create_chart_description")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4">
<FrdPicker
directories={directories}
selectedDirectoryId={selectedDirectoryId}
onChange={setSelectedDirectoryId}
workspaceId={workspaceId}
/>
{hasSelectedDirectory && (
{hasSelectedDirectory ? (
<>
<AIQuerySection
workspaceId={workspaceId}
onChartGenerated={handleChartGenerated}
feedbackRecordDirectoryId={selectedDirectoryId}
/>
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-2 text-sm text-gray-500">
{t("workspace.analysis.charts.OR")}
</span>
</div>
<div className="space-y-2">
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
<Input
id="create-chart-name"
value={chartName}
onChange={(event) => setChartName(event.target.value)}
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
maxLength={255}
required
/>
</div>
<ManualChartBuilder
selectedChartType={selectedChartType}
onChartTypeSelect={handleChartTypeChange}
/>
{!isEditing && (
<>
<AIQuerySection
workspaceId={workspaceId}
onChartGenerated={handleChartGenerated}
feedbackRecordDirectoryId={selectedDirectoryId}
/>
{selectedChartType && (
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-2 text-sm text-gray-500">
{t("workspace.analysis.charts.OR")}
</span>
</div>
</div>
</>
)}
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
{chartType && (
<AdvancedChartBuilder
workspaceId={workspaceId}
chartType={selectedChartType}
initialQuery={chartData?.query}
chartType={chartType}
initialQuery={chartData?.query ?? initialQuery}
hidePreview={true}
onChartGenerated={handleChartGenerated}
feedbackRecordDirectoryId={selectedDirectoryId}
runQueryCtaLabel={
chartData
? t("workspace.analysis.charts.update_chart")
: t("workspace.analysis.charts.preview_chart")
}
/>
)}
{chartData && (
{(isEditing || chartData) && (
<div ref={chartPreviewRef}>
<ChartPreview chartData={chartData} />
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
</div>
)}
</>
) : (
<Alert variant="error" size="small">
<div>
<p>{t("workspace.analysis.charts.no_data_source_available")}</p>
<Link
className="mt-1 inline-block font-medium underline"
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.analysis.charts.go_to_feedback_record_directories")}
</Link>
</div>
</Alert>
)}
</div>
</DialogBody>
{chartData && (
<>
<ChartDialogFooter
onSaveClick={() => setIsSaveDialogOpen(true)}
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
isSaving={isSaving}
/>
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
isOpen={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onConfirm={handleAddToDashboard}
isSaving={isSaving}
/>
</>
<ChartDialogFooter
onSaveClick={handleSaveChart}
isSaving={isSaving}
showAddToDashboard={false}
saveLabel={
autoAddToDashboardId
? t("workspace.analysis.charts.save_and_add_to_dashboard")
: t("workspace.analysis.charts.save_chart")
}
/>
)}
</DialogContent>
</Dialog>
@@ -1,158 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
interface EditChartViewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string;
chartId: string;
initialChart?: TChartWithCreator;
onSuccess?: () => void;
directories: { id: string; name: string }[];
}
export function EditChartView({
open,
onOpenChange,
workspaceId,
chartId,
initialChart,
onSuccess,
directories,
}: Readonly<EditChartViewProps>) {
const { t } = useTranslation();
const {
chartData,
initialQuery,
isLoadingChart,
chartLoadError,
chartName,
setChartName,
selectedChartType,
handleChartTypeChange,
handleChartGenerated,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
handleAddToDashboard,
handleSaveChart,
isSaving,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
selectedDirectoryId,
handleClose,
} = useChartDialog({ open, onOpenChange, workspaceId, chartId, initialChart, onSuccess, directories });
if (isLoadingChart && !initialChart) {
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
}
if (!isLoadingChart && !chartData && !initialChart && chartLoadError) {
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent width="wide">
<DialogHeader>
<DialogTitle>{t("common.error")}</DialogTitle>
<DialogDescription />
</DialogHeader>
<DialogBody>
<div className="flex flex-col items-center justify-center gap-4 py-8">
<p className="text-sm text-red-600">{chartLoadError}</p>
<Button variant="outline" onClick={handleClose}>
{t("common.close")}
</Button>
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
}
const chartType = selectedChartType ?? DEFAULT_CHART_TYPE;
const directoryName = directories.find((d) => d.id === selectedDirectoryId)?.name;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
<DialogHeader>
<DialogTitle>{t("workspace.analysis.charts.edit_chart_title")}</DialogTitle>
<DialogDescription>{t("workspace.analysis.charts.edit_chart_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4 px-1">
<div className="space-y-2">
<label htmlFor="edit-chart-name" className="text-sm">
{t("workspace.analysis.charts.chart_name")}
</label>
<Input
id="edit-chart-name"
value={chartName}
onChange={(e) => setChartName(e.target.value)}
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
className="w-full"
/>
</div>
{directoryName && (
<div className="space-y-2">
<Label>{t("workspace.analysis.charts.data_source")}</Label>
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
{directoryName}
</div>
</div>
)}
<div className="space-y-2">
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
</div>
<AdvancedChartBuilder
workspaceId={workspaceId}
chartType={chartType}
initialQuery={chartData?.query ?? initialQuery}
hidePreview={true}
onChartGenerated={handleChartGenerated}
feedbackRecordDirectoryId={selectedDirectoryId}
/>
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
</div>
</DialogBody>
<ChartDialogFooter
onSaveClick={handleSaveChart}
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
isSaving={isSaving}
/>
<AddToDashboardDialog
isOpen={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onConfirm={handleAddToDashboard}
isSaving={isSaving}
/>
</DialogContent>
</Dialog>
);
}
@@ -26,6 +26,7 @@ export interface UseChartDialogProps {
onOpenChange: (open: boolean) => void;
workspaceId: string;
chartId?: string;
autoAddToDashboardId?: string;
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
initialChart?: TChartWithCreator;
onSuccess?: () => void;
@@ -37,6 +38,7 @@ export function useChartDialog({
onOpenChange,
workspaceId,
chartId,
autoAddToDashboardId,
initialChart,
onSuccess,
directories,
@@ -45,7 +47,6 @@ export function useChartDialog({
const router = useRouter();
const [selectedChartType, setSelectedChartType] = useState<TChartType | undefined>();
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
@@ -54,9 +55,7 @@ export function useChartDialog({
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(
directories?.length === 1 ? directories[0].id : null
);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories?.[0]?.id ?? null);
useEffect(() => {
let cancelled = false;
@@ -85,7 +84,7 @@ export function useChartDialog({
setChartName("");
setSelectedChartType(undefined);
setCurrentChartId(undefined);
setSelectedDirectoryId(directories?.length === 1 ? directories[0].id : null);
setSelectedDirectoryId(directories?.[0]?.id ?? null);
return;
}
@@ -159,11 +158,6 @@ export function useChartDialog({
const handleChartGenerated = (data: AnalyticsResponse) => {
setChartData(data);
if (!currentChartId) {
setChartName(
data.chartType ? `${t("workspace.analysis.charts.chart")} ${new Date().toLocaleString()}` : ""
);
}
setSelectedChartType(data.chartType);
};
@@ -180,6 +174,8 @@ export function useChartDialog({
setIsSaving(true);
try {
let savedChartId = currentChartId;
if (currentChartId) {
const result = await updateChartAction({
workspaceId,
@@ -218,11 +214,32 @@ export function useChartDialog({
}
setCurrentChartId(result.data.id);
savedChartId = result.data.id;
toast.success(t("workspace.analysis.charts.chart_saved_successfully"));
}
setIsSaveDialogOpen(false);
if (autoAddToDashboardId && savedChartId) {
const addResult = await addChartToDashboardAction({
workspaceId,
chartId: savedChartId,
dashboardId: autoAddToDashboardId,
});
if (!addResult?.data) {
toast.error(
getFormattedErrorMessage(addResult) ||
t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
);
return;
}
toast.success(t("workspace.analysis.charts.chart_added_to_dashboard"));
}
onOpenChange(false);
if (autoAddToDashboardId) {
router.push(`/workspaces/${workspaceId}/dashboards/${autoAddToDashboardId}`);
}
router.refresh();
onSuccess?.();
} catch (error: unknown) {
@@ -328,7 +345,7 @@ export function useChartDialog({
setSelectedChartType(undefined);
setCurrentChartId(undefined);
setChartLoadError(null);
setSelectedDirectoryId(directories?.length === 1 ? directories[0].id : null);
setSelectedDirectoryId(directories?.[0]?.id ?? null);
onOpenChange(false);
}
};
@@ -349,8 +366,6 @@ export function useChartDialog({
setSelectedChartType,
currentChartId,
setCurrentChartId,
isSaveDialogOpen,
setIsSaveDialogOpen,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
dashboards,
@@ -0,0 +1,36 @@
import { MessageSquareDashedIcon } from "lucide-react";
import Link from "next/link";
import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button";
interface NoFeedbackRecordsStateProps {
workspaceId: string;
hasFeedbackSources?: boolean;
}
export const NoFeedbackRecordsState = async ({
workspaceId,
hasFeedbackSources = false,
}: Readonly<NoFeedbackRecordsStateProps>) => {
const t = await getTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 text-center">
<MessageSquareDashedIcon className="h-8 w-8 text-slate-400" />
<p className="text-balance text-sm text-slate-600">
{hasFeedbackSources
? t("workspace.analysis.no_feedback_records_with_sources_message")
: t("workspace.analysis.no_feedback_records_message")}
</p>
<Button asChild size="sm">
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
{hasFeedbackSources
? t("workspace.analysis.manage_feedback_sources")
: t("workspace.analysis.setup_feedback_source")}
</Link>
</Button>
</div>
</div>
);
};
@@ -1,13 +1,14 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getChartsAction } from "@/modules/ee/analysis/charts/actions";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import { addChartToDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -18,6 +19,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Label } from "@/modules/ui/components/label";
import { MultiSelect } from "@/modules/ui/components/multi-select";
interface AddExistingChartsDialogProps {
@@ -25,6 +27,7 @@ interface AddExistingChartsDialogProps {
onOpenChange: (open: boolean) => void;
workspaceId: string;
dashboardId: string;
directories: { id: string; name: string }[];
existingChartIds: string[];
onSuccess: () => void;
}
@@ -39,39 +42,45 @@ export function AddExistingChartsDialog({
onOpenChange,
workspaceId,
dashboardId,
directories,
existingChartIds,
onSuccess,
}: Readonly<AddExistingChartsDialogProps>) {
const { t } = useTranslation();
const router = useRouter();
const [chartOptions, setChartOptions] = useState<ChartOption[]>([]);
const [selectedChartIds, setSelectedChartIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const existingChartIdsRef = useRef(existingChartIds);
existingChartIdsRef.current = existingChartIds;
const loadCharts = useCallback(async () => {
setIsLoading(true);
setSelectedChartIds([]);
try {
const result = await getChartsAction({ workspaceId });
if (result?.data) {
const availableCharts = result.data.filter(
(chart) => !existingChartIdsRef.current.includes(chart.id)
);
setChartOptions(availableCharts.map((chart) => ({ value: chart.id, label: chart.name })));
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch {
toast.error(t("workspace.analysis.dashboards.charts_load_failed"));
} finally {
setIsLoading(false);
}
}, [workspaceId, t]);
useEffect(() => {
if (!open) return;
const loadCharts = async () => {
setIsLoading(true);
setSelectedChartIds([]);
try {
const result = await getChartsAction({ workspaceId });
if (result?.data) {
const availableCharts = result.data.filter((chart) => !existingChartIds.includes(chart.id));
setChartOptions(availableCharts.map((chart) => ({ value: chart.id, label: chart.name })));
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch {
toast.error(t("workspace.analysis.dashboards.charts_load_failed"));
} finally {
setIsLoading(false);
}
};
loadCharts();
}, [open, workspaceId, existingChartIds, t]);
}, [open, loadCharts]);
const handleAdd = async () => {
if (selectedChartIds.length === 0) return;
@@ -127,15 +136,8 @@ export function AddExistingChartsDialog({
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
</div>
) : (
<>
{chartOptions.length === 0 && (
<Alert variant="info" className="mb-4">
<AlertTitle>{t("workspace.analysis.dashboards.no_charts_to_add_message")}</AlertTitle>
<AlertDescription>
{t("workspace.analysis.dashboards.no_charts_available_description")}
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label>{t("common.add_chart")}</Label>
<MultiSelect
options={chartOptions}
value={selectedChartIds}
@@ -143,18 +145,35 @@ export function AddExistingChartsDialog({
placeholder={t("common.search_charts")}
disabled={chartOptions.length === 0}
/>
</>
</div>
)}
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
{t("common.cancel")}
</Button>
<Button onClick={handleAdd} loading={isAdding} disabled={selectedChartIds.length === 0 || isAdding}>
{selectedChartIds.length > 0
? t("workspace.analysis.dashboards.add_count_charts", { count: selectedChartIds.length })
: t("common.add")}
</Button>
<DialogFooter className="sm:justify-between">
<CreateChartButton
workspaceId={workspaceId}
directories={directories}
autoAddToDashboardId={dashboardId}
label={t("workspace.analysis.dashboards.create_new_chart")}
onSuccess={() => {
onOpenChange(false);
router.refresh();
onSuccess();
}}
buttonProps={{ variant: "secondary", size: "default", disabled: isAdding }}
/>
<div className="flex flex-col-reverse gap-2 sm:flex-row">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
{t("common.cancel")}
</Button>
<Button
onClick={handleAdd}
loading={isAdding}
disabled={selectedChartIds.length === 0 || isAdding}>
{selectedChartIds.length > 0
? t("workspace.analysis.dashboards.add_count_charts", { count: selectedChartIds.length })
: t("common.add")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -12,9 +12,13 @@ import { Button } from "@/modules/ui/components/button";
interface CreateDashboardButtonProps {
workspaceId: string;
disabled?: boolean;
}
export const CreateDashboardButton = ({ workspaceId }: Readonly<CreateDashboardButtonProps>) => {
export const CreateDashboardButton = ({
workspaceId,
disabled = false,
}: Readonly<CreateDashboardButtonProps>) => {
const { t } = useTranslation();
const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
@@ -59,7 +63,7 @@ export const CreateDashboardButton = ({ workspaceId }: Readonly<CreateDashboardB
return (
<>
<Button size="sm" onClick={() => handleOpenChange(true)}>
<Button size="sm" onClick={() => handleOpenChange(true)} disabled={disabled}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.dashboards.create_dashboard")}
</Button>
@@ -8,12 +8,14 @@ import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { AddExistingChartsDialog } from "@/modules/ee/analysis/dashboards/components/add-existing-charts-dialog";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { IconBar } from "@/modules/ui/components/iconbar";
interface DashboardControlBarProps {
workspaceId: string;
dashboardId: string;
directories: { id: string; name: string }[];
existingChartIds: string[];
isEditing: boolean;
isSaving: boolean;
@@ -28,6 +30,7 @@ interface DashboardControlBarProps {
export const DashboardControlBar = ({
workspaceId,
dashboardId,
directories,
existingChartIds,
isEditing,
isSaving,
@@ -82,12 +85,6 @@ export const DashboardControlBar = ({
];
const viewModeActions = [
{
icon: PlusIcon,
tooltip: t("common.add_charts"),
onClick: () => setIsAddExistingDialogOpen(true),
isVisible: !isReadOnly,
},
{
icon: RefreshCwIcon,
tooltip: t("common.refresh"),
@@ -110,7 +107,19 @@ export const DashboardControlBar = ({
return (
<>
<IconBar actions={isEditing ? editModeActions : viewModeActions} />
{isEditing ? (
<IconBar actions={editModeActions} />
) : (
<div className="flex items-center gap-2">
{!isReadOnly && (
<Button onClick={() => setIsAddExistingDialogOpen(true)}>
<PlusIcon />
{t("common.add_charts")}
</Button>
)}
<IconBar actions={viewModeActions} />
</div>
)}
<DeleteDialog
deleteWhat={t("workspace.analysis.dashboards.dashboard")}
open={isDeleteDialogOpen}
@@ -124,6 +133,7 @@ export const DashboardControlBar = ({
onOpenChange={setIsAddExistingDialogOpen}
workspaceId={workspaceId}
dashboardId={dashboardId}
directories={directories}
existingChartIds={existingChartIds}
onSuccess={() => {
setIsAddExistingDialogOpen(false);
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
import "react-resizable/css/styles.css";
import type { TChartQuery } from "@formbricks/types/analysis";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import { DashboardControlBar } from "@/modules/ee/analysis/dashboards/components/dashboard-control-bar";
import { DashboardPageHeader } from "@/modules/ee/analysis/dashboards/components/dashboard-page-header";
import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/dashboard-widget";
@@ -27,6 +28,7 @@ interface DashboardDetailClientProps {
workspaceId: string;
dashboard: TDashboardDetail;
widgetDataPromises: Map<string, Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>>;
directories: { id: string; name: string }[];
isReadOnly: boolean;
}
@@ -114,17 +116,26 @@ const MemoizedWidgetItem = memo(function WidgetItem({
widget,
isEditing,
dataPromise,
onEdit,
onResize,
onRemove,
}: Readonly<{
widget: TDashboardWidget;
isEditing: boolean;
dataPromise?: Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>;
onEdit?: () => void;
onResize?: () => void;
onRemove?: () => void;
}>) {
const title = widget.chart.name;
const title = widget.chart?.name ?? "";
return (
<DashboardWidget title={title} isEditing={isEditing} onRemove={onRemove}>
<DashboardWidget
title={title}
isEditing={isEditing}
onEdit={onEdit}
onResize={onResize}
onRemove={onRemove}>
<MemoizedWidgetContent widget={widget} dataPromise={dataPromise} />
</DashboardWidget>
);
@@ -134,6 +145,7 @@ export function DashboardDetailClient({
workspaceId,
dashboard,
widgetDataPromises,
directories,
isReadOnly,
}: Readonly<DashboardDetailClientProps>) {
const router = useRouter();
@@ -142,6 +154,7 @@ export function DashboardDetailClient({
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editingChartId, setEditingChartId] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [name, setName] = useState(dashboard.name);
@@ -171,6 +184,32 @@ export function DashboardDetailClient({
[dashboard.widgets]
);
const handleEnterEditMode = useCallback(() => {
if (isEditing) {
return;
}
setDraftWidgets((current) => current ?? dashboard.widgets);
setIsEditing(true);
}, [dashboard.widgets, isEditing]);
const handleEditChart = useCallback((chartId: string) => {
setEditingChartId(chartId);
}, []);
const handleRemoveWidgetFromMenu = useCallback(
(widgetId: string) => {
if (!isEditing) {
setDraftWidgets((current) => (current ?? dashboard.widgets).filter((w) => w.id !== widgetId));
setIsEditing(true);
return;
}
handleRemoveWidget(widgetId);
},
[dashboard.widgets, handleRemoveWidget, isEditing]
);
const handleCancel = useCallback(() => {
setName(dashboard.name);
setDraftWidgets(null);
@@ -248,16 +287,14 @@ export function DashboardDetailClient({
<DashboardControlBar
workspaceId={workspaceId}
dashboardId={dashboard.id}
directories={directories}
existingChartIds={widgets.map((w) => w.chartId)}
isEditing={isEditing}
isSaving={isSaving}
hasChanges={hasChanges}
isReadOnly={isReadOnly}
onRefresh={() => router.refresh()}
onEditToggle={() => {
setDraftWidgets(dashboard.widgets);
setIsEditing(true);
}}
onEditToggle={handleEnterEditMode}
onSave={handleSave}
onCancel={handleCancel}
/>
@@ -296,7 +333,9 @@ export function DashboardDetailClient({
widget={widget}
isEditing={isEditing}
dataPromise={widgetDataPromises.get(widget.id)}
onRemove={isEditing ? () => handleRemoveWidget(widget.id) : undefined}
onEdit={isReadOnly ? undefined : () => handleEditChart(widget.chartId)}
onResize={isReadOnly ? undefined : handleEnterEditMode}
onRemove={isReadOnly ? undefined : () => handleRemoveWidgetFromMenu(widget.id)}
/>
</div>
))}
@@ -305,6 +344,23 @@ export function DashboardDetailClient({
)}
</div>
</section>
{!isReadOnly && (
<CreateChartDialog
open={editingChartId !== null}
onOpenChange={(open) => {
if (!open) {
setEditingChartId(null);
}
}}
workspaceId={workspaceId}
chartId={editingChartId ?? undefined}
onSuccess={() => {
setEditingChartId(null);
router.refresh();
}}
directories={directories}
/>
)}
</PageContentWrapper>
);
}
@@ -1,6 +1,6 @@
"use client";
import { MoreVerticalIcon, TrashIcon } from "lucide-react";
import { Maximize2Icon, MoreVerticalIcon, SquarePenIcon, TrashIcon } from "lucide-react";
import { ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
@@ -15,12 +15,22 @@ interface DashboardWidgetProps {
title: string;
children: ReactNode;
isEditing?: boolean;
onEdit?: () => void;
onResize?: () => void;
onRemove?: () => void;
}
export function DashboardWidget({ title, children, isEditing, onRemove }: Readonly<DashboardWidgetProps>) {
export function DashboardWidget({
title,
children,
isEditing,
onEdit,
onResize,
onRemove,
}: Readonly<DashboardWidgetProps>) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const hasMenuActions = Boolean(onEdit || onResize || onRemove);
return (
<div
@@ -34,7 +44,7 @@ export function DashboardWidget({ title, children, isEditing, onRemove }: Readon
isEditing && "rgl-drag-handle cursor-grab active:cursor-grabbing"
)}>
<h3 className="flex-1 truncate text-sm font-semibold text-gray-800">{title}</h3>
{onRemove && (
{hasMenuActions && (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
@@ -47,15 +57,37 @@ export function DashboardWidget({ title, children, isEditing, onRemove }: Readon
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onSelect={() => {
setMenuOpen(false);
onRemove();
}}
className="text-red-600 focus:text-red-600">
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.remove")}
</DropdownMenuItem>
{onEdit && (
<DropdownMenuItem
onSelect={() => {
setMenuOpen(false);
onEdit();
}}>
<SquarePenIcon className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
)}
{onResize && (
<DropdownMenuItem
onSelect={() => {
setMenuOpen(false);
onResize();
}}>
<Maximize2Icon className="mr-2 h-4 w-4" />
{t("common.resize")}
</DropdownMenuItem>
)}
{onRemove && (
<DropdownMenuItem
onSelect={() => {
setMenuOpen(false);
onRemove();
}}
className="text-red-600 focus:text-red-600">
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.remove")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -4,6 +4,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
import { injectTenantFilter } from "@/modules/ee/analysis/charts/lib/chart-utils";
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { DashboardDetailClient } from "../components/dashboard-detail-client";
import { getDashboard } from "../lib/dashboards";
@@ -16,13 +17,14 @@ interface WidgetQueryResult {
async function executeWidgetQuery(
query: TChartQuery,
feedbackRecordDirectoryId: string
): Promise<WidgetQueryResult | null> {
): Promise<WidgetQueryResult | { error: string }> {
try {
const scopedQuery = injectTenantFilter(query, feedbackRecordDirectoryId);
const data = await executeQuery(scopedQuery as Record<string, unknown>);
return { data: Array.isArray(data) ? data : [], query };
} catch {
return null;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load chart data";
return { error: message };
}
}
@@ -33,6 +35,7 @@ export async function DashboardDetailPage({
}>) {
const { workspaceId, dashboardId } = await params;
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
let dashboard;
try {
@@ -44,27 +47,23 @@ export async function DashboardDetailPage({
throw error;
}
const widgetDataPromises = new Map<string, Promise<WidgetQueryResult>>();
const widgetDataPromises = new Map<string, Promise<WidgetQueryResult | { error: string }>>();
const widgetsWithCharts = dashboard.widgets.filter(
(w): w is typeof w & { chart: NonNullable<typeof w.chart> } => !!w.chart
);
const queryPromises = widgetsWithCharts.map((widget) => ({
widgetId: widget.id,
promise: executeWidgetQuery(widget.chart.query, widget.chart.feedbackRecordDirectoryId),
}));
const results = await Promise.all(queryPromises.map((q) => q.promise));
queryPromises.forEach(({ widgetId }, i: number) => {
const result = results[i];
if (result) {
widgetDataPromises.set(widgetId, Promise.resolve(result));
}
});
for (const widget of widgetsWithCharts) {
widgetDataPromises.set(
widget.id,
executeWidgetQuery(widget.chart.query, widget.chart.feedbackRecordDirectoryId)
);
}
return (
<DashboardDetailClient
workspaceId={workspaceId}
dashboard={dashboard}
widgetDataPromises={widgetDataPromises}
directories={directories}
isReadOnly={isReadOnly}
/>
);
@@ -1,6 +1,9 @@
import { use } from "react";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getTranslate } from "@/lingodotdev/server";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
import { hasWorkspaceFeedbackRecords } from "@/modules/ee/analysis/lib/feedback-records";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { TDashboardWithCount } from "../../types/analysis";
import { CreateDashboardButton } from "../components/create-dashboard-button";
@@ -31,18 +34,30 @@ export const DashboardsListPage = async ({ workspaceId }: Readonly<DashboardsLis
const t = await getTranslate();
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
const dashboardsPromise = getDashboards(workspaceId);
const [hasFeedbackRecords, connectors] = await Promise.all([
hasWorkspaceFeedbackRecords(workspaceId),
getConnectorsWithMappings(workspaceId),
]);
const dashboardsPromise = hasFeedbackRecords ? getDashboards(workspaceId) : null;
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
workspaceId={workspaceId}
cta={isReadOnly ? undefined : <CreateDashboardButton workspaceId={workspaceId} />}>
<DashboardsListContent
dashboardsPromise={dashboardsPromise}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
/>
cta={
isReadOnly ? undefined : (
<CreateDashboardButton workspaceId={workspaceId} disabled={!hasFeedbackRecords} />
)
}>
{hasFeedbackRecords && dashboardsPromise ? (
<DashboardsListContent
dashboardsPromise={dashboardsPromise}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
/>
) : (
<NoFeedbackRecordsState workspaceId={workspaceId} hasFeedbackSources={connectors.length > 0} />
)}
</AnalysisPageLayout>
);
};
@@ -0,0 +1,30 @@
"server-only";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { listFeedbackRecords } from "@/modules/hub/service";
export const hasFeedbackRecordsInDirectories = async (directoryIds: string[]): Promise<boolean> => {
if (directoryIds.length === 0) {
return false;
}
const results = await Promise.all(
directoryIds.map((directoryId) => listFeedbackRecords({ tenant_id: directoryId, limit: 1 }))
);
const hasRecords = results.some((result) => (result.data?.data?.length ?? 0) > 0);
if (hasRecords) {
return true;
}
const hasErrors = results.some((result) => Boolean(result.error));
// Do not lock creation flows when record availability is unknown.
return hasErrors;
};
export const hasWorkspaceFeedbackRecords = async (workspaceId: string): Promise<boolean> => {
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
return hasFeedbackRecordsInDirectories(directories.map((directory) => directory.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);
@@ -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>
);
};
@@ -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}
/>
)}
@@ -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>
@@ -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(),
+4
View File
@@ -3,7 +3,10 @@ export {
createFeedbackRecord,
createFeedbackRecordsBatch,
listFeedbackRecords,
retrieveFeedbackRecord,
updateFeedbackRecord,
type CreateFeedbackRecordResult,
type HubFeedbackRecordResult,
type ListFeedbackRecordsResult,
} from "./service";
export type {
@@ -11,4 +14,5 @@ export type {
FeedbackRecordData,
FeedbackRecordListParams,
FeedbackRecordListResponse,
FeedbackRecordUpdateParams,
} from "./types";
+1 -1
View File
@@ -15,7 +15,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?",
+50 -7
View File
@@ -7,12 +7,16 @@ 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;
};
export type CreateFeedbackRecordResult = HubFeedbackRecordResult;
const NO_CONFIG_ERROR = {
status: 0,
@@ -20,7 +24,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 +36,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 +50,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 +121,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 +133,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);
+1
View File
@@ -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;
+14 -4
View File
@@ -44,7 +44,7 @@ const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4 bg-w
default:
"py-3 px-4 text-sm grid grid-cols-[2fr_auto] grid-rows-[auto_auto] gap-y-0.5 gap-x-3 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
small:
"px-4 py-2 text-xs flex items-center gap-2 [&>svg]:flex-shrink-0 [&_button]:bg-transparent [&_button:hover]:bg-transparent [&>svg~*]:pl-0",
"px-4 py-2 text-xs flex items-center gap-2 [&>svg]:flex-shrink-0 [&_button]:bg-transparent [&_button:hover]:bg-transparent [&_a]:bg-transparent [&_a:hover]:bg-transparent [&>svg~*]:pl-0",
},
},
defaultVariants: {
@@ -94,8 +94,8 @@ const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<H
<h5
ref={ref}
className={cn(
"col-start-1 row-start-1 font-medium tracking-tight",
size === "small" ? "flex-shrink truncate" : "col-start-1 row-start-1",
"col-start-1 row-start-1 tracking-tight",
size === "small" ? "flex-shrink truncate font-normal" : "col-start-1 row-start-1 font-medium",
className
)}
{...props}>
@@ -133,6 +133,7 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
// Determine button styling based on alert context
const buttonVariant = variant ?? (alertSize === "small" ? "link" : "secondary");
const buttonSize = size ?? (alertSize === "small" ? "sm" : "default");
const isSmallLinkButton = alertSize === "small" && buttonVariant === "link";
return (
<div
@@ -142,7 +143,16 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
? "-my-2 -mr-4 ml-auto flex-shrink-0"
: "col-start-2 row-span-2 row-start-1 flex items-center justify-center"
)}>
<Button ref={ref} variant={buttonVariant} size={buttonSize} className={className} {...props}>
<Button
ref={ref}
variant={buttonVariant}
size={buttonSize}
className={cn(
isSmallLinkButton &&
"bg-transparent font-normal underline-offset-4 hover:bg-transparent hover:underline",
className
)}
{...props}>
{children}
</Button>
</div>
@@ -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;
@@ -31,11 +31,22 @@ import {
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { getTeamsByOrganizationIdAction } from "@/modules/workspaces/settings/actions";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import {
getFeedbackRecordDirectoriesByOrganizationIdAction,
getTeamsByOrganizationIdAction,
} from "@/modules/workspaces/settings/actions";
const ZCreateWorkspaceForm = z.object({
name: ZWorkspace.shape.name,
teamIds: z.array(z.string()).optional(),
feedbackRecordDirectoryId: z.string().optional(),
});
type TCreateWorkspaceForm = z.infer<typeof ZCreateWorkspaceForm>;
@@ -57,27 +68,51 @@ export const CreateWorkspaceModal = ({
const router = useRouter();
const [organizationTeams, setOrganizationTeams] = useState<TOrganizationTeam[]>([]);
useEffect(() => {
const fetchOrganizationTeams = async () => {
const response = await getTeamsByOrganizationIdAction({ organizationId });
if (response?.data) {
setOrganizationTeams(response.data);
} else {
const errorMessage = getFormattedErrorMessage(response);
toast.error(errorMessage);
}
};
fetchOrganizationTeams();
}, [organizationId]);
const [feedbackDirectories, setFeedbackDirectories] = useState<{ id: string; name: string }[]>([]);
const form = useForm<TCreateWorkspaceForm>({
resolver: zodResolver(ZCreateWorkspaceForm),
defaultValues: {
name: "",
teamIds: [],
feedbackRecordDirectoryId: undefined,
},
});
const { getValues, setValue } = form;
useEffect(() => {
const fetchModalData = async () => {
const [teamsResponse, directoriesResponse] = await Promise.all([
getTeamsByOrganizationIdAction({ organizationId }),
getFeedbackRecordDirectoriesByOrganizationIdAction({ organizationId }),
]);
if (teamsResponse?.data) {
setOrganizationTeams(teamsResponse.data);
} else {
const errorMessage = getFormattedErrorMessage(teamsResponse);
toast.error(errorMessage);
}
if (directoriesResponse?.data) {
setFeedbackDirectories(directoriesResponse.data);
const selectedFeedbackDirectory = getValues("feedbackRecordDirectoryId");
const isSelectedDirectoryAvailable = directoriesResponse.data.some(
(directory) => directory.id === selectedFeedbackDirectory
);
if (directoriesResponse.data.length === 0) {
setValue("feedbackRecordDirectoryId", undefined);
} else if (!selectedFeedbackDirectory || !isSelectedDirectoryAvailable) {
setValue("feedbackRecordDirectoryId", directoriesResponse.data[0].id);
}
} else {
const errorMessage = getFormattedErrorMessage(directoriesResponse);
toast.error(errorMessage);
}
};
fetchModalData();
}, [organizationId, getValues, setValue]);
const { isSubmitting } = form.formState;
@@ -92,6 +127,7 @@ export const CreateWorkspaceModal = ({
data: {
name: data.name,
teamIds: data.teamIds || [],
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
},
});
@@ -138,6 +174,40 @@ export const CreateWorkspaceModal = ({
)}
/>
<FormField
control={form.control}
name="feedbackRecordDirectoryId"
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
<FormControl>
<Select
value={field.value ?? ""}
onValueChange={field.onChange}
disabled={feedbackDirectories.length === 0}>
<SelectTrigger>
<SelectValue
placeholder={
feedbackDirectories.length > 0
? t("workspace.unify.select_feedback_record_directory")
: t("workspace.unify.no_feedback_record_directory_available")
}
/>
</SelectTrigger>
<SelectContent>
{feedbackDirectories.map((directory) => (
<SelectItem key={directory.id} value={directory.id}>
{directory.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
)}
/>
{isAccessControlAllowed && organizationTeams.length > 0 && (
<FormField
control={form.control}
@@ -11,6 +11,7 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspace } from "@/lib/workspace/service";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getFeedbackRecordDirectories } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
import { updateWorkspace } from "@/modules/workspaces/settings/lib/workspace";
@@ -96,3 +97,25 @@ export const getTeamsByOrganizationIdAction = authenticatedActionClient
const teams = await getTeamsByOrganizationId(parsedInput.organizationId);
return teams;
});
const ZGetFeedbackRecordDirectoriesByOrganizationIdAction = z.object({
organizationId: ZId,
});
export const getFeedbackRecordDirectoriesByOrganizationIdAction = authenticatedActionClient
.inputSchema(ZGetFeedbackRecordDirectoriesByOrganizationIdAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const directories = await getFeedbackRecordDirectories(parsedInput.organizationId);
return directories.filter((directory) => !directory.isArchived).map(({ id, name }) => ({ id, name }));
});
@@ -1,6 +1,14 @@
"use client";
import { BlocksIcon, BrushIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react";
import {
BlocksIcon,
BrushIcon,
LanguagesIcon,
ListChecksIcon,
ShapesIcon,
TagIcon,
UsersIcon,
} from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
@@ -39,6 +47,13 @@ export const WorkspaceConfigNavigation = ({ activeId, loading }: WorkspaceConfig
href: `${workspaceBasePath}/app-connection`,
current: pathname?.includes("/app-connection"),
},
{
id: "feedback-sources",
label: t("workspace.unify.feedback_sources"),
icon: <ShapesIcon className="h-5 w-5" />,
href: `${workspaceBasePath}/feedback-sources`,
current: pathname?.includes("/feedback-sources"),
},
{
id: "integrations",
label: t("common.integrations"),
@@ -40,6 +40,7 @@ vi.mock("@formbricks/database", () => ({
},
feedbackRecordDirectory: {
upsert: vi.fn(),
findFirst: vi.fn(),
},
feedbackRecordDirectoryWorkspace: {
count: vi.fn(),
@@ -136,6 +137,34 @@ describe("workspace lib", () => {
});
});
test("creates workspace and links selected feedback directory when provided", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p-selected" };
vi.mocked(prisma.feedbackRecordDirectory.findFirst).mockResolvedValueOnce({
id: "frd-selected",
} as any);
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.create).mockResolvedValueOnce({} as any);
const result = await createWorkspace("org1", {
name: "Workspace with Selected Directory",
feedbackRecordDirectoryId: "frd-selected",
});
expect(result).toEqual(createdWorkspace);
expect(prisma.feedbackRecordDirectory.findFirst).toHaveBeenCalledWith({
where: {
id: "frd-selected",
organizationId: "org1",
isArchived: false,
},
select: { id: true },
});
expect(prisma.feedbackRecordDirectoryWorkspace.create).toHaveBeenCalledWith({
data: { feedbackRecordDirectoryId: "frd-selected", workspaceId: "p-selected" },
});
expect(prisma.feedbackRecordDirectory.upsert).not.toHaveBeenCalled();
});
test("skips FRD link when default FRD already has links", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p4" };
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
@@ -147,6 +176,19 @@ describe("workspace lib", () => {
expect(prisma.feedbackRecordDirectoryWorkspace.create).not.toHaveBeenCalled();
});
test("throws InvalidInputError when selected feedback directory is invalid", async () => {
vi.mocked(prisma.feedbackRecordDirectory.findFirst).mockResolvedValueOnce(null);
await expect(
createWorkspace("org1", {
name: "Workspace with Invalid Directory",
feedbackRecordDirectoryId: "frd-missing",
})
).rejects.toThrow(InvalidInputError);
expect(prisma.workspace.create).not.toHaveBeenCalled();
});
test("throws ValidationError if name is missing", async () => {
await expect(createWorkspace("org1", {})).rejects.toThrow(ValidationError);
});
@@ -29,6 +29,14 @@ const selectWorkspace = {
customHeadScripts: true,
};
type TCreateWorkspaceInput = Partial<TWorkspaceUpdateInput> & {
feedbackRecordDirectoryId?: string;
};
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.partial().extend({
feedbackRecordDirectoryId: ZId.optional(),
});
export const updateWorkspace = async (
workspaceId: string,
inputWorkspace: TWorkspaceUpdateInput
@@ -56,17 +64,32 @@ export const updateWorkspace = async (
export const createWorkspace = async (
organizationId: string,
workspaceInput: Partial<TWorkspaceUpdateInput>
workspaceInput: TCreateWorkspaceInput
): Promise<TWorkspace> => {
validateInputs([organizationId, ZString], [workspaceInput, ZWorkspaceUpdateInput.partial()]);
validateInputs([organizationId, ZString], [workspaceInput, ZCreateWorkspaceInput]);
if (!workspaceInput.name) {
throw new ValidationError("Workspace Name is required");
}
const { teamIds, ...data } = workspaceInput;
const { teamIds, feedbackRecordDirectoryId, ...data } = workspaceInput;
try {
if (feedbackRecordDirectoryId) {
const feedbackDirectory = await prisma.feedbackRecordDirectory.findFirst({
where: {
id: feedbackRecordDirectoryId,
organizationId,
isArchived: false,
},
select: { id: true },
});
if (!feedbackDirectory) {
throw new InvalidInputError("FEEDBACK_RECORD_DIRECTORY_NOT_FOUND");
}
}
const workspace = await prisma.workspace.create({
data: {
config: {
@@ -89,6 +112,17 @@ export const createWorkspace = async (
});
}
if (feedbackRecordDirectoryId) {
await prisma.feedbackRecordDirectoryWorkspace.create({
data: {
feedbackRecordDirectoryId,
workspaceId: workspace.id,
},
});
return workspace;
}
// Ensure default FRD exists + link to first workspace atomically
const defaultFrd = await prisma.feedbackRecordDirectory.upsert({
where: {
@@ -0,0 +1,42 @@
import { notFound } from "next/navigation";
import { ConnectorsSection } from "@/app/(app)/workspaces/[workspaceId]/unify/sources/components/connectors-page-client";
import { transformToUnifySurvey } from "@/app/(app)/workspaces/[workspaceId]/unify/sources/lib";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getSurveys } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export const WorkspaceSourcesPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const t = await getTranslate();
const params = await props.params;
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
await getWorkspaceAuth(params.workspaceId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
if (!hasAccess) {
return notFound();
}
const [connectors, surveys, directories] = await Promise.all([
getConnectorsWithMappings(params.workspaceId),
getSurveys(params.workspaceId),
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
]);
const unifySurveys = surveys.map(transformToUnifySurvey);
return (
<ConnectorsSection
workspaceId={params.workspaceId}
initialConnectors={connectors}
initialSurveys={unifySurveys}
directories={directories}
/>
);
};
@@ -0,0 +1 @@
ALTER TYPE "public"."ConnectorType" RENAME VALUE 'formbricks' TO 'formbricks_survey';
+2 -2
View File
@@ -1170,7 +1170,7 @@ model DashboardWidget {
}
enum ConnectorType {
formbricks
formbricks_survey
csv
}
@@ -1197,7 +1197,7 @@ enum HubFieldType {
///
/// @property id - Unique identifier for the connector
/// @property name - Display name for the connector
/// @property type - Type of connector (formbricks, webhook, csv, email, slack)
/// @property type - Type of connector (formbricks_survey, webhook, csv, email, slack)
/// @property status - Current state of the connector (active, paused)
/// @property environment - The environment this connector belongs to
/// @property config - Type-specific configuration (e.g., webhook secret, S3 config)
+3 -1
View File
@@ -2,7 +2,7 @@ import { z } from "zod";
import { TSurveyElementTypeEnum } from "./surveys/constants";
// Connector type enum
export const ZConnectorType = z.enum(["formbricks", "csv"]);
export const ZConnectorType = z.enum(["formbricks_survey", "csv"]);
export type TConnectorType = z.infer<typeof ZConnectorType>;
// Connector status enum
@@ -139,6 +139,8 @@ export const UNSUPPORTED_CONNECTOR_ELEMENT_TYPES: readonly TSurveyElementTypeEnu
export const ELEMENT_TYPE_TO_HUB_FIELD_TYPE: Record<string, THubFieldType> = {
openText: "text",
nps: "nps",
csat: "csat",
ces: "ces",
rating: "rating",
multipleChoiceSingle: "categorical",
multipleChoiceMulti: "categorical",