mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 19:35:53 -05:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c932a8583 | |||
| 99d794ad53 | |||
| b123965d93 | |||
| aed47b94a8 | |||
| ecaa2887b7 | |||
| ad094b2d4c | |||
| b099219244 | |||
| af82329c4a | |||
| cfff6b1495 | |||
| 9bc4e69821 | |||
| 81b1c036f6 | |||
| 635200db78 | |||
| 793320746e | |||
| 6501041a48 | |||
| f5337e77f3 | |||
| 0192c1ed00 | |||
| 2e2b13c36b | |||
| 95dd4404d1 | |||
| 8a9912f839 | |||
| 016dc3d92a | |||
| 3a147a2b09 | |||
| e159b45911 | |||
| 96d14b98f0 | |||
| aa90d9fd1a | |||
| 2ffe79ffd2 | |||
| cffeb0513e | |||
| bc334c24cf | |||
| 077a9934ad | |||
| 1ed8d8076e | |||
| 345b282733 | |||
| c7c30a9d58 | |||
| 08510659de | |||
| f8fa29d56e | |||
| 8b048c3105 | |||
| b2705a4f8f | |||
| e867caa373 | |||
| ff6176df0a | |||
| d0f4228b45 | |||
| de79b58648 | |||
| 04d528b9b8 | |||
| c815b11015 | |||
| 1e7830d850 | |||
| 77cd1e9bd1 | |||
| e665227437 | |||
| 3a802810e3 | |||
| fbbf917093 | |||
| a7e42bfd29 | |||
| 562fdec899 | |||
| 75e71e39bc | |||
| 337aedf463 | |||
| d670d5de31 | |||
| 5ccb4af249 | |||
| 62aa186a81 | |||
| cb094761ca | |||
| f35e54f21d | |||
| f49f40610b | |||
| 9e754bad9c | |||
| 4dcf6fda40 | |||
| 1b8ccd7199 | |||
| 4f9088559f | |||
| 18550f1d11 | |||
| 881cd31f74 | |||
| e00405dca2 |
@@ -0,0 +1 @@
|
||||
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
|
||||
@@ -278,5 +278,23 @@ REDIS_URL=redis://localhost:6379
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
|
||||
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
|
||||
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
|
||||
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
|
||||
# CUBEJS_API_SECRET=
|
||||
# URL where the Cube.js instance is running
|
||||
# CUBEJS_API_URL=http://localhost:4000
|
||||
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
|
||||
# CUBEJS_API_TOKEN=
|
||||
#
|
||||
# Cube connects to the Hub DB. With docker-compose.dev.yml defaults, use the local postgres service.
|
||||
# CUBEJS_DB_HOST=postgres
|
||||
# CUBEJS_DB_PORT=5432
|
||||
# CUBEJS_DB_NAME=postgres
|
||||
# CUBEJS_DB_USER=postgres
|
||||
# CUBEJS_DB_PASS=postgres
|
||||
#
|
||||
# Alternative (external Hub/Postgres on the hub network): formbricks_hub_postgres, db: hub, user/pass: formbricks/formbricks_dev
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGO_API_KEY=your_api_key_here
|
||||
|
||||
@@ -32,6 +32,7 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
|
||||
|
||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||
We are using SonarQube to identify code smells and security hotspots.
|
||||
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
|
||||
|
||||
## Architecture & Patterns
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Page = () => {
|
||||
return redirect("/");
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
|
||||
|
||||
const ChartsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const { workspaceId } = await props.params;
|
||||
return <ChartsListPage workspaceId={workspaceId} />;
|
||||
};
|
||||
|
||||
export default ChartsPage;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
|
||||
|
||||
const Page = (props: { params: Promise<{ workspaceId: string; dashboardId: string }> }) => {
|
||||
return <DashboardDetailPage params={props.params} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
|
||||
|
||||
const DashboardsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const { workspaceId } = await props.params;
|
||||
return <DashboardsListPage workspaceId={workspaceId} />;
|
||||
};
|
||||
|
||||
export default DashboardsPage;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { AnalysisListLoading } from "@/modules/ee/analysis/loading";
|
||||
|
||||
export default AnalysisListLoading;
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BarChart3Icon,
|
||||
Building2Icon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
Loader2,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
MessageSquareTextIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
@@ -144,44 +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,
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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.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,
|
||||
id: "unify-feedback",
|
||||
name: t("workspace.unify.unify_feedback"),
|
||||
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"),
|
||||
@@ -240,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"),
|
||||
@@ -521,23 +561,50 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
|
||||
{/* Main Nav Switch */}
|
||||
<ul>
|
||||
{mainNavigation.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{mainNavigationSections.map((section) => (
|
||||
<li key={section.id}>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<p className="px-4 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
{section.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul>
|
||||
{section.items.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
|
||||
<NavigationLink
|
||||
href={configurationNavigationItem.href}
|
||||
isActive={configurationNavigationItem.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={configurationNavigationItem.disabled}
|
||||
disabledMessage={
|
||||
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
|
||||
}
|
||||
linkText={configurationNavigationItem.name}>
|
||||
<configurationNavigationItem.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -118,6 +118,11 @@ export const WorkspaceBreadcrumb = ({
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `${workspaceBasePath}/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "feedback-sources",
|
||||
label: t("workspace.unify.feedback_sources"),
|
||||
href: `${workspaceBasePath}/feedback-sources`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
@@ -138,6 +143,11 @@ export const WorkspaceBreadcrumb = ({
|
||||
label: t("common.tags"),
|
||||
href: `${workspaceBasePath}/tags`,
|
||||
},
|
||||
{
|
||||
id: "unify",
|
||||
label: t("common.unify"),
|
||||
href: `${workspaceBasePath}/workspace/unify`,
|
||||
},
|
||||
];
|
||||
|
||||
const areWorkspaceSettingsDisabled = isMembershipPending || isBilling;
|
||||
|
||||
@@ -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
-1
@@ -287,7 +287,7 @@ export const ElementFilterComboBox = ({
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
|
||||
<div className="absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none animate-in">
|
||||
<CommandList className="max-h-52">
|
||||
<CommandInput
|
||||
value={searchQuery}
|
||||
|
||||
+1
-1
@@ -232,7 +232,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
|
||||
<div className="absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none animate-in">
|
||||
<CommandList className="max-h-[600px]">
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface UnifyConfigNavigationProps {
|
||||
workspaceId: string;
|
||||
activeId?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const UnifyConfigNavigation = ({
|
||||
workspaceId,
|
||||
activeId: activeIdProp,
|
||||
loading,
|
||||
}: UnifyConfigNavigationProps) => {
|
||||
const { t } = useTranslation();
|
||||
const baseHref = `/workspaces/${workspaceId}/unify`;
|
||||
|
||||
const activeId = activeIdProp ?? "feedback-records";
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
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;
|
||||
}
|
||||
);
|
||||
+988
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UnifyConfigNavigation } from "../components/UnifyConfigNavigation";
|
||||
import { FeedbackRecordsTable } from "./feedback-records-table";
|
||||
|
||||
interface FeedbackRecordsPageClientProps {
|
||||
workspaceId: string;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export function FeedbackRecordsPageClient({
|
||||
workspaceId,
|
||||
initialRecords,
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsPageClientProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
|
||||
<UnifyConfigNavigation workspaceId={workspaceId} activeId="feedback-records" />
|
||||
</PageHeader>
|
||||
|
||||
<FeedbackRecordsTable
|
||||
workspaceId={workspaceId}
|
||||
initialRecords={initialRecords}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
HashIcon,
|
||||
MessageSquareTextIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ToggleLeftIcon,
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { CsvImportModal } from "../sources/components/csv-import-modal";
|
||||
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
|
||||
|
||||
const RECORDS_PER_PAGE = 50;
|
||||
|
||||
const FIELD_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
text: <TypeIcon className="h-3.5 w-3.5" />,
|
||||
categorical: <HashIcon className="h-3.5 w-3.5" />,
|
||||
nps: <HashIcon className="h-3.5 w-3.5" />,
|
||||
csat: <HashIcon className="h-3.5 w-3.5" />,
|
||||
ces: <HashIcon className="h-3.5 w-3.5" />,
|
||||
rating: <HashIcon className="h-3.5 w-3.5" />,
|
||||
number: <HashIcon className="h-3.5 w-3.5" />,
|
||||
boolean: <ToggleLeftIcon className="h-3.5 w-3.5" />,
|
||||
date: <CalendarIcon className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string): string => {
|
||||
if (record.value_text != null) return record.value_text;
|
||||
if (record.value_number != null) return String(record.value_number);
|
||||
if (record.value_boolean != null) return record.value_boolean ? t("common.yes") : t("common.no");
|
||||
if (record.value_date != null) return formatDateForDisplay(new Date(record.value_date), locale);
|
||||
return "—";
|
||||
};
|
||||
|
||||
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) + "…";
|
||||
}
|
||||
|
||||
interface FeedbackRecordsTableProps {
|
||||
workspaceId: string;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export const FeedbackRecordsTable = ({
|
||||
workspaceId,
|
||||
initialRecords,
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsTableProps>) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit");
|
||||
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const directories = useMemo(
|
||||
() =>
|
||||
Object.entries(frdMap)
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[frdMap]
|
||||
);
|
||||
const feedbackDirectoryName = useMemo(() => {
|
||||
const directoryNames = Array.from(
|
||||
new Set(
|
||||
records
|
||||
.map((record) => frdMap[record.tenant_id])
|
||||
.filter((directoryName): directoryName is string => Boolean(directoryName))
|
||||
)
|
||||
);
|
||||
|
||||
if (directoryNames.length > 0) {
|
||||
return directoryNames.join(", ");
|
||||
}
|
||||
|
||||
return directories[0]?.name ?? "—";
|
||||
}, [directories, frdMap, records]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
|
||||
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
|
||||
|
||||
const result = await listFeedbackRecordsAction({
|
||||
workspaceId,
|
||||
limit: RECORDS_PER_PAGE,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result) ?? t("workspace.unify.failed_to_load_feedback_records"), {
|
||||
id: toastId,
|
||||
});
|
||||
setIsRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecords(result.data.data);
|
||||
setIsRefreshing(false);
|
||||
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex h-48 flex-col items-center justify-center gap-3 px-4 text-center">
|
||||
<MessageSquareTextIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="text-sm text-slate-500">{error}</p>
|
||||
<Button variant="secondary" size="sm" onClick={handleRefresh}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = records.length === 0 && !isRefreshing;
|
||||
|
||||
const openEditDrawer = (recordId: string) => {
|
||||
setDrawerMode("edit");
|
||||
setDrawerRecordId(recordId);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
setDrawerMode("create");
|
||||
setDrawerRecordId(undefined);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const hasCsvSources = csvSources.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{isEmpty ? (
|
||||
<span />
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.showing_count_loaded", {
|
||||
count: records.length,
|
||||
directoryName: feedbackDirectoryName,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{canWrite &&
|
||||
(hasCsvSources ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="secondary">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={openCreateDrawer}>
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{csvSources.map((source) => (
|
||||
<DropdownMenuItem
|
||||
key={source.id}
|
||||
onClick={() => {
|
||||
setCsvImportSource(source);
|
||||
}}>
|
||||
{t("workspace.unify.import_via_source_name", { sourceName: source.name })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={openCreateDrawer}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
</Button>
|
||||
))}
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
|
||||
{t("workspace.unify.manage_feedback_sources")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t("workspace.unify.refresh_feedback_records")}>
|
||||
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{records.map((record) => (
|
||||
<FeedbackRecordRow
|
||||
key={record.id}
|
||||
record={record}
|
||||
workspaceId={workspaceId}
|
||||
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
|
||||
t={t}
|
||||
onClick={() => openEditDrawer(record.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FeedbackRecordFormDrawer
|
||||
mode={drawerMode}
|
||||
open={isDrawerOpen}
|
||||
onOpenChange={setIsDrawerOpen}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
canWrite={canWrite}
|
||||
recordId={drawerMode === "edit" ? drawerRecordId : undefined}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
|
||||
{csvImportSource && (
|
||||
<CsvImportModal
|
||||
open={csvImportSource !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCsvImportSource(null);
|
||||
}
|
||||
}}
|
||||
connectorId={csvImportSource.id}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FeedbackRecordRow = ({
|
||||
record,
|
||||
workspaceId,
|
||||
locale,
|
||||
t,
|
||||
onClick,
|
||||
}: {
|
||||
record: FeedbackRecordData;
|
||||
workspaceId: string;
|
||||
locale: string;
|
||||
t: TFunction;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const value = formatValue(record, t, locale);
|
||||
const isLongValue = value.length > 60;
|
||||
const isFormbricksSurveySource =
|
||||
(record.source_type === "formbricks" || record.source_type === "formbricks_survey") && !!record.source_id;
|
||||
const surveySummaryHref = `/workspaces/${workspaceId}/surveys/${record.source_id}/summary`;
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="cursor-pointer text-sm text-slate-700 transition-colors hover:bg-slate-50"
|
||||
onClick={onClick}>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
|
||||
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<Badge text={formatSourceType(record.source_type, t)} type="gray" size="tiny" />
|
||||
</td>
|
||||
<td className="max-w-[150px] truncate px-4 py-3" title={record.source_name ?? undefined}>
|
||||
{isFormbricksSurveySource ? (
|
||||
<Link
|
||||
href={surveySummaryHref}
|
||||
className="text-slate-700 underline underline-offset-2 hover:text-slate-900"
|
||||
onClick={(event) => event.stopPropagation()}>
|
||||
{record.source_name ?? "—"}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{record.source_name ?? "—"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate px-4 py-3" title={record.field_label ?? undefined}>
|
||||
{record.field_label ?? record.field_id}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1 text-slate-600">
|
||||
{FIELD_TYPE_ICONS[record.field_type] ?? <HashIcon className="h-3.5 w-3.5" />}
|
||||
{record.field_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-[250px] px-4 py-3">
|
||||
{isLongValue ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{truncate(value, 60)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm whitespace-pre-wrap">
|
||||
{value}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span>{value}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[120px] truncate px-4 py-3 text-slate-500" title={record.user_identifier}>
|
||||
{record.user_identifier ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
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 { listFeedbackRecords } from "@/modules/hub/service";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { FeedbackRecordsPageClient } from "./feedback-records-page-client";
|
||||
|
||||
const INITIAL_PAGE_SIZE = 50;
|
||||
|
||||
export default async function UnifyFeedbackRecordsPage(
|
||||
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;
|
||||
const canWrite = isOwner || isManager || hasReadWriteAccess || hasManageAccess;
|
||||
if (!hasAccess) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const [frds, connectors] = await Promise.all([
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
]);
|
||||
|
||||
const results = await Promise.all(
|
||||
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
|
||||
);
|
||||
|
||||
// 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}
|
||||
initialRecords={merged}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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/feedback-records`);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { transformToUnifySurvey } from "./lib";
|
||||
import { TUnifySurvey } from "./types";
|
||||
|
||||
const ZGetSurveysForUnifyAction = z.object({
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const getSurveysForUnifyAction = authenticatedActionClient
|
||||
.schema(ZGetSurveysForUnifyAction)
|
||||
.action(async ({ ctx, parsedInput }): Promise<TUnifySurvey[]> => {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const surveys = await getSurveys(parsedInput.workspaceId);
|
||||
return surveys.map((survey) => transformToUnifySurvey(survey));
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
|
||||
import { TConnectorType } from "@formbricks/types/connector";
|
||||
|
||||
export const getConnectorIcon = (type: TConnectorType, className: string) => {
|
||||
switch (type) {
|
||||
case "formbricks_survey":
|
||||
return <FormIcon className={className} />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className={className} />;
|
||||
default:
|
||||
return <FormIcon className={className} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const getConnectorTypeLabelKey = (type: TConnectorType): string => {
|
||||
switch (type) {
|
||||
case "formbricks_survey":
|
||||
return "workspace.unify.formbricks_surveys";
|
||||
case "csv":
|
||||
return "workspace.unify.csv_import";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { FEEDBACK_RECORD_FIELDS, TFieldMapping } from "../types";
|
||||
|
||||
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
|
||||
|
||||
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
|
||||
const requiredFieldIds = new Set(
|
||||
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
|
||||
);
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!requiredFieldIds.has(mapping.targetFieldId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mapping.sourceFieldId || mapping.staticValue) {
|
||||
requiredFieldIds.delete(mapping.targetFieldId);
|
||||
}
|
||||
}
|
||||
|
||||
return requiredFieldIds.size === 0;
|
||||
};
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
FileSpreadsheetIcon,
|
||||
MoreVertical,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
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";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface ConnectorRowDropdownProps {
|
||||
connector: TConnectorWithMappings;
|
||||
onEdit: () => void;
|
||||
onCsvImport?: () => void;
|
||||
onDuplicate: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ConnectorRowDropdown({
|
||||
connector,
|
||||
onEdit,
|
||||
onCsvImport,
|
||||
onDuplicate,
|
||||
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);
|
||||
try {
|
||||
await onDelete();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div // eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
data-testid="connector-row-dropdown">
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenuTrigger className="z-10" asChild>
|
||||
<div className="cursor-pointer rounded-lg border bg-white p-2 hover:bg-slate-50">
|
||||
<span className="sr-only">{t("workspace.surveys.open_options")}</span>
|
||||
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="inline-block w-auto min-w-max">
|
||||
<DropdownMenuGroup>
|
||||
{connector.type === "csv" && onCsvImport && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
onCsvImport();
|
||||
}}>
|
||||
<FileSpreadsheetIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.unify.import_csv_data")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{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"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
onEdit();
|
||||
}}>
|
||||
<SquarePenIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
await onDuplicate();
|
||||
}}>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.duplicate")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
await onToggleStatus();
|
||||
}}>
|
||||
{isActive ? <PauseIcon className="mr-2 h-4 w-4" /> : <PlayIcon className="mr-2 h-4 w-4" />}
|
||||
{isActive ? t("common.disable") : t("common.enable")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat={t("workspace.unify.source")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { TConnectorOptionId, getConnectorOptions } from "../utils";
|
||||
|
||||
interface ConnectorTypeSelectorProps {
|
||||
selectedType: TConnectorOptionId | null;
|
||||
onSelectType: (type: TConnectorOptionId) => void;
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="space-y-2">
|
||||
{connectorOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
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 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-0.5 text-xs text-slate-500">{option.description}</p>
|
||||
</div>
|
||||
<div
|
||||
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-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>
|
||||
);
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
"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,
|
||||
duplicateConnectorAction,
|
||||
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 { 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";
|
||||
import { CsvImportModal } from "./csv-import-modal";
|
||||
import { EditConnectorModal } from "./edit-connector-modal";
|
||||
|
||||
interface ConnectorsSectionProps {
|
||||
workspaceId: string;
|
||||
initialConnectors: TConnectorWithMappings[];
|
||||
initialSurveys: TUnifySurvey[];
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function ConnectorsSection({
|
||||
workspaceId,
|
||||
initialConnectors,
|
||||
initialSurveys,
|
||||
directories,
|
||||
}: 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;
|
||||
type: TConnectorType;
|
||||
feedbackRecordDirectoryId: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}): Promise<string | undefined> => {
|
||||
const result = await createConnectorWithMappingsAction({
|
||||
workspaceId: workspaceId,
|
||||
connectorInput: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
},
|
||||
formbricksMappings:
|
||||
data.type === "formbricks_survey" && data.surveyMappings?.length ? data.surveyMappings : undefined,
|
||||
fieldMappings:
|
||||
data.type !== "formbricks_survey" && data.fieldMappings?.length
|
||||
? data.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId || "",
|
||||
targetFieldId: m.targetFieldId as THubTargetField,
|
||||
staticValue: m.staticValue,
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_created_successfully"));
|
||||
router.refresh();
|
||||
return result.data.id;
|
||||
};
|
||||
|
||||
const handleUpdateConnector = async (data: {
|
||||
connectorId: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => {
|
||||
const result = await updateConnectorWithMappingsAction({
|
||||
connectorId: data.connectorId,
|
||||
workspaceId: workspaceId,
|
||||
connectorInput: {
|
||||
name: data.name,
|
||||
},
|
||||
formbricksMappings: data.surveyMappings?.length ? data.surveyMappings : undefined,
|
||||
fieldMappings: data.fieldMappings?.length
|
||||
? data.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId || "",
|
||||
targetFieldId: m.targetFieldId as THubTargetField,
|
||||
staticValue: m.staticValue,
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_updated_successfully"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
|
||||
const result = await deleteConnectorAction({ connectorId, workspaceId: workspaceId });
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_deleted_successfully"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDuplicateConnector = async (connector: TConnectorWithMappings): Promise<void> => {
|
||||
const result = await duplicateConnectorAction({
|
||||
connectorId: connector.id,
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_duplicated_successfully"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (connector: TConnectorWithMappings): Promise<void> => {
|
||||
const newStatus = connector.status === "active" ? "paused" : "active";
|
||||
const result = await updateConnectorWithMappingsAction({
|
||||
connectorId: connector.id,
|
||||
workspaceId: workspaceId,
|
||||
connectorInput: { status: newStatus },
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_status_updated_successfully"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<WorkspaceConfigNavigation activeId="feedback-sources" />
|
||||
</PageHeader>
|
||||
|
||||
<SettingsCard
|
||||
title={t("workspace.unify.feedback_sources")}
|
||||
description={t("workspace.unify.feedback_sources_settings_description")}
|
||||
cta={
|
||||
<CreateConnectorModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
onCreateConnector={handleCreateConnector}
|
||||
surveys={initialSurveys}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
/>
|
||||
}>
|
||||
<ConnectorsTable
|
||||
connectors={initialConnectors}
|
||||
onConnectorClick={setEditingConnector}
|
||||
onCsvImport={setCsvImportConnector}
|
||||
onDuplicate={handleDuplicateConnector}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={handleDeleteConnector}
|
||||
isLoading={false}
|
||||
/>
|
||||
{directories.length > 0 && (
|
||||
<Alert size="small" className="mt-4">
|
||||
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<EditConnectorModal
|
||||
connector={editingConnector}
|
||||
open={editingConnector !== null}
|
||||
onOpenChange={(open) => !open && setEditingConnector(null)}
|
||||
onUpdateConnector={handleUpdateConnector}
|
||||
surveys={initialSurveys}
|
||||
onOpenCsvImport={() => {
|
||||
if (editingConnector) {
|
||||
setCsvImportConnector(editingConnector);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{csvImportConnector && (
|
||||
<CsvImportModal
|
||||
open={csvImportConnector !== null}
|
||||
onOpenChange={(open) => !open && setCsvImportConnector(null)}
|
||||
connectorId={csvImportConnector.id}
|
||||
workspaceId={csvImportConnector.workspaceId}
|
||||
onOpenEditConnector={() => {
|
||||
setEditingConnector(csvImportConnector);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
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 }[] = [
|
||||
{ amount: 60, unit: "seconds" },
|
||||
{ amount: 60, unit: "minutes" },
|
||||
{ amount: 24, unit: "hours" },
|
||||
{ amount: 7, unit: "days" },
|
||||
{ amount: 4.345, unit: "weeks" },
|
||||
{ amount: 12, unit: "months" },
|
||||
{ amount: Number.POSITIVE_INFINITY, unit: "years" },
|
||||
];
|
||||
|
||||
function getRelativeTime(date: Date, locale: string) {
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
let duration = (date.getTime() - Date.now()) / 1000;
|
||||
|
||||
for (const division of RELATIVE_TIME_DIVISIONS) {
|
||||
if (Math.abs(duration) < division.amount) {
|
||||
return formatter.format(Math.round(duration), division.unit);
|
||||
}
|
||||
duration /= division.amount;
|
||||
}
|
||||
|
||||
return formatter.format(Math.round(duration), "years");
|
||||
}
|
||||
|
||||
interface ConnectorsTableDataRowProps {
|
||||
connector: TConnectorWithMappings;
|
||||
onEdit: () => void;
|
||||
onCsvImport?: () => void;
|
||||
onDuplicate: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
|
||||
active: "success",
|
||||
paused: "warning",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
export function ConnectorsTableDataRow({
|
||||
connector,
|
||||
onEdit,
|
||||
onCsvImport,
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: Readonly<ConnectorsTableDataRowProps>) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const handleRowClick = () => {
|
||||
if (connector.type === "csv" && onCsvImport) {
|
||||
onCsvImport();
|
||||
return;
|
||||
}
|
||||
|
||||
onEdit();
|
||||
};
|
||||
|
||||
const getStatusLabel = (s: TConnectorStatus, connectorType: TConnectorType) => {
|
||||
switch (s) {
|
||||
case "active":
|
||||
if (connectorType === "csv") {
|
||||
return t("workspace.unify.status_ready");
|
||||
}
|
||||
return t("workspace.unify.status_live_sync");
|
||||
case "paused":
|
||||
return t("workspace.unify.status_paused");
|
||||
case "error":
|
||||
return t("workspace.unify.status_error");
|
||||
}
|
||||
};
|
||||
|
||||
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={handleRowClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleRowClick();
|
||||
}
|
||||
}}>
|
||||
<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-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, 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.updatedAt, i18n.language)}
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||
<span className="truncate">{connector.creatorName ?? "—"}</span>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-end pr-2">
|
||||
<ConnectorRowDropdown
|
||||
connector={connector}
|
||||
onEdit={onEdit}
|
||||
onCsvImport={onCsvImport}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { ConnectorsTableDataRow } from "./connectors-table-data-row";
|
||||
|
||||
interface ConnectorsTableRowsContainerProps {
|
||||
connectors: TConnectorWithMappings[];
|
||||
onConnectorClick: (connector: TConnectorWithMappings) => void;
|
||||
onCsvImport: (connector: TConnectorWithMappings) => void;
|
||||
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onDelete: (connectorId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ConnectorsTableRowsContainer = ({
|
||||
connectors,
|
||||
onConnectorClick,
|
||||
onCsvImport,
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: ConnectorsTableRowsContainerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (connectors.length === 0) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_sources_connected")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{connectors.map((connector) => (
|
||||
<ConnectorsTableDataRow
|
||||
key={connector.id}
|
||||
connector={connector}
|
||||
onEdit={() => onConnectorClick(connector)}
|
||||
onCsvImport={connector.type === "csv" ? () => onCsvImport(connector) : undefined}
|
||||
onDuplicate={() => onDuplicate(connector)}
|
||||
onToggleStatus={() => onToggleStatus(connector)}
|
||||
onDelete={() => onDelete(connector.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { ConnectorsTableRowsContainer } from "./connectors-table-rows-container";
|
||||
|
||||
interface ConnectorsTableProps {
|
||||
connectors: TConnectorWithMappings[];
|
||||
onConnectorClick: (connector: TConnectorWithMappings) => void;
|
||||
onCsvImport: (connector: TConnectorWithMappings) => void;
|
||||
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onDelete: (connectorId: string) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectorsTable({
|
||||
connectors,
|
||||
onConnectorClick,
|
||||
onCsvImport,
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
}: 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-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("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" />
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2Icon className="h-6 w-6 animate-spin text-slate-500" />
|
||||
</div>
|
||||
) : (
|
||||
<ConnectorsTableRowsContainer
|
||||
connectors={connectors}
|
||||
onConnectorClick={onConnectorClick}
|
||||
onCsvImport={onCsvImport}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+636
@@ -0,0 +1,636 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2Icon, PlusIcon } from "lucide-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,
|
||||
importCsvDataAction,
|
||||
importHistoricalResponsesAction,
|
||||
} from "@/lib/connector/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { TCreateConnectorStep, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
|
||||
import {
|
||||
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 { FormbricksQuestionList } from "./formbricks-question-list";
|
||||
|
||||
interface CreateConnectorModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateConnector: (data: {
|
||||
name: string;
|
||||
type: TConnectorType;
|
||||
feedbackRecordDirectoryId: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<string | undefined>;
|
||||
surveys: TUnifySurvey[];
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const getDialogTitle = (
|
||||
step: TCreateConnectorStep,
|
||||
type: TConnectorOptionId | null,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (step === "selectType") return t("workspace.unify.add_feedback_source");
|
||||
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: TConnectorOptionId | null,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (step === "selectType") return t("workspace.unify.select_source_type_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: 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 ZFormbricksConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
|
||||
type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
|
||||
|
||||
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,
|
||||
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 [currentStep, setCurrentStep] = useState<TCreateConnectorStep>("selectType");
|
||||
const [selectedType, setSelectedType] = useState<TConnectorOptionId | null>(null);
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [csvParsedData, setCsvParsedData] = useState<Record<string, string>[]>([]);
|
||||
const [enumValidationErrors, setEnumValidationErrors] = useState<TEnumValidationError[]>([]);
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
const [responseCountBySurvey, setResponseCountBySurvey] = useState<Record<string, number | null>>({});
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
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;
|
||||
try {
|
||||
const result = await getResponseCountAction({ surveyId, workspaceId });
|
||||
if (result?.data !== undefined) {
|
||||
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: result.data ?? null }));
|
||||
}
|
||||
} catch {
|
||||
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: null }));
|
||||
}
|
||||
},
|
||||
[responseCountBySurvey, workspaceId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSurveyId && currentStep === "mapping" && selectedType === "formbricks_survey") {
|
||||
fetchResponseCount(selectedSurveyId);
|
||||
}
|
||||
}, [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);
|
||||
formbricksForm.reset({
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setCsvParsedData([]);
|
||||
setEnumValidationErrors([]);
|
||||
setResponseCountBySurvey({});
|
||||
setCsvConnectorName("");
|
||||
setIsImporting(false);
|
||||
setIsCreating(false);
|
||||
setSelectedDirectoryId(directories[0]?.id ?? null);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (isImporting) return;
|
||||
if (!newOpen) resetForm();
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStep !== "selectType" || !selectedType) return;
|
||||
|
||||
if (selectedType === "api_ingestion") {
|
||||
handleOpenChange(false);
|
||||
router.push(`/workspaces/${workspaceId}/settings/api-keys`);
|
||||
return;
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (currentStep === "mapping") {
|
||||
setCurrentStep("selectType");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setEnumValidationErrors([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHistoricalImport = async (connectorId: string, surveyId: string) => {
|
||||
const responseCount = responseCountBySurvey[surveyId] ?? 0;
|
||||
if (responseCount <= 0) return;
|
||||
setIsImporting(true);
|
||||
const importResult = await importHistoricalResponsesAction({
|
||||
connectorId,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
});
|
||||
setIsImporting(false);
|
||||
|
||||
if (importResult?.data) {
|
||||
toast.success(
|
||||
t("workspace.unify.historical_import_complete", {
|
||||
successes: importResult.data.successes,
|
||||
failures: importResult.data.failures,
|
||||
skipped: importResult.data.skipped,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(importResult));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCsvImport = async (connectorId: string) => {
|
||||
setIsImporting(true);
|
||||
const importResult = await importCsvDataAction({
|
||||
connectorId,
|
||||
workspaceId,
|
||||
csvData: csvParsedData,
|
||||
});
|
||||
setIsImporting(false);
|
||||
|
||||
if (importResult?.data) {
|
||||
toast.success(
|
||||
t("workspace.unify.csv_import_complete", {
|
||||
successes: importResult.data.successes,
|
||||
failures: importResult.data.failures,
|
||||
skipped: importResult.data.skipped,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(importResult));
|
||||
}
|
||||
};
|
||||
|
||||
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 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);
|
||||
return;
|
||||
}
|
||||
setEnumValidationErrors([]);
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
const connectorId = await onCreateConnector({
|
||||
name: csvConnectorName.trim(),
|
||||
type: "csv",
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
|
||||
if (connectorId && csvParsedData.length > 0) {
|
||||
await handleCsvImport(connectorId);
|
||||
}
|
||||
|
||||
setIsCreating(false);
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
|
||||
const areCsvRequiredFieldsMapped = areAllRequiredFieldsMapped(mappings);
|
||||
|
||||
const handleLoadSourceFields = () => {
|
||||
if (selectedType === "csv") {
|
||||
const fields = parseCSVColumnsToFields("timestamp,customer_id,rating,feedback_text,category");
|
||||
setSourceFields(fields);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{isImporting && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg bg-white/80">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2Icon className="h-8 w-8 animate-spin text-slate-500" />
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.importing_historical_data")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>{getDialogTitle(currentStep, selectedType, t)}</DialogTitle>
|
||||
<DialogDescription>{getDialogDescription(currentStep, selectedType, t)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
{currentStep === "selectType" && (
|
||||
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
/>
|
||||
|
||||
{directories.length === 0 && (
|
||||
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "csv" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="connectorName" className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.source_name")}
|
||||
</label>
|
||||
<Input
|
||||
id="connectorName"
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{directories.length === 0 && (
|
||||
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
|
||||
)}
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<CsvConnectorUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={(m) => {
|
||||
setMappings(m);
|
||||
setEnumValidationErrors([]);
|
||||
}}
|
||||
onSourceFieldsChange={setSourceFields}
|
||||
onLoadSampleCSV={handleLoadSourceFields}
|
||||
onParsedDataChange={setCsvParsedData}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enumValidationErrors.length > 0 && (
|
||||
<Alert variant="error" size="small">
|
||||
{enumValidationErrors.map((err) => {
|
||||
const uniqueValues = [...new Set(err.invalidEntries.map((e) => e.value))];
|
||||
const rowNumbers = err.invalidEntries.slice(0, 5).map((e) => e.row);
|
||||
return (
|
||||
<div key={err.targetFieldName} className="text-xs">
|
||||
<p className="font-medium">
|
||||
{t("workspace.unify.invalid_enum_values", {
|
||||
field: err.targetFieldName,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("workspace.unify.invalid_values_found", {
|
||||
values: uniqueValues.join(", "),
|
||||
rows: rowNumbers.join(", "),
|
||||
extra: err.invalidEntries.length > 5 ? `+${err.invalidEntries.length - 5}` : "",
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-slate-500">
|
||||
{t("workspace.unify.allowed_values", {
|
||||
values: err.allowedValues.join(", "),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{currentStep === "mapping" && (
|
||||
<Button variant="outline" onClick={handleBack} disabled={isCreating || isImporting}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === "selectType" ? (
|
||||
<Button onClick={handleNextStep} disabled={!selectedType}>
|
||||
{getNextStepButtonLabel(selectedType, t)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={
|
||||
selectedType === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleCreateFormbricksConnector)()
|
||||
: handleCreateCsvConnector
|
||||
}
|
||||
disabled={
|
||||
isCreating ||
|
||||
isImporting ||
|
||||
!selectedDirectoryId ||
|
||||
(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")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface NoFeedbackRecordDirectoryAlertProps {
|
||||
workspaceId: string;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const NoFeedbackRecordDirectoryAlert = ({ workspaceId, t }: NoFeedbackRecordDirectoryAlertProps) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { ArrowUpFromLineIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TFieldMapping, TSourceField, createFeedbackCSVDataSchema } from "../types";
|
||||
import { validateCsvFile } from "../utils";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
|
||||
interface CsvConnectorUIProps {
|
||||
sourceFields: TSourceField[];
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
onSourceFieldsChange: (fields: TSourceField[]) => void;
|
||||
onLoadSampleCSV: () => void;
|
||||
onParsedDataChange?: (data: Record<string, string>[]) => void;
|
||||
}
|
||||
|
||||
export function CsvConnectorUI({
|
||||
sourceFields,
|
||||
mappings,
|
||||
onMappingsChange,
|
||||
onSourceFieldsChange,
|
||||
onLoadSampleCSV,
|
||||
onParsedDataChange,
|
||||
}: CsvConnectorUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
|
||||
const [showMapping, setShowMapping] = useState(false);
|
||||
const [csvError, setCsvError] = useState("");
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (file) {
|
||||
processCSVFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const processCSVFile = (file: File) => {
|
||||
setCsvError("");
|
||||
|
||||
const validateCSVFileResult = validateCsvFile(file, t);
|
||||
|
||||
if (!validateCSVFileResult.valid) {
|
||||
setCsvError(validateCSVFileResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const csv = e.target?.result as string;
|
||||
|
||||
try {
|
||||
const records = parse(csv, { columns: true, skip_empty_lines: true });
|
||||
|
||||
const result = createFeedbackCSVDataSchema(t).safeParse(records);
|
||||
if (!result.success) {
|
||||
setCsvError(result.error.issues[0].message);
|
||||
return;
|
||||
}
|
||||
|
||||
const validRecords = result.data;
|
||||
const headers = Object.keys(validRecords[0]);
|
||||
|
||||
const preview: string[][] = [
|
||||
headers,
|
||||
...validRecords.slice(0, 5).map((row) => headers.map((h) => row[h] ?? "")),
|
||||
];
|
||||
setCsvFile(file);
|
||||
setCsvPreview(preview);
|
||||
|
||||
const fields: TSourceField[] = headers.map((header) => ({
|
||||
id: header,
|
||||
name: header,
|
||||
type: "string",
|
||||
sampleValue: validRecords[0][header] ?? "",
|
||||
}));
|
||||
onSourceFieldsChange(fields);
|
||||
onParsedDataChange?.(validRecords);
|
||||
setShowMapping(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t("common.failed_to_parse_csv");
|
||||
setCsvError(message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) {
|
||||
processCSVFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadSample = () => {
|
||||
onLoadSampleCSV();
|
||||
setShowMapping(true);
|
||||
};
|
||||
|
||||
if (showMapping && sourceFields.length > 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{csvFile && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-800">{csvFile.name}</span>
|
||||
<Badge text={`${csvPreview.length - 1} rows`} type="gray" size="tiny" />
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCsvFile(null);
|
||||
setCsvPreview([]);
|
||||
setCsvError("");
|
||||
setShowMapping(false);
|
||||
onSourceFieldsChange([]);
|
||||
onParsedDataChange?.([]);
|
||||
}}>
|
||||
{t("workspace.unify.change_file")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{csvPreview.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{csvPreview[0]?.map((header, i) => (
|
||||
<th key={i} className="px-3 py-2 text-left font-medium text-slate-700">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{csvPreview.slice(1, 4).map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-t border-slate-100">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex} className="px-3 py-2 text-slate-600">
|
||||
{cell || <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{csvPreview.length > 4 && (
|
||||
<div className="border-t border-slate-100 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
|
||||
{t("workspace.unify.showing_rows", { count: csvPreview.length - 1 })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={onMappingsChange}
|
||||
connectorType="csv"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{csvError && (
|
||||
<Alert variant="error" size="small">
|
||||
{csvError}
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">{t("workspace.unify.upload_csv_file")}</h4>
|
||||
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
|
||||
<label
|
||||
htmlFor="csv-file-upload"
|
||||
className="flex cursor-pointer flex-col items-center justify-center"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}>
|
||||
<ArrowUpFromLineIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
<span className="font-semibold">{t("workspace.unify.click_to_upload")}</span>{" "}
|
||||
{t("workspace.unify.or_drag_and_drop")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{t("workspace.unify.csv_files_only")}</p>
|
||||
<input
|
||||
type="file"
|
||||
id="csv-file-upload"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Button variant="secondary" size="sm" onClick={handleLoadSample}>
|
||||
{t("workspace.unify.load_sample_csv")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { ArrowUpFromLineIcon, Loader2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { importCsvDataAction } from "@/lib/connector/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { createFeedbackCSVDataSchema } from "../types";
|
||||
import { validateCsvFile } from "../utils";
|
||||
|
||||
interface CsvImportModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
connectorId: string;
|
||||
workspaceId: string;
|
||||
onOpenEditConnector?: () => void;
|
||||
}
|
||||
|
||||
export function CsvImportModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
connectorId,
|
||||
workspaceId,
|
||||
onOpenEditConnector,
|
||||
}: CsvImportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
const [parsedData, setParsedData] = useState<Record<string, string>[]>([]);
|
||||
const [csvError, setCsvError] = useState("");
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const processCSVFile = (file: File) => {
|
||||
setCsvError("");
|
||||
|
||||
const validateCSVFileResult = validateCsvFile(file, t);
|
||||
|
||||
if (!validateCSVFileResult.valid) {
|
||||
setCsvError(validateCSVFileResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
file
|
||||
.text()
|
||||
.then((csv) => {
|
||||
const records = parse(csv, { columns: true, skip_empty_lines: true });
|
||||
const result = createFeedbackCSVDataSchema(t).safeParse(records);
|
||||
|
||||
if (!result.success) {
|
||||
setCsvError(result.error.issues[0].message);
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvFile(file);
|
||||
setParsedData(result.data);
|
||||
setRowCount(result.data.length);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : t("common.failed_to_parse_csv");
|
||||
setCsvError(message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (file) processCSVFile(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) processCSVFile(file);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (parsedData.length === 0) return;
|
||||
|
||||
setIsImporting(true);
|
||||
const result = await importCsvDataAction({ connectorId, workspaceId, csvData: parsedData });
|
||||
setIsImporting(false);
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(
|
||||
t("workspace.unify.csv_import_complete", {
|
||||
successes: result.data.successes,
|
||||
failures: result.data.failures,
|
||||
skipped: result.data.skipped,
|
||||
})
|
||||
);
|
||||
setCsvFile(null);
|
||||
setParsedData([]);
|
||||
setRowCount(0);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setCsvFile(null);
|
||||
setParsedData([]);
|
||||
setRowCount(0);
|
||||
setCsvError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.unify.import_csv_data")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.unify.upload_csv_data_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Alert variant="info" size="small">
|
||||
{t("workspace.unify.csv_import_duplicate_warning")}
|
||||
</Alert>
|
||||
|
||||
{csvError && (
|
||||
<Alert variant="error" size="small">
|
||||
{csvError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{csvFile ? (
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-800">{csvFile.name}</span>
|
||||
<Badge text={`${rowCount} rows`} type="gray" size="tiny" />
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={handleClear} disabled={isImporting}>
|
||||
{t("workspace.unify.change_file")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
|
||||
<label
|
||||
htmlFor="csv-import-upload"
|
||||
className="flex cursor-pointer flex-col items-center justify-center"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}>
|
||||
<ArrowUpFromLineIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
<span className="font-semibold">{t("workspace.unify.click_to_upload")}</span>{" "}
|
||||
{t("workspace.unify.or_drag_and_drop")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{t("workspace.unify.csv_files_only")}</p>
|
||||
<input
|
||||
type="file"
|
||||
id="csv-import-upload"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{onOpenEditConnector && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onOpenEditConnector();
|
||||
}}>
|
||||
{t("workspace.unify.edit_csv_mapping")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleImport} disabled={parsedData.length === 0 || isImporting}>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("workspace.unify.importing_data")}
|
||||
</>
|
||||
) : (
|
||||
t("workspace.unify.import_rows", { count: rowCount })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
|
||||
import { parseCSVColumnsToFields } from "../utils";
|
||||
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 {
|
||||
connector: TConnectorWithMappings | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUpdateConnector: (data: {
|
||||
connectorId: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<void>;
|
||||
surveys: TUnifySurvey[];
|
||||
onOpenCsvImport?: () => void;
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
type TFormbricksEditConnectorForm = z.infer<typeof ZFormbricksEditConnectorForm>;
|
||||
|
||||
export const EditConnectorModal = ({
|
||||
connector,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdateConnector,
|
||||
surveys,
|
||||
onOpenCsvImport,
|
||||
}: EditConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const formbricksForm = useForm<TFormbricksEditConnectorForm>({
|
||||
resolver: zodResolver(ZFormbricksEditConnectorForm),
|
||||
defaultValues: {
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
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) {
|
||||
if (connector.type === "formbricks_survey") {
|
||||
const mappedSurveyId = connector.formbricksMappings[0]?.surveyId ?? "";
|
||||
const mappedQuestionIds = connector.formbricksMappings
|
||||
.filter((mapping) => mapping.surveyId === mappedSurveyId)
|
||||
.map((mapping) => mapping.elementId);
|
||||
|
||||
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)),
|
||||
];
|
||||
setSourceFields(
|
||||
columnsFromMappings.length > 0
|
||||
? parseCSVColumnsToFields(columnsFromMappings.join(","))
|
||||
: parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS)
|
||||
);
|
||||
setMappings(
|
||||
connector.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId,
|
||||
targetFieldId: m.targetFieldId,
|
||||
staticValue: m.staticValue ?? undefined,
|
||||
}))
|
||||
);
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
} else {
|
||||
setCsvConnectorName("");
|
||||
setSourceFields([]);
|
||||
setMappings([]);
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [connector, formbricksForm]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCsvConnectorName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
resetForm();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const handleUpdateFormbricksConnector = async (values: TFormbricksEditConnectorForm) => {
|
||||
if (connector?.type !== "formbricks_survey") return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: values.sourceName.trim(),
|
||||
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
|
||||
fieldMappings: undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
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 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 (isUpdating) return true;
|
||||
|
||||
if (connector.type === "formbricks_survey") {
|
||||
return (
|
||||
!isConnectorNameValid(formbricksValues.sourceName ?? "") ||
|
||||
!formbricksValues.surveyId ||
|
||||
!formbricksValues.selectedQuestionIds?.length
|
||||
);
|
||||
}
|
||||
|
||||
if (connector.type === "csv") {
|
||||
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredFieldsMapped(mappings);
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [connector, csvConnectorName, formbricksValues, isUpdating, mappings]);
|
||||
|
||||
if (!connector) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.unify.edit_source_connection")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.unify.update_mapping_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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="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>
|
||||
|
||||
<DialogFooter>
|
||||
{connector.type === "csv" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
onOpenCsvImport?.();
|
||||
}}>
|
||||
{t("workspace.unify.import_feedback")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
|
||||
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { TUnifySurvey } from "../types";
|
||||
|
||||
interface FormbricksQuestionListProps {
|
||||
survey: TUnifySurvey | null;
|
||||
selectedQuestionIds: string[];
|
||||
onQuestionToggle: (questionId: string) => void;
|
||||
}
|
||||
|
||||
const isUnsupportedElementType = (type: string): boolean =>
|
||||
(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(type);
|
||||
|
||||
export const FormbricksQuestionList = ({
|
||||
survey,
|
||||
selectedQuestionIds,
|
||||
onQuestionToggle,
|
||||
}: Readonly<FormbricksQuestionListProps>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!survey) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-slate-300 p-3">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.select_a_survey_to_see_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (survey.elements.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-slate-300 p-3">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.survey_has_no_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto rounded-md border border-slate-200 p-3">
|
||||
{survey.elements.map((element) => {
|
||||
const unsupported = isUnsupportedElementType(element.type);
|
||||
const isChecked = selectedQuestionIds.includes(element.id);
|
||||
const elementTypeLabel = getTSurveyElementTypeEnumName(element.type, t) ?? element.type;
|
||||
const inputId = `connector-question-${element.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className={`flex items-start gap-3 rounded-md border border-slate-100 p-2 ${
|
||||
unsupported ? "opacity-60" : ""
|
||||
}`}>
|
||||
<Checkbox
|
||||
id={inputId}
|
||||
checked={!unsupported && isChecked}
|
||||
disabled={unsupported}
|
||||
onCheckedChange={() => {
|
||||
if (!unsupported) {
|
||||
onQuestionToggle(element.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={inputId} className={unsupported ? "cursor-not-allowed" : "cursor-pointer"}>
|
||||
{element.headline}
|
||||
</Label>
|
||||
<p className="text-xs text-slate-500">{elementTypeLabel}</p>
|
||||
{unsupported && (
|
||||
<p className="text-xs text-slate-500">{t("workspace.unify.question_type_not_supported")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
"use client";
|
||||
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { TFieldMapping, TSourceField, TTargetField } from "../types";
|
||||
|
||||
interface DraggableSourceFieldProps {
|
||||
field: TSourceField;
|
||||
isMapped: boolean;
|
||||
}
|
||||
|
||||
export const DraggableSourceField = ({ field, isMapped }: DraggableSourceFieldProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: field.id,
|
||||
data: field,
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||
isDragging
|
||||
? "border-brand-dark bg-slate-100 opacity-50"
|
||||
: isMapped
|
||||
? "border-green-300 bg-green-50 text-green-800"
|
||||
: "border-slate-200 bg-white hover:border-slate-300"
|
||||
}`}>
|
||||
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
|
||||
<div className="flex-1 truncate">
|
||||
<span className="font-medium">{field.name}</span>
|
||||
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
|
||||
</div>
|
||||
{field.sampleValue && (
|
||||
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getMappingStateClass = (isActive: boolean, hasMapping: unknown): string => {
|
||||
if (isActive) return "border-brand-dark bg-slate-100";
|
||||
if (hasMapping) return "border-green-300 bg-green-50";
|
||||
return "border-dashed border-slate-300 bg-slate-50";
|
||||
};
|
||||
|
||||
interface RemoveMappingButtonProps {
|
||||
onClick: () => void;
|
||||
variant: "green" | "blue";
|
||||
}
|
||||
|
||||
const RemoveMappingButton = ({ onClick, variant }: RemoveMappingButtonProps) => {
|
||||
const colorClass = variant === "green" ? "hover:bg-green-100" : "hover:bg-blue-100";
|
||||
const iconClass = variant === "green" ? "text-green-600" : "text-blue-600";
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={`ml-1 rounded p-0.5 ${colorClass}`}>
|
||||
<XIcon className={`h-3 w-3 ${iconClass}`} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface EnumTargetFieldContentProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const EnumTargetFieldContent = ({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
t,
|
||||
}: EnumTargetFieldContentProps) => {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
<span className="text-xs text-slate-400">{t("workspace.unify.enum")}</span>
|
||||
</div>
|
||||
|
||||
{mappedSourceField && !mapping?.staticValue ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
|
||||
</div>
|
||||
) : (
|
||||
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
|
||||
<SelectTrigger className="h-8 w-full bg-white">
|
||||
<SelectValue placeholder={t("workspace.unify.select_a_value")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.enumValues?.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StringTargetFieldContentProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
hasMapping: unknown;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const StringTargetFieldContent = ({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
hasMapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
t,
|
||||
}: StringTargetFieldContentProps) => {
|
||||
const [isEditingStatic, setIsEditingStatic] = useState(false);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
</div>
|
||||
|
||||
{mappedSourceField && !mapping?.staticValue && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapping?.staticValue && !mappedSourceField && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
= “{mapping.staticValue}”
|
||||
</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingStatic && !hasMapping && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
placeholder={
|
||||
field.exampleStaticValues
|
||||
? `e.g., ${field.exampleStaticValues[0]}`
|
||||
: t("workspace.unify.enter_value")
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && customValue.trim()) {
|
||||
onStaticValueChange(customValue.trim());
|
||||
setCustomValue("");
|
||||
setIsEditingStatic(false);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setCustomValue("");
|
||||
setIsEditingStatic(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (customValue.trim()) {
|
||||
onStaticValueChange(customValue.trim());
|
||||
setCustomValue("");
|
||||
}
|
||||
setIsEditingStatic(false);
|
||||
}}
|
||||
className="rounded p-1 text-slate-500 hover:bg-slate-200">
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMapping && !isEditingStatic && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">{t("workspace.unify.drop_field_or")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingStatic(true)}
|
||||
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
{t("workspace.unify.set_value")}
|
||||
</button>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-slate-300">|</span>
|
||||
{field.exampleStaticValues.slice(0, 3).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => onStaticValueChange(val)}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||
{val}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DroppableTargetFieldProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
isOver?: boolean;
|
||||
}
|
||||
|
||||
export const DroppableTargetField = ({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
isOver,
|
||||
}: DroppableTargetFieldProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
|
||||
id: field.id,
|
||||
data: field,
|
||||
});
|
||||
|
||||
const isActive = isOver || isOverCurrent;
|
||||
const hasMapping = mappedSourceField || mapping?.staticValue;
|
||||
const containerClass = cn(
|
||||
"flex items-center gap-2 rounded-md border p-2 text-sm transition-colors",
|
||||
getMappingStateClass(!!isActive, hasMapping)
|
||||
);
|
||||
|
||||
if (field.type === "enum" && field.enumValues) {
|
||||
return (
|
||||
<div ref={setNodeRef} className={containerClass}>
|
||||
<EnumTargetFieldContent
|
||||
field={field}
|
||||
mappedSourceField={mappedSourceField}
|
||||
mapping={mapping}
|
||||
onRemoveMapping={onRemoveMapping}
|
||||
onStaticValueChange={onStaticValueChange}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "string") {
|
||||
return (
|
||||
<div ref={setNodeRef} className={containerClass}>
|
||||
<StringTargetFieldContent
|
||||
field={field}
|
||||
mappedSourceField={mappedSourceField}
|
||||
mapping={mapping}
|
||||
hasMapping={hasMapping}
|
||||
onRemoveMapping={onRemoveMapping}
|
||||
onStaticValueChange={onStaticValueChange}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to get display label for static values
|
||||
const getStaticValueLabel = (value: string) => {
|
||||
if (value === "$now") return t("workspace.unify.feedback_date");
|
||||
return value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={containerClass}>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
<span className="text-xs text-slate-400">({field.type})</span>
|
||||
</div>
|
||||
|
||||
{mappedSourceField && !mapping?.staticValue && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapping?.staticValue && !mappedSourceField && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
= {getStaticValueLabel(mapping.staticValue)}
|
||||
</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMapping && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">{t("workspace.unify.drop_a_field_here")}</span>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-slate-300">|</span>
|
||||
{field.exampleStaticValues.map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => onStaticValueChange(val)}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||
{getStaticValueLabel(val)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorType } from "@formbricks/types/connector";
|
||||
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField } from "../types";
|
||||
import { DraggableSourceField, DroppableTargetField } from "./mapping-field";
|
||||
|
||||
interface MappingUIProps {
|
||||
sourceFields: TSourceField[];
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
connectorType: TConnectorType;
|
||||
}
|
||||
|
||||
export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorType }: MappingUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const sourceFieldId = active.id as string;
|
||||
const targetFieldId = over.id as string;
|
||||
|
||||
const newMappings = mappings.filter(
|
||||
(m) => m.sourceFieldId !== sourceFieldId && m.targetFieldId !== targetFieldId
|
||||
);
|
||||
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (targetFieldId: string) => {
|
||||
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
|
||||
};
|
||||
|
||||
const handleStaticValueChange = (targetFieldId: string, staticValue: string) => {
|
||||
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
|
||||
onMappingsChange([...newMappings, { targetFieldId, staticValue }]);
|
||||
};
|
||||
|
||||
const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id);
|
||||
|
||||
const getMappingForTarget = (targetFieldId: string) => {
|
||||
return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null;
|
||||
};
|
||||
|
||||
const getMappedSourceField = (targetFieldId: string) => {
|
||||
const mapping = getMappingForTarget(targetFieldId);
|
||||
return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null;
|
||||
};
|
||||
|
||||
const isSourceFieldMapped = (sourceFieldId: string) =>
|
||||
mappings.some((m) => m.sourceFieldId === sourceFieldId);
|
||||
|
||||
const activeField = activeId ? getSourceFieldById(activeId) : null;
|
||||
|
||||
return (
|
||||
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Source Fields Panel */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">
|
||||
{connectorType === "csv" ? t("workspace.unify.csv_columns") : t("workspace.unify.source_fields")}
|
||||
</h4>
|
||||
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">
|
||||
{connectorType === "csv"
|
||||
? t("workspace.unify.click_load_sample_csv")
|
||||
: t("workspace.unify.no_source_fields_loaded")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sourceFields.map((field) => (
|
||||
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Target Fields Panel */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.feedback_record_fields")}
|
||||
</h4>
|
||||
|
||||
{/* Required Fields */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{t("workspace.unify.required")}
|
||||
</p>
|
||||
{requiredFields.map((field) => (
|
||||
<DroppableTargetField
|
||||
key={field.id}
|
||||
field={field}
|
||||
mappedSourceField={getMappedSourceField(field.id) ?? null}
|
||||
mapping={getMappingForTarget(field.id)}
|
||||
onRemoveMapping={() => handleRemoveMapping(field.id)}
|
||||
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Optional Fields */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{t("workspace.unify.optional")}
|
||||
</p>
|
||||
{optionalFields.map((field) => (
|
||||
<DroppableTargetField
|
||||
key={field.id}
|
||||
field={field}
|
||||
mappedSourceField={getMappedSourceField(field.id) ?? null}
|
||||
mapping={getMappingForTarget(field.id)}
|
||||
onRemoveMapping={() => handleRemoveMapping(field.id)}
|
||||
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeField ? (
|
||||
<div className="rounded-md border border-brand-dark bg-white p-2 text-sm shadow-lg">
|
||||
<span className="font-medium">{activeField.name}</span>
|
||||
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { transformToUnifySurvey } from "./lib";
|
||||
|
||||
vi.mock("@formbricks/types/surveys/validation", () => ({
|
||||
getTextContent: (str: string) => str,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (val: Record<string, string>, _lang: string) => val?.default ?? "",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/utils", () => ({
|
||||
getElementsFromBlocks: (blocks: Array<{ elements: unknown[] }>) =>
|
||||
blocks.flatMap((block) => block.elements),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
recallToHeadline: (headline: Record<string, string>) => headline,
|
||||
}));
|
||||
|
||||
const NOW = new Date("2026-02-24T10:00:00.000Z");
|
||||
|
||||
const createMockSurvey = (overrides: Partial<TSurvey> = {}): TSurvey =>
|
||||
({
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
status: "inProgress",
|
||||
createdAt: NOW,
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-text",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "What do you think?" },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "el-nps",
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
headline: { default: "How likely to recommend?" },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}) as unknown as TSurvey;
|
||||
|
||||
describe("transformToUnifySurvey", () => {
|
||||
test("transforms a survey with basic elements", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey());
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
status: "active",
|
||||
createdAt: NOW,
|
||||
elements: [
|
||||
{
|
||||
id: "el-text",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: "What do you think?",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "el-nps",
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
headline: "How likely to recommend?",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("filters out CTA elements", () => {
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-text",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Feedback" },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "el-cta",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "Click here" },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
|
||||
expect(result.elements).toHaveLength(1);
|
||||
expect(result.elements[0].id).toBe("el-text");
|
||||
});
|
||||
|
||||
test("defaults required to false when not set", () => {
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
headline: { default: "Rate us" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
expect(result.elements[0].required).toBe(false);
|
||||
});
|
||||
|
||||
test("falls back to 'Untitled' when headline is empty", () => {
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "" },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
expect(result.elements[0].headline).toBe("Untitled");
|
||||
});
|
||||
|
||||
describe("mapSurveyStatus", () => {
|
||||
test("maps 'inProgress' to 'active'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "inProgress" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("active");
|
||||
});
|
||||
|
||||
test("maps 'paused' to 'paused'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "paused" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("paused");
|
||||
});
|
||||
|
||||
test("maps 'draft' to 'draft'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "draft" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("draft");
|
||||
});
|
||||
|
||||
test("maps 'completed' to 'completed'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "completed" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("completed");
|
||||
});
|
||||
|
||||
test("maps unknown status to 'draft'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "archived" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("draft");
|
||||
});
|
||||
});
|
||||
|
||||
test("handles multiple blocks", () => {
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
elements: [
|
||||
{ id: "el-2", type: TSurveyElementTypeEnum.Rating, headline: { default: "Q2" }, required: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
expect(result.elements).toHaveLength(2);
|
||||
expect(result.elements[0].id).toBe("el-1");
|
||||
expect(result.elements[1].id).toBe("el-2");
|
||||
});
|
||||
|
||||
test("handles empty blocks", () => {
|
||||
const survey = createMockSurvey({ blocks: [] } as Partial<TSurvey>);
|
||||
const result = transformToUnifySurvey(survey);
|
||||
expect(result.elements).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves all element types except CTA", () => {
|
||||
const elementTypes = [
|
||||
TSurveyElementTypeEnum.OpenText,
|
||||
TSurveyElementTypeEnum.NPS,
|
||||
TSurveyElementTypeEnum.Rating,
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
TSurveyElementTypeEnum.Date,
|
||||
TSurveyElementTypeEnum.Consent,
|
||||
TSurveyElementTypeEnum.Matrix,
|
||||
TSurveyElementTypeEnum.Ranking,
|
||||
TSurveyElementTypeEnum.PictureSelection,
|
||||
TSurveyElementTypeEnum.ContactInfo,
|
||||
TSurveyElementTypeEnum.Address,
|
||||
TSurveyElementTypeEnum.FileUpload,
|
||||
TSurveyElementTypeEnum.Cal,
|
||||
TSurveyElementTypeEnum.CTA,
|
||||
];
|
||||
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: elementTypes.map((type, i) => ({
|
||||
id: `el-${i.toString()}`,
|
||||
type,
|
||||
headline: { default: `Question ${i.toString()}` },
|
||||
required: false,
|
||||
})),
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
const resultTypes = result.elements.map((e) => e.type);
|
||||
|
||||
expect(resultTypes).not.toContain(TSurveyElementTypeEnum.CTA);
|
||||
expect(result.elements).toHaveLength(elementTypes.length - 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { TUnifySurvey, TUnifySurveyElement } from "./types";
|
||||
|
||||
const getElementHeadline = (element: TSurveyElement, survey: TSurvey): string => {
|
||||
return (
|
||||
getTextContent(
|
||||
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
|
||||
) || "Untitled"
|
||||
);
|
||||
};
|
||||
|
||||
const mapSurveyStatus = (status: string): TUnifySurvey["status"] => {
|
||||
switch (status) {
|
||||
case "inProgress":
|
||||
return "active";
|
||||
case "paused":
|
||||
return "paused";
|
||||
case "draft":
|
||||
return "draft";
|
||||
case "completed":
|
||||
return "completed";
|
||||
default:
|
||||
return "draft";
|
||||
}
|
||||
};
|
||||
|
||||
export const transformToUnifySurvey = (survey: TSurvey): TUnifySurvey => {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const unifySurveyElements: TUnifySurveyElement[] = elements
|
||||
.filter((el) => el.type !== TSurveyElementTypeEnum.CTA)
|
||||
.map((el) => ({
|
||||
id: el.id,
|
||||
type: el.type,
|
||||
headline: getElementHeadline(el, survey),
|
||||
required: el.required ?? false,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: survey.id,
|
||||
name: survey.name,
|
||||
status: mapSurveyStatus(survey.status),
|
||||
elements: unifySurveyElements,
|
||||
createdAt: survey.createdAt,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UnifySourcesPage(props: { params: Promise<{ workspaceId: string }> }) {
|
||||
const params = await props.params;
|
||||
redirect(`/workspaces/${params.workspaceId}/feedback-sources`);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { z } from "zod";
|
||||
import { THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
|
||||
export interface TUnifySurveyElement {
|
||||
id: string;
|
||||
type: TSurveyElementTypeEnum;
|
||||
headline: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface TUnifySurvey {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "draft" | "active" | "paused" | "completed";
|
||||
elements: TUnifySurveyElement[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface TFieldMapping {
|
||||
targetFieldId: string;
|
||||
sourceFieldId?: string;
|
||||
staticValue?: string;
|
||||
}
|
||||
|
||||
export type TTargetFieldType = "string" | "enum" | "timestamp" | "float64" | "boolean" | "jsonb" | "string[]";
|
||||
|
||||
export interface TTargetField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TTargetFieldType;
|
||||
required: boolean;
|
||||
description: string;
|
||||
enumValues?: THubFieldType[];
|
||||
exampleStaticValues?: string[];
|
||||
}
|
||||
|
||||
export interface TSourceField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
sampleValue?: string;
|
||||
}
|
||||
|
||||
export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
|
||||
{
|
||||
id: "collected_at",
|
||||
name: "Collected At",
|
||||
type: "timestamp",
|
||||
required: true,
|
||||
description: "When the feedback was originally collected",
|
||||
},
|
||||
{
|
||||
id: "source_type",
|
||||
name: "Source Type",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Type of source (e.g., survey, review, support)",
|
||||
},
|
||||
{
|
||||
id: "field_id",
|
||||
name: "Field ID",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Unique question/field identifier",
|
||||
},
|
||||
{
|
||||
id: "field_type",
|
||||
name: "Field Type",
|
||||
type: "enum",
|
||||
required: true,
|
||||
description: "Data type (text, nps, csat, rating, etc.)",
|
||||
enumValues: ZHubFieldType.options,
|
||||
},
|
||||
{
|
||||
id: "tenant_id",
|
||||
name: "Tenant ID",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Tenant/organization identifier for multi-tenant deployments",
|
||||
},
|
||||
{
|
||||
id: "source_id",
|
||||
name: "Source ID",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Reference to survey/form/ticket/review ID",
|
||||
},
|
||||
{
|
||||
id: "source_name",
|
||||
name: "Source Name",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Human-readable source name for display",
|
||||
},
|
||||
{
|
||||
id: "field_label",
|
||||
name: "Field Label",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Question text or field label for display",
|
||||
},
|
||||
{
|
||||
id: "field_group_id",
|
||||
name: "Field Group ID",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Stable identifier grouping related fields (for ranking, matrix, grid questions)",
|
||||
},
|
||||
{
|
||||
id: "field_group_label",
|
||||
name: "Field Group Label",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Human-readable question text for the group",
|
||||
},
|
||||
{
|
||||
id: "value_text",
|
||||
name: "Value (Text)",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Text responses (feedback, comments, open-ended answers)",
|
||||
},
|
||||
{
|
||||
id: "value_number",
|
||||
name: "Value (Number)",
|
||||
type: "float64",
|
||||
required: false,
|
||||
description: "Numeric responses (ratings, scores, NPS, CSAT)",
|
||||
},
|
||||
{
|
||||
id: "value_boolean",
|
||||
name: "Value (Boolean)",
|
||||
type: "boolean",
|
||||
required: false,
|
||||
description: "Yes/no responses",
|
||||
},
|
||||
{
|
||||
id: "value_date",
|
||||
name: "Value (Date)",
|
||||
type: "timestamp",
|
||||
required: false,
|
||||
description: "Date/datetime responses",
|
||||
},
|
||||
{
|
||||
id: "metadata",
|
||||
name: "Metadata",
|
||||
type: "jsonb",
|
||||
required: false,
|
||||
description: "Flexible context (device, location, campaign, custom fields)",
|
||||
},
|
||||
{
|
||||
id: "language",
|
||||
name: "Language",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "ISO 639-1 language code (e.g., en, de, fr)",
|
||||
exampleStaticValues: ["en", "de", "fr", "es", "pt", "ja", "zh"],
|
||||
},
|
||||
{
|
||||
id: "user_identifier",
|
||||
name: "User Identifier",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Anonymous user ID for tracking (hashed, never PII)",
|
||||
},
|
||||
];
|
||||
|
||||
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
|
||||
|
||||
export const MAX_CSV_VALUES = {
|
||||
FILE_SIZE: 2_097_152, // 2MB (2 * 1024 * 1024)
|
||||
RECORDS: 1_000, // 1,000 records
|
||||
} as const;
|
||||
|
||||
export const createFeedbackCSVDataSchema = (t: TFunction) =>
|
||||
z
|
||||
.array(z.record(z.string(), z.string()))
|
||||
.min(1, { message: t("workspace.unify.csv_at_least_one_row") })
|
||||
.max(MAX_CSV_VALUES.RECORDS, {
|
||||
message: t("workspace.unify.csv_max_records", {
|
||||
max: MAX_CSV_VALUES.RECORDS.toLocaleString(),
|
||||
}),
|
||||
})
|
||||
.superRefine((rows, ctx) => {
|
||||
const localeSort = (a: string, b: string) => a.localeCompare(b);
|
||||
const firstRowKeys = Object.keys(rows[0]).sort(localeSort).join(",");
|
||||
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const rowKeys = Object.keys(rows[i]).sort(localeSort).join(",");
|
||||
if (rowKeys !== firstRowKeys) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("workspace.unify.csv_inconsistent_columns", { row: (i + 1).toString() }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const emptyHeaders = Object.keys(rows[0]).filter((k) => k.trim() === "");
|
||||
if (emptyHeaders.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("workspace.unify.csv_empty_column_headers"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSchema>>;
|
||||
|
||||
export type TCreateConnectorStep = "selectType" | "mapping";
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { MAX_CSV_VALUES, TSourceField } from "./types";
|
||||
import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } from "./utils";
|
||||
|
||||
const mockT = (key: string) => key;
|
||||
|
||||
describe("getConnectorOptions", () => {
|
||||
test("returns formbricks, csv, api ingestion, and mcp options", () => {
|
||||
const options = getConnectorOptions(mockT as never);
|
||||
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", () => {
|
||||
const options = getConnectorOptions(mockT as never);
|
||||
expect(options.every((o) => !o.disabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("uses translation keys for name and description", () => {
|
||||
const options = getConnectorOptions(mockT as never);
|
||||
expect(options[0].name).toBe("workspace.unify.formbricks_surveys");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCSVColumnsToFields", () => {
|
||||
test("parses comma-separated column names into source fields", () => {
|
||||
const result = parseCSVColumnsToFields("name,email,score");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toEqual<TSourceField[]>([
|
||||
{ id: "name", name: "name", type: "string", sampleValue: "Sample name" },
|
||||
{ id: "email", name: "email", type: "string", sampleValue: "Sample email" },
|
||||
{ id: "score", name: "score", type: "string", sampleValue: "Sample score" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("trims whitespace from column names", () => {
|
||||
const result = parseCSVColumnsToFields(" name , email , score ");
|
||||
expect(result[0].id).toBe("name");
|
||||
expect(result[1].id).toBe("email");
|
||||
expect(result[2].id).toBe("score");
|
||||
});
|
||||
|
||||
test("handles single column", () => {
|
||||
const result = parseCSVColumnsToFields("feedback");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("feedback");
|
||||
});
|
||||
|
||||
test("generates sample values from column names", () => {
|
||||
const result = parseCSVColumnsToFields("rating,comment");
|
||||
expect(result[0].sampleValue).toBe("Sample rating");
|
||||
expect(result[1].sampleValue).toBe("Sample comment");
|
||||
});
|
||||
});
|
||||
|
||||
const createMockFile = (name: string, size: number, type: string): File =>
|
||||
new File(["x".repeat(size)], name, { type });
|
||||
|
||||
describe("validateCsvFile", () => {
|
||||
test("accepts a valid .csv file", () => {
|
||||
const file = createMockFile("data.csv", 1024, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test("rejects a file without .csv extension", () => {
|
||||
const file = createMockFile("data.xlsx", 1024, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_files_only" });
|
||||
});
|
||||
|
||||
test("rejects a file with wrong MIME type", () => {
|
||||
const file = createMockFile("data.csv", 1024, "application/json");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_files_only" });
|
||||
});
|
||||
|
||||
test("accepts a .csv file with empty MIME type", () => {
|
||||
const file = createMockFile("data.csv", 1024, "");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test("accepts a .csv file with alternative csv MIME type", () => {
|
||||
const file = createMockFile("report.csv", 512, "application/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test("rejects a file exceeding the size limit", () => {
|
||||
const file = createMockFile("big.csv", MAX_CSV_VALUES.FILE_SIZE + 1, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_file_too_large" });
|
||||
});
|
||||
|
||||
test("accepts a file exactly at the size limit", () => {
|
||||
const file = createMockFile("exact.csv", MAX_CSV_VALUES.FILE_SIZE, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test("checks extension before MIME type", () => {
|
||||
const file = createMockFile("data.txt", 100, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_files_only" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { TFunction } from "i18next";
|
||||
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: TConnectorOptionId;
|
||||
name: string;
|
||||
description: string;
|
||||
disabled: boolean;
|
||||
badge?: { text: string; type: "success" | "gray" | "warning" };
|
||||
}
|
||||
|
||||
export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
|
||||
{
|
||||
id: "formbricks_survey",
|
||||
name: t("workspace.unify.formbricks_surveys"),
|
||||
description: t("workspace.unify.source_connect_formbricks_description"),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "csv",
|
||||
name: t("workspace.unify.csv_import"),
|
||||
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[] => {
|
||||
return columns.split(",").map((col) => {
|
||||
const trimmed = col.trim();
|
||||
return { id: trimmed, name: trimmed, type: "string", sampleValue: `Sample ${trimmed}` };
|
||||
});
|
||||
};
|
||||
|
||||
export interface TEnumValidationError {
|
||||
targetFieldName: string;
|
||||
invalidEntries: { row: number; value: string }[];
|
||||
allowedValues: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that CSV columns mapped to enum target fields contain only allowed values.
|
||||
* Returns an array of validation errors (empty if all valid).
|
||||
*/
|
||||
export const validateEnumMappings = (
|
||||
mappings: TFieldMapping[],
|
||||
csvData: Record<string, string>[]
|
||||
): TEnumValidationError[] => {
|
||||
const errors: TEnumValidationError[] = [];
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!mapping.sourceFieldId || mapping.staticValue) continue;
|
||||
|
||||
const targetField = FEEDBACK_RECORD_FIELDS.find((f) => f.id === mapping.targetFieldId);
|
||||
if (targetField?.type !== "enum" || !targetField?.enumValues) continue;
|
||||
|
||||
const allowedValues = new Set(targetField.enumValues);
|
||||
const invalidEntries: { row: number; value: string }[] = [];
|
||||
|
||||
for (let i = 0; i < csvData.length; i++) {
|
||||
const value = csvData[i][mapping.sourceFieldId]?.trim();
|
||||
if (value && !allowedValues.has(value as THubFieldType)) {
|
||||
invalidEntries.push({ row: i + 1, value });
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidEntries.length > 0) {
|
||||
errors.push({
|
||||
targetFieldName: targetField.name,
|
||||
invalidEntries,
|
||||
allowedValues: targetField.enumValues,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const validateCsvFile = (
|
||||
file: File,
|
||||
t: TFunction
|
||||
): { valid: true } | { valid: false; error: string } => {
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
return { valid: false, error: t("workspace.unify.csv_files_only") };
|
||||
}
|
||||
if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) {
|
||||
return { valid: false, error: t("workspace.unify.csv_files_only") };
|
||||
}
|
||||
if (file.size > MAX_CSV_VALUES.FILE_SIZE) {
|
||||
return { valid: false, error: t("workspace.unify.csv_file_too_large") };
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { handleConnectorPipeline } from "@/lib/connector/pipeline-handler";
|
||||
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
@@ -153,6 +154,14 @@ export const POST = async (request: Request) => {
|
||||
});
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Handle connector pipeline for Hub integration (only on responseFinished to avoid duplicates)
|
||||
// This sends response data to the Hub for configured connectors
|
||||
try {
|
||||
await handleConnectorPipeline(response, survey, workspaceId);
|
||||
} catch (error) {
|
||||
// Log but don't throw - connector failures shouldn't break the main pipeline
|
||||
logger.error({ error, surveyId, responseId: response.id }, "Connector pipeline failed");
|
||||
}
|
||||
// Fetch integrations and responseCount in parallel
|
||||
const [integrations, responseCount] = await Promise.all([
|
||||
getIntegrations(workspaceId),
|
||||
|
||||
+122
-1
@@ -159,6 +159,7 @@ checksums:
|
||||
common/count_questions: a7a34376a01eda781381fe7544541293
|
||||
common/count_responses: 437e022825c7a08481d8f7e56926742d
|
||||
common/count_selections: a1ec41682b9a7d8601c3905dfba34e16
|
||||
common/create: 757ccd28dd533ff3a933355273c1e32a
|
||||
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
|
||||
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
|
||||
common/create_survey: 1cfbba08d34876566d84b2960054a987
|
||||
@@ -191,6 +192,7 @@ checksums:
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/elements: 8cb054d952b341e5965284860d532bc7
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/enable: 463972a7a95f50f3105d09b92508f2cd
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
|
||||
@@ -205,6 +207,7 @@ checksums:
|
||||
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
|
||||
common/failed_to_parse_csv: 7a3d675ecbb3d15884faf1006a5752d6
|
||||
common/filter: 626325a05e4c8800f7ede7012b0cadaf
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||
@@ -273,6 +276,7 @@ checksums:
|
||||
common/new: 126d036fae5fb6b629728ecb97e6195b
|
||||
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
|
||||
common/next: 89ddbcf710eba274963494f312bdc8a9
|
||||
common/no: 8c708225830b06df2d1141c536f2a0d6
|
||||
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
|
||||
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
|
||||
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
|
||||
@@ -349,6 +353,7 @@ checksums:
|
||||
common/response_id: 73375099cc976dc7203b8e27f5f709e0
|
||||
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
|
||||
common/restart: bab6232e89f24e3129f8e48268739d5b
|
||||
common/retry: 6e44d18639560596569a1278f9c83676
|
||||
common/role: 53743bbb6ca938f5b893552e839d067f
|
||||
common/saas: f01686245bcfb35a3590ab56db677bdb
|
||||
common/sales: 38758eb50094cd8190a71fe67be4d647
|
||||
@@ -420,6 +425,7 @@ checksums:
|
||||
common/trial_one_day_remaining: 2d64d39fca9589c4865357817bcc24d5
|
||||
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
|
||||
common/type: f04471a7ddac844b9ad145eb9911ef75
|
||||
common/unify: bdb518a1e62f51049ccc4366b909fb0a
|
||||
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
|
||||
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
|
||||
common/update: 079fc039262fd31b10532929685c2d1b
|
||||
@@ -437,6 +443,7 @@ checksums:
|
||||
common/variables: ffd3eec5497af36d7b4e4185bad1313a
|
||||
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
|
||||
common/video: 8050c90e4289b105a0780f0fdda6ff66
|
||||
common/view: 36a9b5e3dc153c036d320460d72a03c3
|
||||
common/warning: 6618da2c7e5e93bb4ea0e16d29ab8c4c
|
||||
common/we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable: f29f2e0286195dab170b9806bcd74fc9
|
||||
common/webhook: 70f95b2c27f2c3840b500fcaf79ee83c
|
||||
@@ -455,6 +462,7 @@ checksums:
|
||||
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
|
||||
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
|
||||
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
|
||||
common/yes: ec580fd11a45779b039466f1e35eed2a
|
||||
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
|
||||
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
|
||||
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
|
||||
@@ -2207,6 +2215,7 @@ checksums:
|
||||
workspace/settings/feedback_record_directories/archive_not_allowed: 3ffe3336572a633406858887de60a470
|
||||
workspace/settings/feedback_record_directories/are_you_sure_you_want_to_archive: d249e6e8bc0345835a13f70856eb1c30
|
||||
workspace/settings/feedback_record_directories/assign_workspaces_description: 6c3f0bbf3bd7744bb313f4cd7886e184
|
||||
workspace/settings/feedback_record_directories/connectors_description: 6efec0b94291db18124e8bfb1ced7e89
|
||||
workspace/settings/feedback_record_directories/create_feedback_directory: c178dd6dbd702398df3ac08a9fa43324
|
||||
workspace/settings/feedback_record_directories/description: 8f56b169cb38d8c7b2697bf3a3ed7a61
|
||||
workspace/settings/feedback_record_directories/directory_archived_successfully: fba5b99ced59d0546c8f2241c092a5dd
|
||||
@@ -2218,12 +2227,13 @@ checksums:
|
||||
workspace/settings/feedback_record_directories/directory_unarchived_successfully: 08d56e260decc62fe664b50ab774b728
|
||||
workspace/settings/feedback_record_directories/directory_updated_successfully: 638cb6c92f535328d809274cf2be4d7d
|
||||
workspace/settings/feedback_record_directories/empty_state: 665593dcb7cfa081a3e719677d0f6b0d
|
||||
workspace/settings/feedback_record_directories/enter_directory_name: a1c950988199bb4c4e014dcf430cce41
|
||||
workspace/settings/feedback_record_directories/error_directory_has_connectors: 792ca3a69d639f4fb602dd72daf5a806
|
||||
workspace/settings/feedback_record_directories/error_directory_name_duplicate: 349d650f562cff96b084787126323ca2
|
||||
workspace/settings/feedback_record_directories/error_directory_name_required: 0f42d7292979006a1069063ab213b8e3
|
||||
workspace/settings/feedback_record_directories/error_directory_workspaces_invalid_org: 477b5c1a466c4194668544ffd42ec9bf
|
||||
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/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
|
||||
workspace/settings/feedback_record_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
|
||||
workspace/settings/feedback_record_directories/title: e3d425c27f80162f29ce094e31a3fd8f
|
||||
@@ -3215,6 +3225,117 @@ checksums:
|
||||
workspace/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
|
||||
workspace/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
|
||||
workspace/teams/team_settings_description: 52f91883b9ceb6de83efbf8efd4f11c0
|
||||
workspace/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
|
||||
workspace/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
|
||||
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
|
||||
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
|
||||
workspace/unify/connector_status_updated_successfully: 443fd63b27f15a81ff146375adac739f
|
||||
workspace/unify/connector_updated_successfully: 11308c4a2881345209cefa06a3d90eab
|
||||
workspace/unify/connectors: 4d6f256254573013a8714c2afe98dcc2
|
||||
workspace/unify/create_mapping: cbe8c951e7819f574ca7d793920b2b60
|
||||
workspace/unify/created_by: 6775c2fa7d495fea48f1ad816daea93b
|
||||
workspace/unify/csv_at_least_one_row: 165bbc1853dde85c44eb5a587c52ce28
|
||||
workspace/unify/csv_columns: 280c5ba0b19ae5fa6d42f4d05a1771cb
|
||||
workspace/unify/csv_empty_column_headers: 6e9af154be54778cfca32296fbd23ecb
|
||||
workspace/unify/csv_file_too_large: e94c7a7c26096aae9eddb2db30c5cfc1
|
||||
workspace/unify/csv_files_only: 920612b537521b14c154f1ac9843e947
|
||||
workspace/unify/csv_import: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
workspace/unify/csv_import_complete: e8b6306e62e10c128f6464176ba879dd
|
||||
workspace/unify/csv_import_duplicate_warning: 56625e4613b93690e95661e5faaa4b27
|
||||
workspace/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
|
||||
workspace/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
|
||||
workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
|
||||
workspace/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922
|
||||
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
|
||||
workspace/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
|
||||
workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9
|
||||
workspace/unify/edit_source_connection: eb42476becc8de3de4ca9626828573f0
|
||||
workspace/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
|
||||
workspace/unify/enter_value: 4f068bb59617975c1e546218373122cd
|
||||
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
|
||||
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
|
||||
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
|
||||
workspace/unify/feedback_record_directory: 89a08a540d1c6eb9f0b1a4b8f56e8aca
|
||||
workspace/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
|
||||
workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388
|
||||
workspace/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
|
||||
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_rows: d2963498a7d2766264c4d67db677e8ff
|
||||
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/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/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_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
|
||||
workspace/unify/set_value: b8a86f8da957ebd599ece4b1b1936a78
|
||||
workspace/unify/setup_connection: cce7d9c488d737d04e70bed929a46f8a
|
||||
workspace/unify/showing_count_loaded: f443aae08223b65fbd5521d6e69534a4
|
||||
workspace/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131
|
||||
workspace/unify/source: 45309626f464f4bda161ee783a4c8c80
|
||||
workspace/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
|
||||
workspace/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
|
||||
workspace/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
|
||||
workspace/unify/source_name: 157675beca12efcd8ec512c5256b1a61
|
||||
workspace/unify/source_type: d1ff69af76c687eb189db72030717570
|
||||
workspace/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
|
||||
workspace/unify/sources: ecbbe6e49baa335c5afd7b04b609d006
|
||||
workspace/unify/status_active: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
workspace/unify/status_completed: 0e4bbce9985f25eb673d9a054c8d5334
|
||||
workspace/unify/status_draft: e8a92958ad300aacfe46c2bf6644927e
|
||||
workspace/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
workspace/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
|
||||
workspace/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d
|
||||
workspace/unify/survey_import_line: 63fa0ea1d7daa3ba333436fbc65f8b19
|
||||
workspace/unify/total_feedback_records: 8962087650b62e4a12b81e7d09317ffa
|
||||
workspace/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
|
||||
workspace/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
|
||||
workspace/unify/updated_at: 8fdb85248e591254973403755dcc3724
|
||||
workspace/unify/upload_csv_data_description: 7fab46222ab05a4424db90a7cc96cdf5
|
||||
workspace/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
|
||||
workspace/unify/user_identifier: 61073457a5c3901084b557d065f876be
|
||||
workspace/unify/value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||
workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9
|
||||
workspace/xm-templates/ces_description: c8d9794dd17d5ab85a979f1b3e1bc935
|
||||
workspace/xm-templates/csat: fdfc1dc6214cce661dcdc32a71d80337
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
TConnectorWithMappings,
|
||||
THubFieldType,
|
||||
ZConnectorCreateInput,
|
||||
ZConnectorFieldMappingCreateInput,
|
||||
ZConnectorUpdateInput,
|
||||
getHubFieldTypeFromElementType,
|
||||
} from "@formbricks/types/connector";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
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 {
|
||||
getOrganizationIdFromConnectorId,
|
||||
getOrganizationIdFromSurveyId,
|
||||
getOrganizationIdFromWorkspaceId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
import type { FeedbackRecordListParams, FeedbackRecordListResponse } from "@/modules/hub/types";
|
||||
import { importCsvData } from "./csv-import";
|
||||
import { importHistoricalResponses } from "./import";
|
||||
import {
|
||||
TMappingsInput,
|
||||
createConnectorWithMappings,
|
||||
deleteConnector,
|
||||
getConnectorWithMappingsById,
|
||||
updateConnector,
|
||||
updateConnectorWithMappings,
|
||||
} from "./service";
|
||||
|
||||
const ZDeleteConnectorAction = z.object({
|
||||
connectorId: ZId,
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const deleteConnectorAction = authenticatedActionClient
|
||||
.inputSchema(ZDeleteConnectorAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDeleteConnectorAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return deleteConnector(parsedInput.connectorId, parsedInput.workspaceId);
|
||||
}
|
||||
);
|
||||
|
||||
const resolveSurveyMappings = async (
|
||||
surveyId: string,
|
||||
elementIds: string[]
|
||||
): Promise<{ surveyId: string; elementId: string; hubFieldType: THubFieldType }[]> => {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
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;
|
||||
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),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const resolveFormbricksMappingsInput = async (
|
||||
entries: { surveyId: string; elementIds: string[] }[]
|
||||
): Promise<TMappingsInput> => {
|
||||
const allMappings = await Promise.all(
|
||||
entries.map(({ surveyId, elementIds }) => resolveSurveyMappings(surveyId, elementIds))
|
||||
);
|
||||
return { type: "formbricks_survey", mappings: allMappings.flat() };
|
||||
};
|
||||
|
||||
const ZFormbricksSurveyMapping = z.object({
|
||||
surveyId: ZId,
|
||||
elementIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
const ZCreateConnectorWithMappingsAction = z
|
||||
.object({
|
||||
workspaceId: ZId,
|
||||
connectorInput: ZConnectorCreateInput,
|
||||
formbricksMappings: z.array(ZFormbricksSurveyMapping).optional(),
|
||||
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.connectorInput.type === "formbricks_survey") {
|
||||
if (!data.formbricksMappings?.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["formbricksMappings"],
|
||||
message: "At least one survey mapping is required for Formbricks connectors",
|
||||
});
|
||||
}
|
||||
} else if (data.connectorInput.type === "csv") {
|
||||
if (!data.fieldMappings?.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["fieldMappings"],
|
||||
message: "At least one field mapping is required for CSV connectors",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const createConnectorWithMappingsAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateConnectorWithMappingsAction)
|
||||
.action(async ({ ctx, parsedInput }): Promise<TConnectorWithMappings> => {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify FRD belongs to same org
|
||||
const frd = await prisma.feedbackRecordDirectory.findUnique({
|
||||
where: { id: parsedInput.connectorInput.feedbackRecordDirectoryId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (frd?.organizationId !== organizationId) {
|
||||
throw new AuthorizationError("Invalid feedback record directory");
|
||||
}
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
const { formbricksMappings, fieldMappings } = parsedInput;
|
||||
|
||||
if (formbricksMappings?.length) {
|
||||
await Promise.all(
|
||||
formbricksMappings.map(async ({ surveyId }) => {
|
||||
const orgId = await getOrganizationIdFromSurveyId(surveyId);
|
||||
if (orgId !== organizationId) {
|
||||
throw new AuthorizationError("You are not authorized to access this survey");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
mappingsInput = await resolveFormbricksMappingsInput(formbricksMappings);
|
||||
} else if (fieldMappings?.length) {
|
||||
mappingsInput = { type: "field", mappings: fieldMappings };
|
||||
}
|
||||
|
||||
return createConnectorWithMappings(
|
||||
parsedInput.workspaceId,
|
||||
{ ...parsedInput.connectorInput, createdBy: ctx.user.id },
|
||||
mappingsInput
|
||||
);
|
||||
});
|
||||
|
||||
const ZUpdateConnectorWithMappingsAction = z.object({
|
||||
connectorId: ZId,
|
||||
workspaceId: ZId,
|
||||
connectorInput: ZConnectorUpdateInput,
|
||||
formbricksMappings: z.array(ZFormbricksSurveyMapping).min(1).optional(),
|
||||
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
|
||||
});
|
||||
|
||||
export const updateConnectorWithMappingsAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateConnectorWithMappingsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateConnectorWithMappingsAction>;
|
||||
}): Promise<TConnectorWithMappings> => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
if (parsedInput.formbricksMappings?.length) {
|
||||
await Promise.all(
|
||||
parsedInput.formbricksMappings.map(async ({ surveyId }) => {
|
||||
const orgId = await getOrganizationIdFromSurveyId(surveyId);
|
||||
if (orgId !== organizationId) {
|
||||
throw new AuthorizationError("You are not authorized to access this survey");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
mappingsInput = await resolveFormbricksMappingsInput(parsedInput.formbricksMappings);
|
||||
} else if (parsedInput.fieldMappings && parsedInput.fieldMappings.length > 0) {
|
||||
mappingsInput = { type: "field", mappings: parsedInput.fieldMappings };
|
||||
}
|
||||
|
||||
return updateConnectorWithMappings(
|
||||
parsedInput.connectorId,
|
||||
parsedInput.workspaceId,
|
||||
parsedInput.connectorInput,
|
||||
mappingsInput
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const ZDuplicateConnectorAction = z.object({
|
||||
connectorId: ZId,
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const duplicateConnectorAction = authenticatedActionClient
|
||||
.inputSchema(ZDuplicateConnectorAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDuplicateConnectorAction>;
|
||||
}): Promise<TConnectorWithMappings> => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const source = await getConnectorWithMappingsById(parsedInput.connectorId, parsedInput.workspaceId);
|
||||
if (!source) {
|
||||
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
|
||||
}
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
if (source.type === "formbricks_survey" && source.formbricksMappings.length > 0) {
|
||||
mappingsInput = {
|
||||
type: "formbricks_survey",
|
||||
mappings: source.formbricksMappings.map((m) => ({
|
||||
surveyId: m.surveyId,
|
||||
elementId: m.elementId,
|
||||
hubFieldType: m.hubFieldType,
|
||||
customFieldLabel: m.customFieldLabel ?? undefined,
|
||||
})),
|
||||
};
|
||||
} else if (source.fieldMappings.length > 0) {
|
||||
mappingsInput = {
|
||||
type: "field",
|
||||
mappings: source.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId,
|
||||
targetFieldId: m.targetFieldId,
|
||||
staticValue: m.staticValue ?? undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return createConnectorWithMappings(
|
||||
parsedInput.workspaceId,
|
||||
{
|
||||
name: `${source.name} (copy)`,
|
||||
type: source.type,
|
||||
feedbackRecordDirectoryId: source.feedbackRecordDirectoryId,
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
mappingsInput
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const ZGetResponseCountAction = z.object({
|
||||
surveyId: ZId,
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const getResponseCountAction = authenticatedActionClient
|
||||
.inputSchema(ZGetResponseCountAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetResponseCountAction>;
|
||||
}): Promise<number> => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getResponseCountBySurveyId(parsedInput.surveyId);
|
||||
}
|
||||
);
|
||||
|
||||
const ZImportHistoricalResponsesAction = z.object({
|
||||
connectorId: ZId,
|
||||
workspaceId: ZId,
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const importHistoricalResponsesAction = authenticatedActionClient
|
||||
.inputSchema(ZImportHistoricalResponsesAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZImportHistoricalResponsesAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const connector = await getConnectorWithMappingsById(parsedInput.connectorId, parsedInput.workspaceId);
|
||||
if (!connector) {
|
||||
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
|
||||
}
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
return importHistoricalResponses(connector, survey);
|
||||
}
|
||||
);
|
||||
|
||||
const ZImportCsvDataAction = z.object({
|
||||
connectorId: ZId,
|
||||
workspaceId: ZId,
|
||||
csvData: z.array(z.record(z.string(), z.string())).min(1),
|
||||
});
|
||||
|
||||
export const importCsvDataAction = authenticatedActionClient
|
||||
.inputSchema(ZImportCsvDataAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZImportCsvDataAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const connector = await getConnectorWithMappingsById(parsedInput.connectorId, parsedInput.workspaceId);
|
||||
if (!connector) {
|
||||
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
|
||||
}
|
||||
|
||||
const result = await importCsvData(connector, parsedInput.csvData);
|
||||
|
||||
if (result.successes > 0) {
|
||||
await updateConnector(parsedInput.connectorId, parsedInput.workspaceId, {
|
||||
lastSyncAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
const ZListFeedbackRecordsAction = z.object({
|
||||
workspaceId: ZId,
|
||||
limit: z.number().min(1).max(1000).optional(),
|
||||
cursor: z.string().optional(),
|
||||
sourceType: z.string().optional(),
|
||||
fieldType: z
|
||||
.enum(["text", "categorical", "nps", "csat", "ces", "rating", "number", "boolean", "date"])
|
||||
.optional(),
|
||||
since: z.string().optional(),
|
||||
until: z.string().optional(),
|
||||
});
|
||||
|
||||
export const listFeedbackRecordsAction = authenticatedActionClient
|
||||
.inputSchema(ZListFeedbackRecordsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZListFeedbackRecordsAction>;
|
||||
}): Promise<FeedbackRecordListResponse> => {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// tenant_id = FRD id. Fan out across all FRDs assigned to this workspace, merge + sort desc.
|
||||
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(parsedInput.workspaceId);
|
||||
if (frds.length === 0) {
|
||||
return { data: [], limit: parsedInput.limit ?? 50 };
|
||||
}
|
||||
|
||||
const perFrdLimit = parsedInput.limit ?? 50;
|
||||
const baseParams = {
|
||||
limit: perFrdLimit,
|
||||
...(parsedInput.sourceType ? { source_type: parsedInput.sourceType } : {}),
|
||||
...(parsedInput.fieldType ? { field_type: parsedInput.fieldType } : {}),
|
||||
...(parsedInput.since ? { since: parsedInput.since } : {}),
|
||||
...(parsedInput.until ? { until: parsedInput.until } : {}),
|
||||
};
|
||||
|
||||
const results = await Promise.all(
|
||||
frds.map((frd) =>
|
||||
listFeedbackRecords({ ...baseParams, tenant_id: frd.id } as FeedbackRecordListParams)
|
||||
)
|
||||
);
|
||||
|
||||
const errored = results.find((r) => r.error);
|
||||
if (errored?.error) {
|
||||
logger.warn({ error: errored.error }, "Failed to list feedback records");
|
||||
throw new Error(errored.error.message);
|
||||
}
|
||||
|
||||
const merged = results
|
||||
.flatMap((r) => r.data?.data ?? [])
|
||||
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
|
||||
.slice(0, perFrdLimit);
|
||||
|
||||
return { data: merged, limit: perFrdLimit };
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { importCsvData } from "./csv-import";
|
||||
|
||||
vi.mock("@/modules/hub", () => ({
|
||||
createFeedbackRecordsBatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./csv-transform", () => ({
|
||||
transformCsvRowsToFeedbackRecords: vi.fn(),
|
||||
}));
|
||||
|
||||
const { createFeedbackRecordsBatch } = vi.mocked(await import("@/modules/hub"));
|
||||
const { transformCsvRowsToFeedbackRecords } = vi.mocked(await import("./csv-transform"));
|
||||
|
||||
const NOW = new Date("2026-02-25T10:00:00.000Z");
|
||||
|
||||
const makeConnector = (overrides?: Partial<TConnectorWithMappings>): TConnectorWithMappings => ({
|
||||
id: "conn-1",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
name: "CSV Import",
|
||||
type: "csv",
|
||||
status: "active",
|
||||
workspaceId: "env-1",
|
||||
lastSyncAt: null,
|
||||
createdBy: null,
|
||||
creatorName: null,
|
||||
formbricksMappings: [],
|
||||
fieldMappings: [
|
||||
{
|
||||
id: "fm-1",
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
sourceFieldId: "feedback",
|
||||
targetFieldId: "value_text",
|
||||
staticValue: null,
|
||||
},
|
||||
{
|
||||
id: "fm-2",
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
sourceFieldId: "",
|
||||
targetFieldId: "source_type",
|
||||
staticValue: "csv",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("importCsvData", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError for non-csv connector", async () => {
|
||||
const connector = makeConnector({ type: "formbricks_survey" });
|
||||
await expect(importCsvData(connector, [])).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when no field mappings configured", async () => {
|
||||
const connector = makeConnector({ fieldMappings: [] });
|
||||
await expect(importCsvData(connector, [{ feedback: "test" }])).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("returns zeros when all rows are skipped", async () => {
|
||||
transformCsvRowsToFeedbackRecords.mockReturnValue({ records: [], skipped: 3 });
|
||||
|
||||
const result = await importCsvData(makeConnector(), [{ a: "1" }, { a: "2" }, { a: "3" }]);
|
||||
|
||||
expect(result).toEqual({ successes: 0, failures: 0, skipped: 3 });
|
||||
expect(createFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("sends transformed records to Hub and counts results", async () => {
|
||||
transformCsvRowsToFeedbackRecords.mockReturnValue({
|
||||
records: [
|
||||
{ source_type: "csv", field_id: "q1", field_type: "text" as const, value_text: "Good" },
|
||||
{ source_type: "csv", field_id: "q2", field_type: "text" as const, value_text: "Bad" },
|
||||
],
|
||||
skipped: 1,
|
||||
});
|
||||
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [
|
||||
{ data: { id: "fb1" }, error: null },
|
||||
{ data: null, error: { status: 400, message: "Bad request", detail: null } },
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await importCsvData(makeConnector(), [{}, {}, {}]);
|
||||
|
||||
expect(result).toEqual({ successes: 1, failures: 1, skipped: 1 });
|
||||
});
|
||||
|
||||
test("processes records in batches of 50", async () => {
|
||||
const records = Array.from({ length: 120 }, (_, i) => ({
|
||||
source_type: "csv",
|
||||
field_id: `q${i}`,
|
||||
field_type: "text" as const,
|
||||
value_text: `row ${i}`,
|
||||
}));
|
||||
|
||||
transformCsvRowsToFeedbackRecords.mockReturnValue({ records, skipped: 0 });
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [{ data: { id: "fb" }, error: null }],
|
||||
} as never);
|
||||
|
||||
await importCsvData(
|
||||
makeConnector(),
|
||||
Array.from({ length: 120 }, () => ({}))
|
||||
);
|
||||
|
||||
expect(createFeedbackRecordsBatch).toHaveBeenCalledTimes(3);
|
||||
expect(createFeedbackRecordsBatch.mock.calls[0][0]).toHaveLength(50);
|
||||
expect(createFeedbackRecordsBatch.mock.calls[1][0]).toHaveLength(50);
|
||||
expect(createFeedbackRecordsBatch.mock.calls[2][0]).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import "server-only";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { createFeedbackRecordsBatch } from "@/modules/hub";
|
||||
import { transformCsvRowsToFeedbackRecords } from "./csv-transform";
|
||||
import { TImportResult } from "./import";
|
||||
|
||||
const CSV_BATCH_SIZE = 50;
|
||||
|
||||
export const importCsvData = async (
|
||||
connector: TConnectorWithMappings,
|
||||
csvRows: Record<string, string>[]
|
||||
): Promise<TImportResult> => {
|
||||
if (connector.type !== "csv") {
|
||||
throw new InvalidInputError("CSV import is only supported for CSV connectors");
|
||||
}
|
||||
|
||||
if (connector.fieldMappings.length === 0) {
|
||||
throw new InvalidInputError("Connector has no field mappings configured");
|
||||
}
|
||||
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords(
|
||||
csvRows,
|
||||
connector.fieldMappings,
|
||||
connector.feedbackRecordDirectoryId
|
||||
);
|
||||
|
||||
let successes = 0;
|
||||
let failures = 0;
|
||||
|
||||
for (let i = 0; i < records.length; i += CSV_BATCH_SIZE) {
|
||||
const batch = records.slice(i, i + CSV_BATCH_SIZE);
|
||||
const { results } = await createFeedbackRecordsBatch(batch);
|
||||
successes += results.filter((r) => r.data !== null).length;
|
||||
failures += results.filter((r) => r.error !== null).length;
|
||||
}
|
||||
|
||||
return { successes, failures, skipped };
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorFieldMapping } from "@formbricks/types/connector";
|
||||
import { transformCsvRowToFeedbackRecord, transformCsvRowsToFeedbackRecords } from "./csv-transform";
|
||||
|
||||
const NOW = new Date("2026-02-25T10:00:00.000Z");
|
||||
|
||||
const makeMapping = (
|
||||
sourceFieldId: string,
|
||||
targetFieldId: string,
|
||||
staticValue?: string
|
||||
): TConnectorFieldMapping => ({
|
||||
id: `mapping-${targetFieldId}`,
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
sourceFieldId,
|
||||
targetFieldId: targetFieldId as TConnectorFieldMapping["targetFieldId"],
|
||||
staticValue: staticValue ?? null,
|
||||
});
|
||||
|
||||
const baseMappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("feedback_text", "value_text"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("", "source_type", "survey"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
describe("transformCsvRowToFeedbackRecord", () => {
|
||||
test("transforms a basic row with all required fields", () => {
|
||||
const row = {
|
||||
feedback_text: "Great product!",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.source_type).toBe("survey");
|
||||
expect(result!.field_id).toBe("q1");
|
||||
expect(result!.field_type).toBe("text");
|
||||
expect(result!.value_text).toBe("Great product!");
|
||||
expect(result!.collected_at).toBe("2026-01-15T10:00:00.000Z");
|
||||
});
|
||||
|
||||
test("returns null when required fields are missing", () => {
|
||||
const row = { feedback_text: "Great product!" };
|
||||
const mappings = [makeMapping("feedback_text", "value_text")];
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("coerces value_number from string", () => {
|
||||
const mappings = [...baseMappings, makeMapping("rating", "value_number")];
|
||||
const row = {
|
||||
feedback_text: "Good",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
rating: "4.5",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.value_number).toBe(4.5);
|
||||
});
|
||||
|
||||
test("skips value_number when not a valid number", () => {
|
||||
const mappings = [...baseMappings, makeMapping("rating", "value_number")];
|
||||
const row = {
|
||||
feedback_text: "Good",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
rating: "not-a-number",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.value_number).toBeUndefined();
|
||||
});
|
||||
|
||||
test("coerces value_boolean from string", () => {
|
||||
const mappings = [...baseMappings, makeMapping("is_promoter", "value_boolean")];
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "true" },
|
||||
mappings
|
||||
)!.value_boolean
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "0" },
|
||||
mappings
|
||||
)!.value_boolean
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "yes" },
|
||||
mappings
|
||||
)!.value_boolean
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("handles $now static value for collected_at", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("", "source_type", "csv"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("", "collected_at", "$now"),
|
||||
];
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord({ question: "q1" }, mappings);
|
||||
expect(result!.collected_at).toBe(NOW.toISOString());
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("uses static value over source field", () => {
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("type_column", "source_type", "always_survey"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
const row = { question: "q1", type_column: "review", timestamp: "2026-01-15" };
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.source_type).toBe("always_survey");
|
||||
});
|
||||
|
||||
test("skips empty string values", () => {
|
||||
const row = {
|
||||
feedback_text: "",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
expect(result!.value_text).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses metadata as JSON", () => {
|
||||
const mappings = [...baseMappings, makeMapping("meta", "metadata")];
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15",
|
||||
meta: '{"device":"mobile","version":"2.1"}',
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.metadata).toEqual({ device: "mobile", version: "2.1" });
|
||||
});
|
||||
|
||||
test("wraps non-JSON metadata in { raw: value }", () => {
|
||||
const mappings = [...baseMappings, makeMapping("meta", "metadata")];
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15",
|
||||
meta: "just a string",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.metadata).toEqual({ raw: "just a string" });
|
||||
});
|
||||
|
||||
test("handles invalid date gracefully", () => {
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
timestamp: "not-a-date",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
expect(result!.collected_at).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformCsvRowsToFeedbackRecords", () => {
|
||||
test("transforms multiple rows and counts skipped", () => {
|
||||
const rows = [
|
||||
{ feedback_text: "Good", question: "q1", timestamp: "2026-01-15" },
|
||||
{ feedback_text: "Bad", question: "q2", timestamp: "2026-01-16" },
|
||||
{ feedback_text: "No question field" },
|
||||
];
|
||||
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("feedback_text", "value_text"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("", "source_type", "survey"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords(rows, mappings);
|
||||
|
||||
expect(records).toHaveLength(2);
|
||||
expect(skipped).toBe(1);
|
||||
expect(records[0].field_id).toBe("q1");
|
||||
expect(records[1].field_id).toBe("q2");
|
||||
});
|
||||
|
||||
test("returns empty records for empty input", () => {
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords([], baseMappings);
|
||||
expect(records).toHaveLength(0);
|
||||
expect(skipped).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { TConnectorFieldMapping, THubTargetField } from "@formbricks/types/connector";
|
||||
import { FeedbackRecordCreateParams } from "@/modules/hub";
|
||||
|
||||
const NUMERIC_FIELDS = new Set<THubTargetField>(["value_number"]);
|
||||
const BOOLEAN_FIELDS = new Set<THubTargetField>(["value_boolean"]);
|
||||
const TIMESTAMP_FIELDS = new Set<THubTargetField>(["collected_at", "value_date"]);
|
||||
const JSON_FIELDS = new Set<THubTargetField>(["metadata"]);
|
||||
|
||||
const coerceValue = (value: string, targetField: THubTargetField): string | number | boolean | undefined => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return undefined;
|
||||
|
||||
if (NUMERIC_FIELDS.has(targetField)) {
|
||||
const parsed = Number.parseFloat(trimmed);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
if (BOOLEAN_FIELDS.has(targetField)) {
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower === "true" || lower === "1" || lower === "yes") return true;
|
||||
if (lower === "false" || lower === "0" || lower === "no") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (TIMESTAMP_FIELDS.has(targetField)) {
|
||||
const date = new Date(trimmed);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const resolveValue = (
|
||||
row: Record<string, string>,
|
||||
mapping: TConnectorFieldMapping
|
||||
): string | number | boolean | undefined => {
|
||||
if (mapping.staticValue) {
|
||||
if (mapping.staticValue === "$now" && TIMESTAMP_FIELDS.has(mapping.targetFieldId)) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
return coerceValue(mapping.staticValue, mapping.targetFieldId);
|
||||
}
|
||||
|
||||
const rawValue = row[mapping.sourceFieldId];
|
||||
if (rawValue === undefined || rawValue === null) return undefined;
|
||||
|
||||
return coerceValue(rawValue, mapping.targetFieldId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform a single CSV row into a FeedbackRecord using field mappings.
|
||||
*
|
||||
* Each mapping maps a CSV column (sourceFieldId) or a static value to a target field.
|
||||
* Returns null if required fields (source_type, field_id, field_type) are missing after mapping.
|
||||
*/
|
||||
export const transformCsvRowToFeedbackRecord = (
|
||||
row: Record<string, string>,
|
||||
mappings: TConnectorFieldMapping[],
|
||||
tenantId?: string
|
||||
): FeedbackRecordCreateParams | null => {
|
||||
const record: Record<string, string | number | boolean | Record<string, unknown> | undefined> = {};
|
||||
|
||||
for (const mapping of mappings) {
|
||||
const value = resolveValue(row, mapping);
|
||||
if (value === undefined) continue;
|
||||
|
||||
if (JSON_FIELDS.has(mapping.targetFieldId)) {
|
||||
try {
|
||||
record[mapping.targetFieldId] = typeof value === "string" ? JSON.parse(value) : value;
|
||||
} catch {
|
||||
record[mapping.targetFieldId] = { raw: value };
|
||||
}
|
||||
} else {
|
||||
record[mapping.targetFieldId] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!record.source_type || !record.field_id || !record.field_type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tenantId && !record.tenant_id) {
|
||||
record.tenant_id = tenantId;
|
||||
}
|
||||
|
||||
return record as unknown as FeedbackRecordCreateParams;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform multiple CSV rows into FeedbackRecords.
|
||||
* Returns the successfully transformed records and a count of skipped rows.
|
||||
*/
|
||||
export const transformCsvRowsToFeedbackRecords = (
|
||||
rows: Record<string, string>[],
|
||||
mappings: TConnectorFieldMapping[],
|
||||
tenantId?: string
|
||||
): { records: FeedbackRecordCreateParams[]; skipped: number } => {
|
||||
const records: FeedbackRecordCreateParams[] = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const record = transformCsvRowToFeedbackRecord(row, mappings, tenantId);
|
||||
if (record) {
|
||||
records.push(record);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { records, skipped };
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { importHistoricalResponses } from "./import";
|
||||
|
||||
vi.mock("../response/service", () => ({
|
||||
getResponses: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/hub", () => ({
|
||||
createFeedbackRecordsBatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./transform", () => ({
|
||||
transformResponseToFeedbackRecords: vi.fn(),
|
||||
}));
|
||||
|
||||
const { getResponses } = vi.mocked(await import("../response/service"));
|
||||
const { createFeedbackRecordsBatch } = vi.mocked(await import("@/modules/hub"));
|
||||
const { transformResponseToFeedbackRecords } = vi.mocked(await import("./transform"));
|
||||
|
||||
const ENV_ID = "clxxxxxxxxxxxxxxxx001";
|
||||
const CONNECTOR_ID = "clxxxxxxxxxxxxxxxx002";
|
||||
const SURVEY_ID = "clxxxxxxxxxxxxxxxx003";
|
||||
const NOW = new Date("2026-02-24T10:00:00.000Z");
|
||||
|
||||
const mockConnector: TConnectorWithMappings = {
|
||||
id: CONNECTOR_ID,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
name: "Test Connector",
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
workspaceId: ENV_ID,
|
||||
lastSyncAt: null,
|
||||
createdBy: null,
|
||||
creatorName: null,
|
||||
formbricksMappings: [
|
||||
{
|
||||
id: "mapping-1",
|
||||
createdAt: NOW,
|
||||
connectorId: CONNECTOR_ID,
|
||||
workspaceId: ENV_ID,
|
||||
surveyId: SURVEY_ID,
|
||||
elementId: "el-1",
|
||||
hubFieldType: "text",
|
||||
customFieldLabel: null,
|
||||
},
|
||||
],
|
||||
fieldMappings: [],
|
||||
};
|
||||
|
||||
const mockSurvey = { id: SURVEY_ID, blocks: [] } as unknown as TSurvey;
|
||||
|
||||
describe("importHistoricalResponses", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError for non-formbricks connector", async () => {
|
||||
const csvConnector = { ...mockConnector, type: "csv" as const };
|
||||
|
||||
await expect(importHistoricalResponses(csvConnector, mockSurvey)).rejects.toThrow(InvalidInputError);
|
||||
expect(getResponses).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns zeros when there are no responses", async () => {
|
||||
getResponses.mockResolvedValue([]);
|
||||
|
||||
const result = await importHistoricalResponses(mockConnector, mockSurvey);
|
||||
|
||||
expect(result).toEqual({ successes: 0, failures: 0, skipped: 0 });
|
||||
});
|
||||
|
||||
test("counts successes and skipped correctly", async () => {
|
||||
const mockResponses = [{ id: "r1" }, { id: "r2" }, { id: "r3" }];
|
||||
getResponses.mockResolvedValueOnce(mockResponses as never);
|
||||
getResponses.mockResolvedValueOnce([]);
|
||||
|
||||
transformResponseToFeedbackRecords
|
||||
.mockReturnValueOnce([{ field: "record1" }] as never)
|
||||
.mockReturnValueOnce([])
|
||||
.mockReturnValueOnce([{ field: "record3" }] as never);
|
||||
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [
|
||||
{ data: { id: "fb1" }, error: null },
|
||||
{ data: { id: "fb2" }, error: null },
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await importHistoricalResponses(mockConnector, mockSurvey);
|
||||
|
||||
expect(result.successes).toBe(2);
|
||||
expect(result.failures).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
});
|
||||
|
||||
test("counts failures from Hub API errors", async () => {
|
||||
const mockResponses = [{ id: "r1" }];
|
||||
getResponses.mockResolvedValueOnce(mockResponses as never);
|
||||
getResponses.mockResolvedValueOnce([]);
|
||||
|
||||
transformResponseToFeedbackRecords.mockReturnValue([{ field: "record" }] as never);
|
||||
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [{ data: null, error: { status: 400, message: "Bad request" } }],
|
||||
} as never);
|
||||
|
||||
const result = await importHistoricalResponses(mockConnector, mockSurvey);
|
||||
|
||||
expect(result.successes).toBe(0);
|
||||
expect(result.failures).toBe(1);
|
||||
});
|
||||
|
||||
test("paginates through responses in batches", async () => {
|
||||
const batch1 = Array.from({ length: 50 }, (_, i) => ({ id: `r${i}` }));
|
||||
const batch2 = [{ id: "r50" }];
|
||||
|
||||
getResponses.mockResolvedValueOnce(batch1 as never);
|
||||
getResponses.mockResolvedValueOnce(batch2 as never);
|
||||
getResponses.mockResolvedValueOnce([]);
|
||||
|
||||
transformResponseToFeedbackRecords.mockReturnValue([{ field: "record" }] as never);
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [{ data: { id: "fb" }, error: null }],
|
||||
} as never);
|
||||
|
||||
await importHistoricalResponses(mockConnector, mockSurvey);
|
||||
|
||||
expect(getResponses).toHaveBeenCalledWith(SURVEY_ID, 50, 0);
|
||||
expect(getResponses).toHaveBeenCalledWith(SURVEY_ID, 50, 50);
|
||||
});
|
||||
|
||||
test("does not call Hub API when all responses are skipped", async () => {
|
||||
const mockResponses = [{ id: "r1" }, { id: "r2" }];
|
||||
getResponses.mockResolvedValueOnce(mockResponses as never);
|
||||
getResponses.mockResolvedValueOnce([]);
|
||||
|
||||
transformResponseToFeedbackRecords.mockReturnValue([]);
|
||||
|
||||
const result = await importHistoricalResponses(mockConnector, mockSurvey);
|
||||
|
||||
expect(createFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ successes: 0, failures: 0, skipped: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import "server-only";
|
||||
import { TConnectorFormbricksMapping, TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { createFeedbackRecordsBatch } from "@/modules/hub";
|
||||
import { getResponses } from "../response/service";
|
||||
import { transformResponseToFeedbackRecords } from "./transform";
|
||||
|
||||
const IMPORT_BATCH_SIZE = 50;
|
||||
|
||||
export type TImportResult = { successes: number; failures: number; skipped: number };
|
||||
|
||||
const processBatch = async (
|
||||
responses: Awaited<ReturnType<typeof getResponses>>,
|
||||
survey: TSurvey,
|
||||
mappings: TConnectorFormbricksMapping[],
|
||||
tenantId: string
|
||||
): Promise<TImportResult> => {
|
||||
let successes = 0;
|
||||
let failures = 0;
|
||||
const expectedRecords = responses.length * mappings.length;
|
||||
|
||||
const allRecords = responses.flatMap((response) =>
|
||||
transformResponseToFeedbackRecords(response, survey, mappings, tenantId)
|
||||
);
|
||||
|
||||
if (allRecords.length > 0) {
|
||||
const { results } = await createFeedbackRecordsBatch(allRecords);
|
||||
successes = results.filter((r) => r.data !== null).length;
|
||||
failures = results.filter((r) => r.error !== null).length;
|
||||
}
|
||||
|
||||
return { successes, failures, skipped: expectedRecords - allRecords.length };
|
||||
};
|
||||
|
||||
export const importHistoricalResponses = async (
|
||||
connector: TConnectorWithMappings,
|
||||
survey: TSurvey
|
||||
): Promise<TImportResult> => {
|
||||
if (connector.type !== "formbricks_survey") {
|
||||
throw new InvalidInputError("Historical import is only supported for Formbricks connectors");
|
||||
}
|
||||
|
||||
let successes = 0;
|
||||
let failures = 0;
|
||||
let skipped = 0;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const responses = await getResponses(survey.id, IMPORT_BATCH_SIZE, offset);
|
||||
if (responses.length === 0) break;
|
||||
|
||||
const batch = await processBatch(
|
||||
responses,
|
||||
survey,
|
||||
connector.formbricksMappings,
|
||||
connector.feedbackRecordDirectoryId
|
||||
);
|
||||
successes += batch.successes;
|
||||
failures += batch.failures;
|
||||
skipped += batch.skipped;
|
||||
|
||||
if (responses.length < IMPORT_BATCH_SIZE) break;
|
||||
offset += IMPORT_BATCH_SIZE;
|
||||
}
|
||||
|
||||
return { successes, failures, skipped };
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const mockCreateFeedbackRecordsBatch = vi.fn();
|
||||
|
||||
vi.mock("@/modules/hub", () => ({
|
||||
createFeedbackRecordsBatch: (...args: unknown[]) => mockCreateFeedbackRecordsBatch(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./service", () => ({
|
||||
getConnectorsBySurveyId: vi.fn(),
|
||||
updateConnector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./transform", () => ({
|
||||
transformResponseToFeedbackRecords: vi.fn(),
|
||||
}));
|
||||
|
||||
const { getConnectorsBySurveyId, updateConnector } = await import("./service");
|
||||
const { transformResponseToFeedbackRecords } = await import("./transform");
|
||||
const { handleConnectorPipeline } = await import("./pipeline-handler");
|
||||
|
||||
const mockResponse = {
|
||||
id: "resp-1",
|
||||
createdAt: new Date("2026-02-24T10:00:00.000Z"),
|
||||
surveyId: "survey-1",
|
||||
data: { "el-1": "answer" },
|
||||
} as unknown as TResponse;
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
blocks: [{ id: "block-1", name: "Block", elements: [{ id: "el-1", headline: { default: "Question?" } }] }],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
function createConnector(
|
||||
overrides: Partial<Pick<TConnectorWithMappings, "id" | "formbricksMappings">> = {}
|
||||
): TConnectorWithMappings {
|
||||
return {
|
||||
id: "conn-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Connector",
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
workspaceId: "env-1",
|
||||
feedbackRecordDirectoryId: "frd-1",
|
||||
lastSyncAt: null,
|
||||
formbricksMappings: [
|
||||
{
|
||||
id: "map-1",
|
||||
createdAt: new Date(),
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
surveyId: "survey-1",
|
||||
elementId: "el-1",
|
||||
hubFieldType: "rating",
|
||||
customFieldLabel: null,
|
||||
},
|
||||
],
|
||||
fieldMappings: [],
|
||||
...overrides,
|
||||
} as TConnectorWithMappings;
|
||||
}
|
||||
|
||||
const oneFeedbackRecord = [
|
||||
{
|
||||
field_id: "el-1",
|
||||
field_type: "rating" as const,
|
||||
source_type: "formbricks_survey",
|
||||
source_id: "survey-1",
|
||||
source_name: "Test Survey",
|
||||
field_label: "Question?",
|
||||
value_number: 5,
|
||||
collected_at: "2026-02-24T10:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const noConfigError = {
|
||||
status: 0,
|
||||
message: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
};
|
||||
|
||||
describe("handleConnectorPipeline", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns early when no connectors for survey", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([]);
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(transformResponseToFeedbackRecords).not.toHaveBeenCalled();
|
||||
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("continues when transform returns no feedback records", async () => {
|
||||
const connector = createConnector();
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([connector]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue([]);
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(transformResponseToFeedbackRecords).toHaveBeenCalledWith(
|
||||
mockResponse,
|
||||
mockSurvey,
|
||||
connector.formbricksMappings,
|
||||
"frd-1"
|
||||
);
|
||||
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not update connector when Hub returns no-config (HUB_API_KEY not set)", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
|
||||
mockCreateFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: oneFeedbackRecord.map(() => ({ data: null, error: noConfigError })),
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(mockCreateFeedbackRecordsBatch).toHaveBeenCalledWith(oneFeedbackRecord);
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("sends records to Hub and updates lastSyncAt on full success", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
|
||||
mockCreateFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [{ data: { id: "hub-1", ...oneFeedbackRecord[0] }, error: null }],
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(mockCreateFeedbackRecordsBatch).toHaveBeenCalledWith(oneFeedbackRecord);
|
||||
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
|
||||
lastSyncAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
test("does not update connector when all Hub creates fail", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(oneFeedbackRecord as any);
|
||||
mockCreateFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [
|
||||
{ data: null, error: { status: 500, message: "Hub unavailable", detail: "Hub unavailable" } },
|
||||
],
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("updates lastSyncAt on partial failure when some creates succeed", async () => {
|
||||
const twoRecords = [...oneFeedbackRecord, { ...oneFeedbackRecord[0], field_id: "el-2", value_number: 3 }];
|
||||
const baseMapping = {
|
||||
createdAt: new Date(),
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
surveyId: "survey-1",
|
||||
hubFieldType: "rating" as const,
|
||||
customFieldLabel: null as string | null,
|
||||
};
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([
|
||||
createConnector({
|
||||
formbricksMappings: [
|
||||
{ ...baseMapping, id: "m1", elementId: "el-1" },
|
||||
{ ...baseMapping, id: "m2", elementId: "el-2" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockReturnValue(twoRecords as any);
|
||||
mockCreateFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [
|
||||
{ data: { id: "hub-1" }, error: null },
|
||||
{ data: null, error: { status: 429, message: "Rate limited", detail: "Rate limited" } },
|
||||
],
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(updateConnector).toHaveBeenCalledWith("conn-1", "env-1", {
|
||||
lastSyncAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
test("does not update connector when transform throws", async () => {
|
||||
vi.mocked(getConnectorsBySurveyId).mockResolvedValue([createConnector()]);
|
||||
vi.mocked(transformResponseToFeedbackRecords).mockImplementation(() => {
|
||||
throw new Error("Transform failed");
|
||||
});
|
||||
|
||||
await handleConnectorPipeline(mockResponse, mockSurvey, "env-1");
|
||||
|
||||
expect(updateConnector).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { createFeedbackRecordsBatch } from "@/modules/hub";
|
||||
import { getConnectorsBySurveyId, updateConnector } from "./service";
|
||||
import { transformResponseToFeedbackRecords } from "./transform";
|
||||
|
||||
const getErrorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
const logFailedRecords = (
|
||||
connectorId: string,
|
||||
results: Awaited<ReturnType<typeof createFeedbackRecordsBatch>>["results"]
|
||||
): void => {
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (!result.error) continue;
|
||||
logger.error(
|
||||
{
|
||||
connectorId,
|
||||
feedbackRecordIndex: index,
|
||||
error: {
|
||||
status: result.error.status,
|
||||
message: result.error.message,
|
||||
detail: result.error.detail,
|
||||
},
|
||||
},
|
||||
"Failed to create FeedbackRecord"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const processConnector = async (
|
||||
connector: TConnectorWithMappings,
|
||||
response: TResponse,
|
||||
survey: TSurvey,
|
||||
workspaceId: string
|
||||
): Promise<void> => {
|
||||
const feedbackRecords = transformResponseToFeedbackRecords(
|
||||
response,
|
||||
survey,
|
||||
connector.formbricksMappings,
|
||||
connector.feedbackRecordDirectoryId
|
||||
);
|
||||
|
||||
if (feedbackRecords.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { results } = await createFeedbackRecordsBatch(feedbackRecords);
|
||||
|
||||
const successes = results.filter((r) => r.data !== null).length;
|
||||
const failures = results.filter((r) => r.error !== null).length;
|
||||
|
||||
if (failures > 0) {
|
||||
logger.warn(
|
||||
{
|
||||
connectorId: connector.id,
|
||||
surveyId: survey.id,
|
||||
responseId: response.id,
|
||||
successes,
|
||||
failures,
|
||||
},
|
||||
`Connector pipeline: ${failures}/${feedbackRecords.length} FeedbackRecords failed to send`
|
||||
);
|
||||
logFailedRecords(connector.id, results);
|
||||
} else {
|
||||
logger.info(
|
||||
{
|
||||
connectorId: connector.id,
|
||||
surveyId: survey.id,
|
||||
responseId: response.id,
|
||||
feedbackRecordsCreated: successes,
|
||||
},
|
||||
`Connector pipeline: Successfully sent ${successes} FeedbackRecords`
|
||||
);
|
||||
}
|
||||
|
||||
if (successes > 0) {
|
||||
await updateConnector(connector.id, workspaceId, { lastSyncAt: new Date() });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle connector pipeline for a survey response
|
||||
*
|
||||
* This function is called from the pipeline when a response is created/finished.
|
||||
* It looks up active connectors for the survey and sends the response data.
|
||||
*
|
||||
* @param response - The survey response
|
||||
* @param survey - The survey
|
||||
* @param workspaceId - The workspace ID (used as tenant_id)
|
||||
*/
|
||||
export const handleConnectorPipeline = async (
|
||||
response: TResponse,
|
||||
survey: TSurvey,
|
||||
workspaceId: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const connectors = await getConnectorsBySurveyId(survey.id);
|
||||
|
||||
if (connectors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const connector of connectors) {
|
||||
try {
|
||||
await processConnector(connector, response, survey, workspaceId);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
connectorId: connector.id,
|
||||
surveyId: survey.id,
|
||||
responseId: response.id,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
"Connector pipeline: Failed to process connector"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
surveyId: survey.id,
|
||||
responseId: response.id,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
"Connector pipeline: Failed to handle connectors"
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,541 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
createConnectorWithMappings,
|
||||
deleteConnector,
|
||||
getConnectorsBySurveyId,
|
||||
getConnectorsWithMappings,
|
||||
updateConnector,
|
||||
updateConnectorWithMappings,
|
||||
} from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
connector: {
|
||||
findMany: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
connectorFormbricksMapping: {
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
connectorFieldMapping: {
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
const ENV_ID = "clxxxxxxxxxxxxxxxx001";
|
||||
const CONNECTOR_ID = "clxxxxxxxxxxxxxxxx002";
|
||||
const SURVEY_ID = "clxxxxxxxxxxxxxxxx003";
|
||||
const FRD_ID = "clxxxxxxxxxxxxxxxx004";
|
||||
const NOW = new Date("2026-02-24T10:00:00.000Z");
|
||||
|
||||
const mockConnector = {
|
||||
id: CONNECTOR_ID,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
name: "Test Connector",
|
||||
type: "formbricks_survey" as const,
|
||||
status: "active" as const,
|
||||
workspaceId: ENV_ID,
|
||||
lastSyncAt: null,
|
||||
createdBy: null,
|
||||
};
|
||||
|
||||
const mockConnectorWithMappingsFromDb = {
|
||||
...mockConnector,
|
||||
creator: null,
|
||||
formbricksMappings: [
|
||||
{
|
||||
id: "mapping-1",
|
||||
createdAt: NOW,
|
||||
connectorId: CONNECTOR_ID,
|
||||
workspaceId: ENV_ID,
|
||||
surveyId: SURVEY_ID,
|
||||
elementId: "el-1",
|
||||
hubFieldType: "text",
|
||||
customFieldLabel: null,
|
||||
},
|
||||
],
|
||||
fieldMappings: [],
|
||||
};
|
||||
|
||||
const mockConnectorWithMappings = {
|
||||
...mockConnector,
|
||||
creatorName: null,
|
||||
formbricksMappings: mockConnectorWithMappingsFromDb.formbricksMappings,
|
||||
fieldMappings: [],
|
||||
};
|
||||
|
||||
describe("getConnectorsWithMappings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns connectors for the given environment", async () => {
|
||||
vi.mocked(prisma.connector.findMany).mockResolvedValue([mockConnectorWithMappingsFromDb] as never);
|
||||
|
||||
const result = await getConnectorsWithMappings(ENV_ID);
|
||||
|
||||
expect(prisma.connector.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { workspaceId: ENV_ID },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(CONNECTOR_ID);
|
||||
});
|
||||
|
||||
test("applies pagination when page is provided", async () => {
|
||||
vi.mocked(prisma.connector.findMany).mockResolvedValue([] as never);
|
||||
|
||||
await getConnectorsWithMappings(ENV_ID, 2);
|
||||
|
||||
expect(prisma.connector.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
take: expect.any(Number),
|
||||
skip: expect.any(Number),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty array when no connectors exist", async () => {
|
||||
vi.mocked(prisma.connector.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const result = await getConnectorsWithMappings(ENV_ID);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.connector.findMany).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("connection error", {
|
||||
code: "P1001",
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getConnectorsWithMappings(ENV_ID)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConnectorsBySurveyId", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns active formbricks connectors linked to the survey", async () => {
|
||||
vi.mocked(prisma.connector.findMany).mockResolvedValue([mockConnectorWithMappingsFromDb] as never);
|
||||
|
||||
const result = await getConnectorsBySurveyId(SURVEY_ID);
|
||||
|
||||
expect(prisma.connector.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
formbricksMappings: { some: { surveyId: SURVEY_ID } },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("returns empty when no connectors match", async () => {
|
||||
vi.mocked(prisma.connector.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const result = await getConnectorsBySurveyId(SURVEY_ID);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.connector.findMany).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P1001",
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getConnectorsBySurveyId(SURVEY_ID)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConnector", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("updates connector name and returns the result", async () => {
|
||||
const updated = { ...mockConnector, name: "Renamed" };
|
||||
vi.mocked(prisma.connector.update).mockResolvedValue(updated as never);
|
||||
|
||||
const result = await updateConnector(CONNECTOR_ID, ENV_ID, { name: "Renamed" });
|
||||
|
||||
expect(prisma.connector.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: CONNECTOR_ID, workspaceId: ENV_ID },
|
||||
data: expect.objectContaining({ name: "Renamed" }),
|
||||
})
|
||||
);
|
||||
expect(result.name).toBe("Renamed");
|
||||
});
|
||||
|
||||
test("updates connector status", async () => {
|
||||
const updated = { ...mockConnector, status: "paused" };
|
||||
vi.mocked(prisma.connector.update).mockResolvedValue(updated as never);
|
||||
|
||||
const result = await updateConnector(CONNECTOR_ID, ENV_ID, { status: "paused" });
|
||||
expect(result.status).toBe("paused");
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when connector does not exist", async () => {
|
||||
vi.mocked(prisma.connector.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Not found", {
|
||||
code: "P2015",
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateConnector(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on generic Prisma error", async () => {
|
||||
vi.mocked(prisma.connector.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P1001",
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateConnector(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("rethrows non-Prisma errors", async () => {
|
||||
vi.mocked(prisma.connector.update).mockRejectedValue(new Error("unexpected"));
|
||||
|
||||
await expect(updateConnector(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow("unexpected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteConnector", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("deletes the connector and returns it", async () => {
|
||||
vi.mocked(prisma.connector.delete).mockResolvedValue(mockConnector as never);
|
||||
|
||||
const result = await deleteConnector(CONNECTOR_ID, ENV_ID);
|
||||
|
||||
expect(prisma.connector.delete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: CONNECTOR_ID, workspaceId: ENV_ID },
|
||||
})
|
||||
);
|
||||
expect(result.id).toBe(CONNECTOR_ID);
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when connector does not exist", async () => {
|
||||
vi.mocked(prisma.connector.delete).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Not found", {
|
||||
code: "P2015",
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(deleteConnector(CONNECTOR_ID, ENV_ID)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on generic Prisma error", async () => {
|
||||
vi.mocked(prisma.connector.delete).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P1001",
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(deleteConnector(CONNECTOR_ID, ENV_ID)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConnectorWithMappings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const setupTransaction = () => {
|
||||
const txMethods = {
|
||||
connector: {
|
||||
create: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
connectorFormbricksMapping: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
connectorFieldMapping: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (fn) => {
|
||||
return (fn as (tx: typeof txMethods) => Promise<unknown>)(txMethods);
|
||||
});
|
||||
|
||||
return txMethods;
|
||||
};
|
||||
|
||||
test("creates connector without mappings", async () => {
|
||||
const tx = setupTransaction();
|
||||
tx.connector.create.mockResolvedValue({ id: CONNECTOR_ID, workspaceId: ENV_ID });
|
||||
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappingsFromDb);
|
||||
|
||||
const result = await createConnectorWithMappings(ENV_ID, {
|
||||
name: "New",
|
||||
type: "formbricks_survey",
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
});
|
||||
|
||||
expect(tx.connector.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
name: "New",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: ENV_ID,
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(tx.connectorFormbricksMapping.create).not.toHaveBeenCalled();
|
||||
expect(tx.connectorFieldMapping.create).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockConnectorWithMappings);
|
||||
});
|
||||
|
||||
test("creates connector with formbricks mappings", async () => {
|
||||
const tx = setupTransaction();
|
||||
tx.connector.create.mockResolvedValue({ id: CONNECTOR_ID, workspaceId: ENV_ID });
|
||||
tx.connectorFormbricksMapping.create.mockResolvedValue({});
|
||||
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappingsFromDb);
|
||||
|
||||
await createConnectorWithMappings(
|
||||
ENV_ID,
|
||||
{ name: "FB", type: "formbricks_survey", feedbackRecordDirectoryId: FRD_ID },
|
||||
{
|
||||
type: "formbricks_survey",
|
||||
mappings: [
|
||||
{ surveyId: SURVEY_ID, elementId: "el-1", hubFieldType: "text" },
|
||||
{ surveyId: SURVEY_ID, elementId: "el-2", hubFieldType: "nps" },
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
expect(tx.connectorFormbricksMapping.create).toHaveBeenCalledTimes(2);
|
||||
expect(tx.connectorFormbricksMapping.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
connectorId: CONNECTOR_ID,
|
||||
workspaceId: ENV_ID,
|
||||
surveyId: SURVEY_ID,
|
||||
elementId: "el-1",
|
||||
hubFieldType: "text",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("creates connector with field mappings", async () => {
|
||||
const tx = setupTransaction();
|
||||
tx.connector.create.mockResolvedValue({ id: CONNECTOR_ID, workspaceId: ENV_ID });
|
||||
tx.connectorFieldMapping.create.mockResolvedValue({});
|
||||
tx.connector.findUniqueOrThrow.mockResolvedValue({
|
||||
...mockConnector,
|
||||
formbricksMappings: [],
|
||||
fieldMappings: [],
|
||||
});
|
||||
|
||||
await createConnectorWithMappings(
|
||||
ENV_ID,
|
||||
{ name: "CSV", type: "csv", feedbackRecordDirectoryId: FRD_ID },
|
||||
{
|
||||
type: "field",
|
||||
mappings: [{ sourceFieldId: "col-1", targetFieldId: "value_text" }],
|
||||
}
|
||||
);
|
||||
|
||||
expect(tx.connectorFieldMapping.create).toHaveBeenCalledTimes(1);
|
||||
expect(tx.connectorFieldMapping.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
connectorId: CONNECTOR_ID,
|
||||
workspaceId: ENV_ID,
|
||||
sourceFieldId: "col-1",
|
||||
targetFieldId: "value_text",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
createConnectorWithMappings(ENV_ID, {
|
||||
name: "Dup",
|
||||
type: "formbricks_survey",
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
})
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on generic Prisma error", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P1001",
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
createConnectorWithMappings(ENV_ID, { name: "Fail", type: "csv", feedbackRecordDirectoryId: FRD_ID })
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConnectorWithMappings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const setupTransaction = () => {
|
||||
const txMethods = {
|
||||
connector: {
|
||||
update: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
connectorFormbricksMapping: {
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
connectorFieldMapping: {
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (fn) => {
|
||||
return (fn as (tx: typeof txMethods) => Promise<unknown>)(txMethods);
|
||||
});
|
||||
|
||||
return txMethods;
|
||||
};
|
||||
|
||||
test("updates connector name without changing mappings", async () => {
|
||||
const tx = setupTransaction();
|
||||
tx.connector.update.mockResolvedValue(undefined);
|
||||
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappingsFromDb);
|
||||
|
||||
const result = await updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "Updated" });
|
||||
|
||||
expect(tx.connector.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: CONNECTOR_ID, workspaceId: ENV_ID },
|
||||
data: expect.objectContaining({ name: "Updated" }),
|
||||
})
|
||||
);
|
||||
expect(tx.connectorFormbricksMapping.deleteMany).not.toHaveBeenCalled();
|
||||
expect(tx.connectorFieldMapping.deleteMany).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockConnectorWithMappings);
|
||||
});
|
||||
|
||||
test("replaces formbricks mappings when provided", async () => {
|
||||
const tx = setupTransaction();
|
||||
tx.connector.update.mockResolvedValue(undefined);
|
||||
tx.connectorFormbricksMapping.deleteMany.mockResolvedValue({ count: 1 });
|
||||
tx.connectorFormbricksMapping.create.mockResolvedValue({});
|
||||
tx.connector.findUniqueOrThrow.mockResolvedValue(mockConnectorWithMappingsFromDb);
|
||||
|
||||
await updateConnectorWithMappings(
|
||||
CONNECTOR_ID,
|
||||
ENV_ID,
|
||||
{ name: "Updated" },
|
||||
{
|
||||
type: "formbricks_survey",
|
||||
mappings: [{ surveyId: SURVEY_ID, elementId: "el-new", hubFieldType: "nps" }],
|
||||
}
|
||||
);
|
||||
|
||||
expect(tx.connectorFormbricksMapping.deleteMany).toHaveBeenCalledWith({
|
||||
where: { connectorId: CONNECTOR_ID, workspaceId: ENV_ID },
|
||||
});
|
||||
expect(tx.connectorFormbricksMapping.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("replaces field mappings when provided", async () => {
|
||||
const tx = setupTransaction();
|
||||
tx.connector.update.mockResolvedValue(undefined);
|
||||
tx.connectorFieldMapping.deleteMany.mockResolvedValue({ count: 1 });
|
||||
tx.connectorFieldMapping.create.mockResolvedValue({});
|
||||
tx.connector.findUniqueOrThrow.mockResolvedValue({
|
||||
...mockConnector,
|
||||
formbricksMappings: [],
|
||||
fieldMappings: [],
|
||||
});
|
||||
|
||||
await updateConnectorWithMappings(
|
||||
CONNECTOR_ID,
|
||||
ENV_ID,
|
||||
{ name: "CSV Updated" },
|
||||
{
|
||||
type: "field",
|
||||
mappings: [{ sourceFieldId: "col-x", targetFieldId: "value_number" }],
|
||||
}
|
||||
);
|
||||
|
||||
expect(tx.connectorFieldMapping.deleteMany).toHaveBeenCalledWith({
|
||||
where: { connectorId: CONNECTOR_ID, workspaceId: ENV_ID },
|
||||
});
|
||||
expect(tx.connectorFieldMapping.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when connector does not exist", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Not found", {
|
||||
code: "P2015",
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
|
||||
ResourceNotFoundError
|
||||
);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on generic Prisma error", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P1001",
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import {
|
||||
TConnector,
|
||||
TConnectorCreateInput,
|
||||
TConnectorFieldMappingCreateInput,
|
||||
TConnectorFormbricksMappingCreateInput,
|
||||
TConnectorUpdateInput,
|
||||
TConnectorWithMappings,
|
||||
ZConnectorCreateInput,
|
||||
ZConnectorUpdateInput,
|
||||
} from "@formbricks/types/connector";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
const selectConnectorWithMappings = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
type: true,
|
||||
status: true,
|
||||
workspaceId: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
lastSyncAt: true,
|
||||
createdBy: true,
|
||||
creator: { select: { name: true } },
|
||||
formbricksMappings: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
connectorId: true,
|
||||
workspaceId: true,
|
||||
surveyId: true,
|
||||
elementId: true,
|
||||
hubFieldType: true,
|
||||
customFieldLabel: true,
|
||||
},
|
||||
},
|
||||
fieldMappings: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
connectorId: true,
|
||||
workspaceId: true,
|
||||
sourceFieldId: true,
|
||||
targetFieldId: true,
|
||||
staticValue: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ConnectorSelect;
|
||||
|
||||
const selectConnector = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
type: true,
|
||||
status: true,
|
||||
workspaceId: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
lastSyncAt: true,
|
||||
createdBy: true,
|
||||
} satisfies Prisma.ConnectorSelect;
|
||||
|
||||
type PrismaConnectorWithCreator = Prisma.ConnectorGetPayload<{ select: typeof selectConnectorWithMappings }>;
|
||||
|
||||
const mapConnectorWithMappings = (connector: PrismaConnectorWithCreator): TConnectorWithMappings => {
|
||||
const { creator, ...rest } = connector;
|
||||
return { ...rest, creatorName: creator?.name ?? null } as TConnectorWithMappings;
|
||||
};
|
||||
|
||||
export const getConnectorsWithMappings = reactCache(
|
||||
async (workspaceId: string, page?: number): Promise<TConnectorWithMappings[]> => {
|
||||
validateInputs([workspaceId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const connectors = await prisma.connector.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
select: selectConnectorWithMappings,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
});
|
||||
|
||||
return connectors.map(mapConnectorWithMappings);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getConnectorWithMappingsById = reactCache(
|
||||
async (connectorId: string, workspaceId: string): Promise<TConnectorWithMappings | null> => {
|
||||
validateInputs([connectorId, ZId], [workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
const connector = await prisma.connector.findUnique({
|
||||
where: {
|
||||
id: connectorId,
|
||||
workspaceId,
|
||||
},
|
||||
select: selectConnectorWithMappings,
|
||||
});
|
||||
|
||||
return connector ? mapConnectorWithMappings(connector) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getConnectorsBySurveyId = reactCache(
|
||||
async (surveyId: string): Promise<TConnectorWithMappings[]> => {
|
||||
validateInputs([surveyId, ZId]);
|
||||
|
||||
try {
|
||||
const connectors = await prisma.connector.findMany({
|
||||
where: {
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
formbricksMappings: {
|
||||
some: {
|
||||
surveyId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: selectConnectorWithMappings,
|
||||
});
|
||||
|
||||
return connectors.map(mapConnectorWithMappings);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateConnector = async (
|
||||
connectorId: string,
|
||||
workspaceId: string,
|
||||
data: TConnectorUpdateInput
|
||||
): Promise<TConnector> => {
|
||||
validateInputs([connectorId, ZId], [data, ZConnectorUpdateInput], [workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
const connector = await prisma.connector.update({
|
||||
where: {
|
||||
id: connectorId,
|
||||
workspaceId,
|
||||
},
|
||||
data: {
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
lastSyncAt: data.lastSyncAt,
|
||||
},
|
||||
select: selectConnector,
|
||||
});
|
||||
|
||||
return connector as TConnector;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.RecordDoesNotExist) {
|
||||
throw new ResourceNotFoundError("Connector", connectorId);
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteConnector = async (connectorId: string, workspaceId: string): Promise<TConnector> => {
|
||||
validateInputs([connectorId, ZId], [workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
const connector = await prisma.connector.delete({
|
||||
where: {
|
||||
id: connectorId,
|
||||
workspaceId,
|
||||
},
|
||||
select: selectConnector,
|
||||
});
|
||||
|
||||
return connector as TConnector;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.RecordDoesNotExist) {
|
||||
throw new ResourceNotFoundError("Connector", connectorId);
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// -- Composite functions --
|
||||
|
||||
export type TFormbricksMappingsInput = {
|
||||
type: "formbricks_survey";
|
||||
mappings: TConnectorFormbricksMappingCreateInput[];
|
||||
};
|
||||
|
||||
export type TFieldMappingsInput = {
|
||||
type: "field";
|
||||
mappings: TConnectorFieldMappingCreateInput[];
|
||||
};
|
||||
|
||||
export type TMappingsInput = TFormbricksMappingsInput | TFieldMappingsInput;
|
||||
|
||||
export const createConnectorWithMappings = async (
|
||||
workspaceId: string,
|
||||
data: TConnectorCreateInput,
|
||||
mappingsInput?: TMappingsInput
|
||||
): Promise<TConnectorWithMappings> => {
|
||||
validateInputs([workspaceId, ZId], [data, ZConnectorCreateInput]);
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const connector = await tx.connector.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
workspaceId,
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
createdBy: data.createdBy,
|
||||
},
|
||||
});
|
||||
|
||||
if (mappingsInput?.type === "formbricks_survey") {
|
||||
await Promise.all(
|
||||
mappingsInput.mappings.map((mapping) =>
|
||||
tx.connectorFormbricksMapping.create({
|
||||
data: {
|
||||
connectorId: connector.id,
|
||||
workspaceId,
|
||||
surveyId: mapping.surveyId,
|
||||
elementId: mapping.elementId,
|
||||
hubFieldType: mapping.hubFieldType,
|
||||
customFieldLabel: mapping.customFieldLabel,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
} else if (mappingsInput?.type === "field") {
|
||||
await Promise.all(
|
||||
mappingsInput.mappings.map((mapping) =>
|
||||
tx.connectorFieldMapping.create({
|
||||
data: {
|
||||
connectorId: connector.id,
|
||||
workspaceId,
|
||||
sourceFieldId: mapping.sourceFieldId,
|
||||
targetFieldId: mapping.targetFieldId,
|
||||
staticValue: mapping.staticValue,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return tx.connector.findUniqueOrThrow({
|
||||
where: { id: connector.id },
|
||||
select: selectConnectorWithMappings,
|
||||
});
|
||||
});
|
||||
|
||||
return mapConnectorWithMappings(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError(`Connector with name ${data.name} already exists`);
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateConnectorWithMappings = async (
|
||||
connectorId: string,
|
||||
workspaceId: string,
|
||||
data: TConnectorUpdateInput,
|
||||
mappingsInput?: TMappingsInput
|
||||
): Promise<TConnectorWithMappings> => {
|
||||
validateInputs([connectorId, ZId], [data, ZConnectorUpdateInput], [workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.connector.update({
|
||||
where: { id: connectorId, workspaceId },
|
||||
data: {
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
lastSyncAt: data.lastSyncAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (mappingsInput?.type === "formbricks_survey") {
|
||||
await tx.connectorFormbricksMapping.deleteMany({
|
||||
where: { connectorId, workspaceId },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
mappingsInput.mappings.map((mapping) =>
|
||||
tx.connectorFormbricksMapping.create({
|
||||
data: {
|
||||
connectorId,
|
||||
workspaceId,
|
||||
surveyId: mapping.surveyId,
|
||||
elementId: mapping.elementId,
|
||||
hubFieldType: mapping.hubFieldType,
|
||||
customFieldLabel: mapping.customFieldLabel,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
} else if (mappingsInput?.type === "field") {
|
||||
await tx.connectorFieldMapping.deleteMany({
|
||||
where: { connectorId, workspaceId },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
mappingsInput.mappings.map((mapping) =>
|
||||
tx.connectorFieldMapping.create({
|
||||
data: {
|
||||
connectorId,
|
||||
workspaceId,
|
||||
sourceFieldId: mapping.sourceFieldId,
|
||||
targetFieldId: mapping.targetFieldId,
|
||||
staticValue: mapping.staticValue,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return tx.connector.findUniqueOrThrow({
|
||||
where: { id: connectorId },
|
||||
select: selectConnectorWithMappings,
|
||||
});
|
||||
});
|
||||
|
||||
return mapConnectorWithMappings(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.RecordDoesNotExist) {
|
||||
throw new ResourceNotFoundError("Connector", connectorId);
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,334 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorFormbricksMapping } from "@formbricks/types/connector";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { transformResponseToFeedbackRecords } from "./transform";
|
||||
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (_val: Record<string, string>, _lang: string) => _val?.default ?? "",
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/types/surveys/validation", () => ({
|
||||
getTextContent: (str: string) => str,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/utils", () => ({
|
||||
getElementsFromBlocks: (blocks: Array<{ elements: unknown[] }>) =>
|
||||
blocks.flatMap((block) => block.elements),
|
||||
}));
|
||||
|
||||
const NOW = new Date("2026-02-24T10:00:00.000Z");
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Product Feedback",
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{ id: "el-text", type: "openText", headline: { default: "How can we improve?" } },
|
||||
{ id: "el-nps", type: "nps", headline: { default: "How likely to recommend?" } },
|
||||
{ id: "el-rating", type: "rating", headline: { default: "Rate your experience" } },
|
||||
{ id: "el-date", type: "date", headline: { default: "When did you visit?" } },
|
||||
{ id: "el-bool", type: "consent", headline: { default: "Do you agree?" } },
|
||||
{
|
||||
id: "el-multi",
|
||||
type: "multipleChoiceMulti",
|
||||
headline: { default: "Select features" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockResponse = {
|
||||
id: "resp-1",
|
||||
createdAt: NOW,
|
||||
data: {
|
||||
"el-text": "Great product!",
|
||||
"el-nps": 9,
|
||||
"el-rating": 4,
|
||||
"el-date": "2026-01-15",
|
||||
"el-bool": "true",
|
||||
"el-multi": ["feat-a", "feat-b"],
|
||||
},
|
||||
language: "en",
|
||||
contact: { userId: "user-42" },
|
||||
} as unknown as TResponse;
|
||||
|
||||
const createMapping = (
|
||||
overrides: Partial<TConnectorFormbricksMapping> &
|
||||
Pick<TConnectorFormbricksMapping, "elementId" | "hubFieldType">
|
||||
): TConnectorFormbricksMapping => ({
|
||||
id: `mapping-${overrides.elementId}`,
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
surveyId: "survey-1",
|
||||
customFieldLabel: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const allMappings: TConnectorFormbricksMapping[] = [
|
||||
createMapping({ elementId: "el-text", hubFieldType: "text" }),
|
||||
createMapping({ elementId: "el-nps", hubFieldType: "nps" }),
|
||||
createMapping({ elementId: "el-rating", hubFieldType: "rating" }),
|
||||
createMapping({ elementId: "el-date", hubFieldType: "date" }),
|
||||
createMapping({ elementId: "el-bool", hubFieldType: "boolean" }),
|
||||
createMapping({ elementId: "el-multi", hubFieldType: "categorical" }),
|
||||
];
|
||||
|
||||
describe("transformResponseToFeedbackRecords", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns empty array when response has no data", () => {
|
||||
const emptyResponse = { ...mockResponse, data: null } as unknown as TResponse;
|
||||
const result = transformResponseToFeedbackRecords(emptyResponse, mockSurvey, allMappings);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns empty array when no mappings match the survey", () => {
|
||||
const otherSurveyMappings = allMappings.map((m) => ({ ...m, surveyId: "other-survey" }));
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, otherSurveyMappings);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips elements with empty string values", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-text": "" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips elements with undefined values", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-nps": 9 },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [
|
||||
createMapping({ elementId: "el-text", hubFieldType: "text" }),
|
||||
createMapping({ elementId: "el-nps", hubFieldType: "nps" }),
|
||||
];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].field_id).toBe("el-nps");
|
||||
});
|
||||
|
||||
test("transforms text field correctly", () => {
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
source_type: "formbricks_survey",
|
||||
field_id: "el-text",
|
||||
field_type: "text",
|
||||
field_label: "How can we improve?",
|
||||
source_id: "survey-1",
|
||||
source_name: "Product Feedback",
|
||||
value_text: "Great product!",
|
||||
language: "en",
|
||||
user_identifier: "user-42",
|
||||
});
|
||||
});
|
||||
|
||||
test("transforms nps field correctly", () => {
|
||||
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_number).toBe(9);
|
||||
expect(result[0].field_type).toBe("nps");
|
||||
});
|
||||
|
||||
test("transforms rating field correctly", () => {
|
||||
const mappings = [createMapping({ elementId: "el-rating", hubFieldType: "rating" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_number).toBe(4);
|
||||
});
|
||||
|
||||
test("transforms date field to ISO string", () => {
|
||||
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_date).toBe(new Date("2026-01-15").toISOString());
|
||||
});
|
||||
|
||||
test("transforms boolean field correctly", () => {
|
||||
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_boolean).toBe(true);
|
||||
});
|
||||
|
||||
test("transforms categorical (multi-select) field to comma-separated text", () => {
|
||||
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_text).toBe("feat-a, feat-b");
|
||||
});
|
||||
|
||||
test("uses customFieldLabel when provided", () => {
|
||||
const mappings = [
|
||||
createMapping({ elementId: "el-text", hubFieldType: "text", customFieldLabel: "Custom Label" }),
|
||||
];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
expect(result[0].field_label).toBe("Custom Label");
|
||||
});
|
||||
|
||||
test("sets collected_at from response createdAt", () => {
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
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");
|
||||
expect(result[0].tenant_id).toBe("tenant-abc");
|
||||
});
|
||||
|
||||
test("omits tenant_id when not provided", () => {
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
expect(result[0].tenant_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test("omits language when response language is 'default'", () => {
|
||||
const response = { ...mockResponse, language: "default" } as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result[0].language).toBeUndefined();
|
||||
});
|
||||
|
||||
test("omits user_identifier when contact has no userId", () => {
|
||||
const response = { ...mockResponse, contact: null } as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result[0].user_identifier).toBeUndefined();
|
||||
});
|
||||
|
||||
test("transforms all mappings in a single call", () => {
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings);
|
||||
expect(result).toHaveLength(6);
|
||||
const fieldIds = result.map((r) => r.field_id);
|
||||
expect(fieldIds).toEqual(["el-text", "el-nps", "el-rating", "el-date", "el-bool", "el-multi"]);
|
||||
});
|
||||
|
||||
test("falls back to 'Untitled' for element with no headline", () => {
|
||||
const survey = {
|
||||
...mockSurvey,
|
||||
blocks: [{ elements: [{ id: "el-bare", type: "openText" }] }],
|
||||
} as unknown as TSurvey;
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-bare": "some text" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-bare", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, survey, mappings);
|
||||
expect(result[0].field_label).toBe("Untitled");
|
||||
});
|
||||
|
||||
describe("convertValueToHubFields edge cases", () => {
|
||||
test("parses numeric string for nps field", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-nps": "7" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result[0].value_number).toBe(7);
|
||||
});
|
||||
|
||||
test("returns empty fields for non-parseable numeric string", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-nps": "not-a-number" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result[0].value_number).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles object value for text field", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-text": { nested: "value" } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result[0].value_text).toBe(JSON.stringify({ nested: "value" }));
|
||||
});
|
||||
|
||||
test("handles invalid date string gracefully", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-date": "not-a-date" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result[0].value_date).toBeUndefined();
|
||||
});
|
||||
|
||||
test("converts boolean string '1' to true", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-bool": "1" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result[0].value_boolean).toBe(true);
|
||||
});
|
||||
|
||||
test("converts boolean string 'false' to false", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-bool": "false" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result[0].value_boolean).toBe(false);
|
||||
});
|
||||
|
||||
test("handles array value for text field", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-text": ["a", "b", "c"] },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result[0].value_text).toBe("a, b, c");
|
||||
});
|
||||
|
||||
test("handles single string value for categorical field", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-multi": "single-choice" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result[0].value_text).toBe("single-choice");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import "server-only";
|
||||
import { TConnectorFormbricksMapping, THubFieldType } from "@formbricks/types/connector";
|
||||
import { TResponse, TResponseData, TResponseDataValue } from "@formbricks/types/responses";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import type { FeedbackRecordCreateParams } from "@/modules/hub";
|
||||
|
||||
const getHeadlineFromElement = (element?: TSurveyElement): string => {
|
||||
if (!element?.headline) return "Untitled";
|
||||
const raw = getLocalizedValue(element.headline, "default");
|
||||
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];
|
||||
}
|
||||
|
||||
const convertValueToHubFields = (
|
||||
value: TResponseDataValue,
|
||||
hubFieldType: THubFieldType
|
||||
): Partial<
|
||||
Pick<FeedbackRecordCreateParams, "value_text" | "value_number" | "value_boolean" | "value_date">
|
||||
> => {
|
||||
if (value === undefined || value === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (hubFieldType) {
|
||||
case "text":
|
||||
if (typeof value === "string") return { value_text: value };
|
||||
if (Array.isArray(value)) return { value_text: value.join(", ") };
|
||||
if (typeof value === "object") return { value_text: JSON.stringify(value) };
|
||||
return { value_text: String(value) };
|
||||
|
||||
case "number":
|
||||
case "rating":
|
||||
case "nps":
|
||||
case "csat":
|
||||
case "ces":
|
||||
if (typeof value === "number") return { value_number: value };
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (!Number.isNaN(parsed)) return { value_number: parsed };
|
||||
}
|
||||
return {};
|
||||
|
||||
case "boolean":
|
||||
if (typeof value === "boolean") return { value_boolean: value };
|
||||
if (typeof value === "string") {
|
||||
return { value_boolean: value.toLowerCase() === "true" || value === "1" };
|
||||
}
|
||||
return {};
|
||||
|
||||
case "date":
|
||||
if (typeof value === "string") {
|
||||
const date = new Date(value);
|
||||
if (!Number.isNaN(date.getTime())) return { value_date: date.toISOString() };
|
||||
}
|
||||
if (value instanceof Date) return { value_date: value.toISOString() };
|
||||
return {};
|
||||
|
||||
case "categorical":
|
||||
if (typeof value === "string") return { value_text: value };
|
||||
if (Array.isArray(value)) return { value_text: value.join(", ") };
|
||||
return { value_text: String(value) };
|
||||
|
||||
default:
|
||||
return { value_text: typeof value === "string" ? value : String(value) };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform a Formbricks survey response into FeedbackRecord payloads.
|
||||
* Called from the pipeline handler when a response is created/finished.
|
||||
*/
|
||||
export function transformResponseToFeedbackRecords(
|
||||
response: TResponse,
|
||||
survey: TSurvey,
|
||||
mappings: TConnectorFormbricksMapping[],
|
||||
tenantId: string
|
||||
): FeedbackRecordCreateParams[] {
|
||||
const responseData = response.data;
|
||||
if (!responseData) return [];
|
||||
|
||||
const surveyMappings = mappings.filter((m) => m.surveyId === survey.id);
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const elementMap = new Map(elements.map((el) => [el.id, el]));
|
||||
const feedbackRecords: FeedbackRecordCreateParams[] = [];
|
||||
|
||||
for (const mapping of surveyMappings) {
|
||||
const value = extractResponseValue(responseData, mapping.elementId);
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
|
||||
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(elementMap.get(mapping.elementId));
|
||||
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
|
||||
|
||||
const feedbackRecord = {
|
||||
collected_at: getCollectedAt(response),
|
||||
source_type: "formbricks_survey",
|
||||
submission_id: response.id,
|
||||
tenant_id: tenantId,
|
||||
field_id: mapping.elementId,
|
||||
field_type: mapping.hubFieldType,
|
||||
source_id: survey.id,
|
||||
source_name: survey.name,
|
||||
field_label: fieldLabel,
|
||||
...(response.language && response.language !== "default" ? { language: response.language } : {}),
|
||||
...(response.contact?.userId ? { user_identifier: response.contact.userId } : {}),
|
||||
...valueFields,
|
||||
};
|
||||
|
||||
feedbackRecords.push(feedbackRecord as FeedbackRecordCreateParams);
|
||||
}
|
||||
|
||||
return feedbackRecords;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export const GITHUB_ID = env.GITHUB_ID;
|
||||
export const GITHUB_SECRET = env.GITHUB_SECRET;
|
||||
export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID;
|
||||
export const GOOGLE_CLIENT_SECRET = env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
export const HUB_API_URL = env.HUB_API_URL;
|
||||
export const HUB_API_KEY = env.HUB_API_KEY;
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ export type AuditLoggingCtx = {
|
||||
quotaId?: string;
|
||||
teamId?: string;
|
||||
integrationId?: string;
|
||||
chartId?: string;
|
||||
dashboardId?: string;
|
||||
dashboardWidgetId?: string;
|
||||
feedbackRecordDirectoryId?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getFormattedErrorMessage,
|
||||
getOrganizationIdFromActionClassId,
|
||||
getOrganizationIdFromApiKeyId,
|
||||
getOrganizationIdFromConnectorId,
|
||||
getOrganizationIdFromContactId,
|
||||
getOrganizationIdFromIntegrationId,
|
||||
getOrganizationIdFromInviteId,
|
||||
@@ -46,6 +47,7 @@ vi.mock("@/lib/utils/services", () => ({
|
||||
getLanguage: vi.fn(),
|
||||
getTeam: vi.fn(),
|
||||
getTag: vi.fn(),
|
||||
getConnector: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Helper Utilities", () => {
|
||||
@@ -326,6 +328,27 @@ describe("Helper Utilities", () => {
|
||||
const orgId = await getOrganizationIdFromQuotaId("quota1");
|
||||
expect(orgId).toBe("org1");
|
||||
});
|
||||
|
||||
test("getOrganizationIdFromConnectorId returns organization ID through workspace", async () => {
|
||||
vi.mocked(services.getConnector).mockResolvedValueOnce({
|
||||
workspaceId: "workspace1",
|
||||
});
|
||||
vi.mocked(services.getWorkspace).mockResolvedValueOnce({
|
||||
organizationId: "org1",
|
||||
});
|
||||
|
||||
const orgId = await getOrganizationIdFromConnectorId("connector1");
|
||||
expect(orgId).toBe("org1");
|
||||
expect(services.getConnector).toHaveBeenCalledWith("connector1");
|
||||
expect(services.getWorkspace).toHaveBeenCalledWith("workspace1");
|
||||
});
|
||||
|
||||
test("getOrganizationIdFromConnectorId throws error when connector not found", async () => {
|
||||
vi.mocked(services.getConnector).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getOrganizationIdFromConnectorId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
|
||||
expect(services.getConnector).toHaveBeenCalledWith("nonexistent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace ID retrieval functions", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
getActionClass,
|
||||
getApiKey,
|
||||
getConnector,
|
||||
getContact,
|
||||
getIntegration,
|
||||
getInvite,
|
||||
@@ -272,3 +273,13 @@ export const isStringMatch = (query: string, value: string): boolean => {
|
||||
|
||||
return valueModified.includes(queryModified);
|
||||
};
|
||||
|
||||
// Connector helpers
|
||||
export const getOrganizationIdFromConnectorId = async (connectorId: string) => {
|
||||
const connector = await getConnector(connectorId);
|
||||
if (!connector) {
|
||||
throw new ResourceNotFoundError("connector", connectorId);
|
||||
}
|
||||
|
||||
return await getOrganizationIdFromWorkspaceId(connector.workspaceId);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import { getQuota as getQuotaService } from "@/modules/ee/quotas/lib/quotas";
|
||||
import {
|
||||
getActionClass,
|
||||
getApiKey,
|
||||
getConnector,
|
||||
getContact,
|
||||
getIntegration,
|
||||
getInvite,
|
||||
@@ -89,6 +90,9 @@ vi.mock("@formbricks/database", () => ({
|
||||
contact: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
connector: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
segment: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -556,4 +560,46 @@ describe("Service Functions", () => {
|
||||
await expect(getSegment(segmentId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConnector", () => {
|
||||
const connectorId = "connector123";
|
||||
|
||||
test("returns the connector when found", async () => {
|
||||
const mockConnector = { workspaceId: "ws123" };
|
||||
vi.mocked(prisma.connector.findUnique).mockResolvedValue(mockConnector);
|
||||
|
||||
const result = await getConnector(connectorId);
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(prisma.connector.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: connectorId },
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
expect(result).toEqual(mockConnector);
|
||||
});
|
||||
|
||||
test("returns null when connector not found", async () => {
|
||||
vi.mocked(prisma.connector.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getConnector(connectorId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("throws DatabaseError when Prisma throws a known request error", async () => {
|
||||
vi.mocked(prisma.connector.findUnique).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Error", {
|
||||
code: "P2002",
|
||||
clientVersion: "4.7.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getConnector(connectorId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("rethrows unknown errors", async () => {
|
||||
const unknownError = new Error("Something unexpected");
|
||||
vi.mocked(prisma.connector.findUnique).mockRejectedValue(unknownError);
|
||||
|
||||
await expect(getConnector(connectorId)).rejects.toThrow(unknownError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -313,3 +313,25 @@ export const getSegment = reactCache(async (segmentId: string): Promise<{ worksp
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const getConnector = reactCache(
|
||||
async (connectorId: string): Promise<{ workspaceId: string } | null> => {
|
||||
validateInputs([connectorId, ZId]);
|
||||
try {
|
||||
const connector = await prisma.connector.findUnique({
|
||||
where: {
|
||||
id: connectorId,
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
|
||||
return connector;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
+1888
-1576
File diff suppressed because it is too large
Load Diff
+316
-2
@@ -125,6 +125,9 @@
|
||||
"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",
|
||||
"add_logo": "Add logo",
|
||||
"add_member": "Add member",
|
||||
@@ -136,6 +139,7 @@
|
||||
"allow": "Allow",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
|
||||
"analysis": "Analysis",
|
||||
"and": "And",
|
||||
"anonymous": "Anonymous",
|
||||
"api_keys": "API Keys",
|
||||
@@ -154,6 +158,8 @@
|
||||
"centered_modal": "Centered Modal",
|
||||
"change_organization": "Change organization",
|
||||
"change_workspace": "Change workspace",
|
||||
"chart": "Chart",
|
||||
"charts": "Charts",
|
||||
"choices": "Choices",
|
||||
"choose_organization": "Choose organization",
|
||||
"choose_workspace": "Choose workspace",
|
||||
@@ -167,7 +173,7 @@
|
||||
"code": "Code",
|
||||
"collapse_rows": "Collapse rows",
|
||||
"completed": "Completed",
|
||||
"configuration": "Configuration",
|
||||
"configuration": "Configure",
|
||||
"confirm": "Confirm",
|
||||
"connect": "Connect",
|
||||
"connect_formbricks": "Connect Formbricks",
|
||||
@@ -186,6 +192,7 @@
|
||||
"count_questions": "{count, plural, one {{count} question} other {{count} questions}}",
|
||||
"count_responses": "{count, plural, one {{count} response} other {{count} responses}}",
|
||||
"count_selections": "{count, plural, one {{count} selection} other {{count} selections}}",
|
||||
"create": "Create",
|
||||
"create_new_organization": "Create new organization",
|
||||
"create_segment": "Create segment",
|
||||
"create_survey": "Create survey",
|
||||
@@ -195,6 +202,8 @@
|
||||
"created_by": "Created by",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Dark overlay",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Date",
|
||||
"days": "days",
|
||||
"default": "Default",
|
||||
@@ -218,6 +227,7 @@
|
||||
"edit": "Edit",
|
||||
"elements": "Elements",
|
||||
"email": "Email",
|
||||
"enable": "Enable",
|
||||
"ending_card": "Ending card",
|
||||
"enter_url": "Enter URL",
|
||||
"enterprise_license": "Enterprise License",
|
||||
@@ -232,6 +242,7 @@
|
||||
"failed_to_copy_to_clipboard": "Failed to copy to clipboard",
|
||||
"failed_to_load_organizations": "Failed to load organizations",
|
||||
"failed_to_load_workspaces": "Failed to load workspaces",
|
||||
"failed_to_parse_csv": "Failed to parse CSV",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "First Name",
|
||||
@@ -246,6 +257,7 @@
|
||||
"hidden": "Hidden",
|
||||
"hidden_field": "Hidden field",
|
||||
"hidden_fields": "Hidden fields",
|
||||
"hide": "Hide",
|
||||
"hide_column": "Hide column",
|
||||
"id": "ID",
|
||||
"image": "Image",
|
||||
@@ -292,6 +304,7 @@
|
||||
"mobile_overlay_surveys_look_good": "Do not worry – your surveys look great on every device and screen size!",
|
||||
"mobile_overlay_title": "Oops, tiny screen detected!",
|
||||
"months": "months",
|
||||
"more_options": "More options",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
@@ -300,8 +313,10 @@
|
||||
"new": "New",
|
||||
"new_version_available": "Formbricks {version} is here. Upgrade now!",
|
||||
"next": "Next",
|
||||
"no": "No",
|
||||
"no_actions_found": "No actions found",
|
||||
"no_background_image_found": "No background image found.",
|
||||
"no_changes": "No changes",
|
||||
"no_code": "No code",
|
||||
"no_files_uploaded": "No files were uploaded",
|
||||
"no_overlay": "No overlay",
|
||||
@@ -323,6 +338,7 @@
|
||||
"on": "On",
|
||||
"only_one_file_allowed": "Only one file is allowed",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
|
||||
"open_options": "Open options",
|
||||
"option_id": "Option ID",
|
||||
"option_ids": "Option IDs",
|
||||
"optional": "Optional",
|
||||
@@ -365,6 +381,7 @@
|
||||
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
|
||||
"read_docs": "Read docs",
|
||||
"recipients": "Recipients",
|
||||
"refresh": "Refresh",
|
||||
"remove": "Remove",
|
||||
"remove_from_team": "Remove from team",
|
||||
"reorder_and_hide_columns": "Reorder and hide columns",
|
||||
@@ -376,6 +393,7 @@
|
||||
"response_id": "Response ID",
|
||||
"responses": "Responses",
|
||||
"restart": "Restart",
|
||||
"retry": "Retry",
|
||||
"role": "Role",
|
||||
"saas": "SaaS",
|
||||
"sales": "Sales",
|
||||
@@ -384,6 +402,7 @@
|
||||
"save_changes": "Save changes",
|
||||
"saving": "Saving",
|
||||
"search": "Search",
|
||||
"search_charts": "Search charts...",
|
||||
"security": "Security",
|
||||
"segment": "Segment",
|
||||
"segments": "Segments",
|
||||
@@ -447,6 +466,7 @@
|
||||
"trial_one_day_remaining": "1 day left in your trial",
|
||||
"try_again": "Try again",
|
||||
"type": "Type",
|
||||
"unify": "Unify",
|
||||
"unknown_survey": "Unknown survey",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.",
|
||||
"update": "Update",
|
||||
@@ -464,6 +484,7 @@
|
||||
"variables": "Variables",
|
||||
"verified_email": "Verified Email",
|
||||
"video": "Video",
|
||||
"view": "View",
|
||||
"warning": "Warning",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We were unable to verify your license because the license server is unreachable.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +503,7 @@
|
||||
"workspace_name_placeholder": "e.g. Formbricks",
|
||||
"workspaces": "Workspaces",
|
||||
"years": "years",
|
||||
"yes": "Yes",
|
||||
"you": "You",
|
||||
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
|
||||
@@ -1610,6 +1632,183 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Your survey would be shown on this URL.",
|
||||
"your_survey_would_not_be_shown": "Your survey would not be shown."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "OR",
|
||||
"add_chart_to_dashboard": "Add Chart to Dashboard",
|
||||
"add_chart_to_dashboard_description": "Select a dashboard to add this chart to. The chart will be saved automatically.",
|
||||
"add_filter": "Add filter",
|
||||
"add_to_dashboard": "Add to Dashboard",
|
||||
"advanced_chart_builder_config_prompt": "Configure your chart and click \"Run Query\" to preview",
|
||||
"ai_query_placeholder": "e.g. How many users signed up last week?",
|
||||
"ai_query_section_description": "Describe what you want to see and let AI build the chart.",
|
||||
"ai_query_section_title": "Ask your data",
|
||||
"and_filter_logic": "AND",
|
||||
"apply_changes": "Apply Changes",
|
||||
"chart": "Chart",
|
||||
"chart_added_to_dashboard": "Chart added to dashboard!",
|
||||
"chart_builder_choose_chart_type": "Choose chart type",
|
||||
"chart_data": "Chart Data",
|
||||
"chart_data_tab": "Data",
|
||||
"chart_deleted_successfully": "Chart deleted successfully",
|
||||
"chart_deletion_error": "Failed to delete chart",
|
||||
"chart_duplicated_successfully": "Chart duplicated successfully",
|
||||
"chart_duplication_error": "Failed to duplicate chart",
|
||||
"chart_name": "Chart Name",
|
||||
"chart_name_placeholder": "Chart name",
|
||||
"chart_preview": "Chart Preview",
|
||||
"chart_render_error": "Something went wrong while rendering this chart.",
|
||||
"chart_saved_successfully": "Chart saved successfully!",
|
||||
"chart_type_area": "Area Chart",
|
||||
"chart_type_bar": "Bar Chart",
|
||||
"chart_type_big_number": "Big Number",
|
||||
"chart_type_line": "Line Chart",
|
||||
"chart_type_not_supported": "Chart type \"{{chartType}}\" not yet supported",
|
||||
"chart_type_pie": "Pie Chart",
|
||||
"chart_updated_successfully": "Chart updated successfully!",
|
||||
"configure_description": "Modify the chart type and other settings for this visualization.",
|
||||
"configure_title": "Configure Chart",
|
||||
"configure_type_label": "Chart Type",
|
||||
"contains": "contains",
|
||||
"create_chart": "Create chart",
|
||||
"create_chart_description": "Use AI to generate a chart or build one manually.",
|
||||
"create_chart_with_ai": "Create chart with AI",
|
||||
"custom_range": "Custom Range",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_select_placeholder": "Select a dashboard",
|
||||
"data_label": "Data",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Last 30 days",
|
||||
"date_preset_last_7_days": "Last 7 days",
|
||||
"date_preset_last_month": "Last month",
|
||||
"date_preset_this_month": "This month",
|
||||
"date_preset_this_quarter": "This quarter",
|
||||
"date_preset_this_year": "This year",
|
||||
"date_preset_today": "Today",
|
||||
"date_preset_yesterday": "Yesterday",
|
||||
"date_range": "Date Range",
|
||||
"delete_chart_confirmation": "Are you sure you want to delete this chart?",
|
||||
"dimensions": "Dimensions",
|
||||
"dimensions_toggle_description": "Group data by sentiment, question type, and other dimensions.",
|
||||
"edit_chart_description": "View and edit your chart configuration.",
|
||||
"edit_chart_title": "Edit Chart",
|
||||
"enable_time_dimension": "Enable Time Dimension",
|
||||
"end_date": "End date",
|
||||
"enter_a_name_for_your_chart": "Enter a name for your chart to save it.",
|
||||
"enter_value": "Enter value",
|
||||
"equals": "equals",
|
||||
"failed_to_add_chart_to_dashboard": "Failed to add chart to dashboard",
|
||||
"failed_to_execute_query": "Failed to execute query",
|
||||
"failed_to_load_chart": "Failed to load chart",
|
||||
"failed_to_load_chart_data": "Failed to load chart data",
|
||||
"failed_to_save_chart": "Failed to save chart",
|
||||
"field": "Field",
|
||||
"field_label_average_score": "Average Score",
|
||||
"field_label_collected_at": "Collected At",
|
||||
"field_label_count": "Count",
|
||||
"field_label_detractor_count": "Detractor Count",
|
||||
"field_label_emotion": "Emotion",
|
||||
"field_label_field_type": "Field Type",
|
||||
"field_label_nps_score": "NPS Score",
|
||||
"field_label_nps_value": "NPS Value",
|
||||
"field_label_passive_count": "Passive Count",
|
||||
"field_label_promoter_count": "Promoter Count",
|
||||
"field_label_response_id": "Response ID",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Source Name",
|
||||
"field_label_source_type": "Source Type",
|
||||
"field_label_topic": "Topic",
|
||||
"field_label_user_identifier": "User Identifier",
|
||||
"filter_data": "Filter data",
|
||||
"filters": "Filters",
|
||||
"filters_toggle_description": "Only include data that meets the following conditions.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularity",
|
||||
"granularity_day": "Day",
|
||||
"granularity_hour": "Hour",
|
||||
"granularity_month": "Month",
|
||||
"granularity_quarter": "Quarter",
|
||||
"granularity_week": "Week",
|
||||
"granularity_year": "Year",
|
||||
"greater_than": "greater than",
|
||||
"greater_than_or_equal": "greater than or equal",
|
||||
"group_by": "Group By",
|
||||
"group_by_description": "Break down your data by one or more dimensions. The order is important if you choose multiple dimensions.",
|
||||
"group_data": "Group data",
|
||||
"is_not_set": "is not set",
|
||||
"is_set": "is set",
|
||||
"less_than": "less than",
|
||||
"less_than_or_equal": "less than or equal",
|
||||
"measures": "Measures",
|
||||
"no_charts_found": "No charts found.",
|
||||
"no_dashboards_available": "No dashboards available",
|
||||
"no_dashboards_create_first": "Create a dashboard first to add charts to it.",
|
||||
"no_data_available": "No data available",
|
||||
"no_data_returned": "No data returned from query",
|
||||
"no_data_returned_for_chart": "No data returned for chart",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "None (filter only)",
|
||||
"no_valid_data_to_display": "No valid data to display",
|
||||
"not_contains": "not contains",
|
||||
"not_equals": "not equals",
|
||||
"open_chart": "Open chart {{name}}",
|
||||
"open_options": "Open chart options",
|
||||
"or_filter_logic": "OR",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Please enter a chart name",
|
||||
"please_enter_filter_values": "Please enter values for all filters",
|
||||
"please_select_at_least_one_dimension": "Please select at least one dimension or disable grouping",
|
||||
"please_select_at_least_one_measure": "Please select at least one measure",
|
||||
"please_select_dashboard": "Please select a dashboard",
|
||||
"predefined_measures": "Predefined Measures",
|
||||
"preset": "Preset",
|
||||
"query_executed_successfully": "Query executed successfully",
|
||||
"reset_to_ai_suggestion": "Reset to AI suggestion",
|
||||
"save_chart": "Save Chart",
|
||||
"save_chart_dialog_title": "Save Chart",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Select dimensions...",
|
||||
"select_field": "Select field",
|
||||
"select_measures": "Select measures...",
|
||||
"select_preset": "Select preset",
|
||||
"showing_first_n_of": "Showing first {{n}} of {{count}} rows",
|
||||
"start_date": "Start date",
|
||||
"time_dimension": "Time Dimension",
|
||||
"time_dimension_title": "Add time-based grouping",
|
||||
"time_dimension_toggle_description": "Monitor trends over time."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Add {count} chart(s)",
|
||||
"charts_add_failed": "Failed to add charts to dashboard",
|
||||
"charts_add_partial_failure": "Failed to add {count} chart(s)",
|
||||
"charts_added_to_dashboard": "Charts added to dashboard",
|
||||
"charts_load_failed": "Failed to load charts",
|
||||
"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.",
|
||||
"dashboard_name": "Dashboard Name",
|
||||
"dashboard_name_placeholder": "My dashboard",
|
||||
"dashboard_name_required": "Dashboard name is required",
|
||||
"dashboard_save_failed": "Failed to save dashboard",
|
||||
"dashboard_saved": "Dashboard saved successfully",
|
||||
"delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
|
||||
"delete_failed": "Failed to delete dashboard",
|
||||
"delete_success": "Dashboard deleted successfully",
|
||||
"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": "No more charts available to add. Create a new one.",
|
||||
"no_charts_to_add_message": "No charts to add to this dashboard.",
|
||||
"no_dashboards_found": "No dashboards found.",
|
||||
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
|
||||
"please_enter_name": "Please enter a dashboard name"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Add API Key",
|
||||
"api_key": "API Key",
|
||||
@@ -2308,6 +2507,7 @@
|
||||
"archive_not_allowed": "You are not allowed to archive this directory.",
|
||||
"are_you_sure_you_want_to_archive": "Are you sure you want to archive this directory? Workspaces will no longer have access to it.",
|
||||
"assign_workspaces_description": "Control which workspaces can access this feedback record directory.",
|
||||
"connectors_description": "Connectors that send feedback records to this directory.",
|
||||
"create_feedback_directory": "Create feedback directory",
|
||||
"description": "Manage feedback record directories and their workspace assignments.",
|
||||
"directory_archived_successfully": "Directory archived successfully",
|
||||
@@ -2319,12 +2519,13 @@
|
||||
"directory_unarchived_successfully": "Directory unarchived successfully",
|
||||
"directory_updated_successfully": "Directory updated successfully",
|
||||
"empty_state": "No feedback record directories found. Create one to get started.",
|
||||
"enter_directory_name": "Enter directory name",
|
||||
"error_directory_has_connectors": "Cannot archive a directory that has connectors linked to it. Remove all connectors first.",
|
||||
"error_directory_name_duplicate": "A feedback record directory with this name already exists.",
|
||||
"error_directory_name_required": "Directory name is required.",
|
||||
"error_directory_workspaces_invalid_org": "Some specified workspaces do not belong to this organization.",
|
||||
"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.",
|
||||
"select_workspaces_placeholder": "Select workspaces...",
|
||||
"show_archived": "Show archived",
|
||||
"title": "Feedback Record Directories",
|
||||
@@ -3368,6 +3569,119 @@
|
||||
"team_name": "Team Name",
|
||||
"team_settings_description": "See which teams can access this workspace."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Add Feedback Source",
|
||||
"add_source": "Add source",
|
||||
"allowed_values": "Allowed values: {values}",
|
||||
"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",
|
||||
"connector_status_updated_successfully": "Connector status updated successfully",
|
||||
"connector_updated_successfully": "Connector updated successfully",
|
||||
"connectors": "Connectors",
|
||||
"create_mapping": "Create mapping",
|
||||
"created_by": "Created by",
|
||||
"csv_at_least_one_row": "CSV must contain at least one data row.",
|
||||
"csv_columns": "CSV Columns",
|
||||
"csv_empty_column_headers": "CSV contains empty column headers. All columns must have a name.",
|
||||
"csv_file_too_large": "CSV file is too large. Maximum size is 2MB.",
|
||||
"csv_files_only": "CSV files only",
|
||||
"csv_import": "CSV Import",
|
||||
"csv_import_complete": "CSV import complete: {successes} succeeded, {failures} failed, {skipped} skipped",
|
||||
"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.",
|
||||
"default_connector_name_csv": "CSV Import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey Connection",
|
||||
"deselect_all": "Deselect all",
|
||||
"drop_a_field_here": "Drop a field here",
|
||||
"drop_field_or": "Drop field or",
|
||||
"edit_csv_mapping": "Edit CSV mapping",
|
||||
"edit_source_connection": "Edit Source Connection",
|
||||
"enter_name_for_source": "Enter a name for this source",
|
||||
"enter_value": "Enter value...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Failed to load feedback records",
|
||||
"feedback_date": "Current date",
|
||||
"feedback_record_directory": "Feedback Record Directory",
|
||||
"feedback_record_fields": "Feedback Record Fields",
|
||||
"feedback_records": "Feedback Records",
|
||||
"feedback_records_refreshed": "Feedback records refreshed",
|
||||
"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_rows": "Import {count} rows",
|
||||
"importing_data": "Importing data...",
|
||||
"importing_historical_data": "Importing historical data...",
|
||||
"invalid_enum_values": "Invalid values in column mapped to {field}",
|
||||
"invalid_values_found": "Found: {values} (rows: {rows}) {extra}",
|
||||
"load_sample_csv": "Load sample CSV",
|
||||
"n_supported_questions": "{count} supported questions",
|
||||
"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...",
|
||||
"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_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.",
|
||||
"set_value": "set value",
|
||||
"setup_connection": "Setup connection",
|
||||
"showing_count_loaded": "Showing {count} records",
|
||||
"showing_rows": "Showing 3 of {count} rows",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "Import feedback from CSV files",
|
||||
"source_connect_formbricks_description": "Connect feedback from your Formbricks surveys",
|
||||
"source_fields": "Source Fields",
|
||||
"source_name": "Source Name",
|
||||
"source_type": "Source Type",
|
||||
"source_type_cannot_be_changed": "Source type cannot be changed",
|
||||
"sources": "Sources",
|
||||
"status_active": "In Progress",
|
||||
"status_completed": "Completed",
|
||||
"status_draft": "Draft",
|
||||
"status_error": "Error",
|
||||
"status_paused": "Paused",
|
||||
"survey_has_no_questions": "This survey has no questions",
|
||||
"survey_import_line": "{surveyName}: {responseCount} responses × {questionCount} questions = {total} Feedback Records",
|
||||
"total_feedback_records": "Total: {checked} of {total} Feedback Records selected across {surveyCount} surveys",
|
||||
"unify_feedback": "Unify Feedback",
|
||||
"update_mapping_description": "Update the mapping configuration for this source.",
|
||||
"updated_at": "Updated at",
|
||||
"upload_csv_data_description": "Upload a CSV file to import feedback data.",
|
||||
"upload_csv_file": "Upload CSV File",
|
||||
"user_identifier": "User",
|
||||
"value": "Value"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Leverage every touchpoint to understand ease of customer interaction.",
|
||||
|
||||
+313
-1
@@ -125,6 +125,8 @@
|
||||
"activity": "Actividad",
|
||||
"add": "Añadir",
|
||||
"add_action": "Añadir acción",
|
||||
"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",
|
||||
"add_logo": "Añadir logotipo",
|
||||
"add_member": "Añadir miembro",
|
||||
@@ -136,6 +138,7 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
|
||||
"analysis": "Análisis",
|
||||
"and": "Y",
|
||||
"anonymous": "Anónimo",
|
||||
"api_keys": "Claves API",
|
||||
@@ -154,6 +157,8 @@
|
||||
"centered_modal": "Modal centrado",
|
||||
"change_organization": "Cambiar organización",
|
||||
"change_workspace": "Cambiar espacio de trabajo",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Opciones",
|
||||
"choose_organization": "Elegir organización",
|
||||
"choose_workspace": "Elegir proyecto",
|
||||
@@ -186,6 +191,7 @@
|
||||
"count_questions": "{count, plural, one {{count} pregunta} other {{count} preguntas}}",
|
||||
"count_responses": "{count, plural, one {{count} respuesta} other {{count} respuestas}}",
|
||||
"count_selections": "{count, plural, one {{count} selección} other {{count} selecciones}}",
|
||||
"create": "Crear",
|
||||
"create_new_organization": "Crear organización nueva",
|
||||
"create_segment": "Crear segmento",
|
||||
"create_survey": "Crear encuesta",
|
||||
@@ -195,6 +201,8 @@
|
||||
"created_by": "Creado por",
|
||||
"customer_success": "Éxito del cliente",
|
||||
"dark_overlay": "Superposición oscura",
|
||||
"dashboard": "Panel de control",
|
||||
"dashboards": "Paneles",
|
||||
"date": "Fecha",
|
||||
"days": "días",
|
||||
"default": "Predeterminado",
|
||||
@@ -218,6 +226,7 @@
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"enable": "Activar",
|
||||
"ending_card": "Tarjeta final",
|
||||
"enter_url": "Introducir URL",
|
||||
"enterprise_license": "Licencia empresarial",
|
||||
@@ -232,6 +241,7 @@
|
||||
"failed_to_copy_to_clipboard": "Error al copiar al portapapeles",
|
||||
"failed_to_load_organizations": "Error al cargar organizaciones",
|
||||
"failed_to_load_workspaces": "Error al cargar los proyectos",
|
||||
"failed_to_parse_csv": "Error al analizar el CSV",
|
||||
"filter": "Filtro",
|
||||
"finish": "Finalizar",
|
||||
"first_name": "Nombre",
|
||||
@@ -246,6 +256,7 @@
|
||||
"hidden": "Oculto",
|
||||
"hidden_field": "Campo oculto",
|
||||
"hidden_fields": "Campos ocultos",
|
||||
"hide": "Ocultar",
|
||||
"hide_column": "Ocultar columna",
|
||||
"id": "ID",
|
||||
"image": "Imagen",
|
||||
@@ -292,6 +303,7 @@
|
||||
"mobile_overlay_surveys_look_good": "No te preocupes – ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
|
||||
"mobile_overlay_title": "¡Ups, pantalla pequeña detectada!",
|
||||
"months": "meses",
|
||||
"more_options": "Más opciones",
|
||||
"move_down": "Mover hacia abajo",
|
||||
"move_up": "Mover hacia arriba",
|
||||
"multiple_languages": "Múltiples idiomas",
|
||||
@@ -300,8 +312,10 @@
|
||||
"new": "Nuevo",
|
||||
"new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!",
|
||||
"next": "Siguiente",
|
||||
"no": "No",
|
||||
"no_actions_found": "No se encontraron acciones",
|
||||
"no_background_image_found": "No se encontró imagen de fondo.",
|
||||
"no_changes": "Sin cambios",
|
||||
"no_code": "Sin código",
|
||||
"no_files_uploaded": "No se subieron archivos",
|
||||
"no_overlay": "Sin superposición",
|
||||
@@ -323,6 +337,7 @@
|
||||
"on": "Activado",
|
||||
"only_one_file_allowed": "Solo se permite un archivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Solo los propietarios y gestores pueden realizar esta acción.",
|
||||
"open_options": "Abrir opciones",
|
||||
"option_id": "ID de opción",
|
||||
"option_ids": "IDs de opciones",
|
||||
"optional": "Opcional",
|
||||
@@ -365,6 +380,7 @@
|
||||
"quotas_description": "Limita la cantidad de respuestas que recibes de participantes que cumplen ciertos criterios.",
|
||||
"read_docs": "Leer documentación",
|
||||
"recipients": "Destinatarios",
|
||||
"refresh": "Actualizar",
|
||||
"remove": "Eliminar",
|
||||
"remove_from_team": "Eliminar del equipo",
|
||||
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
|
||||
@@ -376,6 +392,7 @@
|
||||
"response_id": "ID de respuesta",
|
||||
"responses": "Respuestas",
|
||||
"restart": "Reiniciar",
|
||||
"retry": "Reintentar",
|
||||
"role": "Rol",
|
||||
"saas": "SaaS",
|
||||
"sales": "Ventas",
|
||||
@@ -384,6 +401,7 @@
|
||||
"save_changes": "Guardar cambios",
|
||||
"saving": "Guardando",
|
||||
"search": "Buscar",
|
||||
"search_charts": "Buscar gráficos...",
|
||||
"security": "Seguridad",
|
||||
"segment": "Segmento",
|
||||
"segments": "Segmentos",
|
||||
@@ -447,6 +465,7 @@
|
||||
"trial_one_day_remaining": "1 día restante en tu prueba",
|
||||
"try_again": "Intentar de nuevo",
|
||||
"type": "Tipo",
|
||||
"unify": "Unificar",
|
||||
"unknown_survey": "Encuesta desconocida",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más proyectos con un plan superior.",
|
||||
"update": "Actualizar",
|
||||
@@ -464,6 +483,7 @@
|
||||
"variables": "Variables",
|
||||
"verified_email": "Correo electrónico verificado",
|
||||
"video": "Vídeo",
|
||||
"view": "Ver",
|
||||
"warning": "Advertencia",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "No pudimos verificar tu licencia porque el servidor de licencias no está accesible.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +502,7 @@
|
||||
"workspace_name_placeholder": "p. ej. Formbricks",
|
||||
"workspaces": "Proyectos",
|
||||
"years": "años",
|
||||
"yes": "Sí",
|
||||
"you": "Tú",
|
||||
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
|
||||
"you_are_not_authorized_to_perform_this_action": "No tienes autorización para realizar esta acción.",
|
||||
@@ -1610,6 +1631,182 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Tu encuesta se mostraría en esta URL.",
|
||||
"your_survey_would_not_be_shown": "Tu encuesta no se mostraría."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "O",
|
||||
"add_chart_to_dashboard": "Añadir gráfico al panel de control",
|
||||
"add_chart_to_dashboard_description": "Selecciona un panel de control para añadir este gráfico. El gráfico se guardará automáticamente.",
|
||||
"add_filter": "Añadir filtro",
|
||||
"add_to_dashboard": "Añadir al panel de control",
|
||||
"advanced_chart_builder_config_prompt": "Configura tu gráfico y haz clic en \"Ejecutar consulta\" para previsualizar",
|
||||
"ai_query_placeholder": "p. ej. ¿Cuántos usuarios se registraron la semana pasada?",
|
||||
"ai_query_section_description": "Describe lo que quieres ver y deja que la IA construya el gráfico.",
|
||||
"ai_query_section_title": "Pregunta a tus datos",
|
||||
"and_filter_logic": "Y",
|
||||
"apply_changes": "Aplicar cambios",
|
||||
"chart": "Gráfico",
|
||||
"chart_added_to_dashboard": "¡Gráfico añadido al panel de control!",
|
||||
"chart_builder_choose_chart_type": "Elige el tipo de gráfico",
|
||||
"chart_data": "Datos del gráfico",
|
||||
"chart_data_tab": "Datos",
|
||||
"chart_deleted_successfully": "Gráfico eliminado correctamente",
|
||||
"chart_deletion_error": "Error al eliminar el gráfico",
|
||||
"chart_duplicated_successfully": "Gráfico duplicado correctamente",
|
||||
"chart_duplication_error": "Error al duplicar el gráfico",
|
||||
"chart_name": "Nombre del gráfico",
|
||||
"chart_name_placeholder": "Nombre del gráfico",
|
||||
"chart_preview": "Vista previa del gráfico",
|
||||
"chart_render_error": "Algo salió mal al renderizar este gráfico.",
|
||||
"chart_saved_successfully": "¡Gráfico guardado correctamente!",
|
||||
"chart_type_area": "Gráfico de área",
|
||||
"chart_type_bar": "Gráfico de barras",
|
||||
"chart_type_big_number": "Número grande",
|
||||
"chart_type_line": "Gráfico de líneas",
|
||||
"chart_type_not_supported": "El tipo de gráfico \"{{chartType}}\" aún no está soportado",
|
||||
"chart_type_pie": "Gráfico circular",
|
||||
"chart_updated_successfully": "¡Gráfico actualizado correctamente!",
|
||||
"configure_description": "Modifica el tipo de gráfico y otros ajustes para esta visualización.",
|
||||
"configure_title": "Configurar gráfico",
|
||||
"configure_type_label": "Tipo de gráfico",
|
||||
"contains": "contiene",
|
||||
"create_chart": "Crear gráfico",
|
||||
"create_chart_description": "Usa IA para generar un gráfico o créalo manualmente.",
|
||||
"create_chart_with_ai": "Crear gráfico con IA",
|
||||
"custom_range": "Rango personalizado",
|
||||
"dashboard": "Panel de control",
|
||||
"dashboard_select_placeholder": "Selecciona un panel de control",
|
||||
"data_label": "Datos",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Últimos 30 días",
|
||||
"date_preset_last_7_days": "Últimos 7 días",
|
||||
"date_preset_last_month": "Último mes",
|
||||
"date_preset_this_month": "Este mes",
|
||||
"date_preset_this_quarter": "Este trimestre",
|
||||
"date_preset_this_year": "Este año",
|
||||
"date_preset_today": "Hoy",
|
||||
"date_preset_yesterday": "Ayer",
|
||||
"date_range": "Rango de fechas",
|
||||
"delete_chart_confirmation": "¿Estás seguro de que quieres eliminar este gráfico?",
|
||||
"dimensions": "Dimensiones",
|
||||
"dimensions_toggle_description": "Agrupa los datos por sentimiento, tipo de pregunta y otras dimensiones.",
|
||||
"edit_chart_description": "Visualiza y edita la configuración de tu gráfico.",
|
||||
"edit_chart_title": "Editar gráfico",
|
||||
"enable_time_dimension": "Activar dimensión temporal",
|
||||
"end_date": "Fecha de finalización",
|
||||
"enter_a_name_for_your_chart": "Introduce un nombre para tu gráfico para guardarlo.",
|
||||
"enter_value": "Introduce un valor",
|
||||
"equals": "es igual a",
|
||||
"failed_to_add_chart_to_dashboard": "Error al añadir el gráfico al panel de control",
|
||||
"failed_to_execute_query": "Error al ejecutar la consulta",
|
||||
"failed_to_load_chart": "Error al cargar el gráfico",
|
||||
"failed_to_load_chart_data": "Error al cargar los datos del gráfico",
|
||||
"failed_to_save_chart": "Error al guardar el gráfico",
|
||||
"field": "Campo",
|
||||
"field_label_average_score": "Puntuación media",
|
||||
"field_label_collected_at": "Recopilado el",
|
||||
"field_label_count": "Recuento",
|
||||
"field_label_detractor_count": "Recuento de detractores",
|
||||
"field_label_emotion": "Emoción",
|
||||
"field_label_field_type": "Tipo de campo",
|
||||
"field_label_nps_score": "Puntuación NPS",
|
||||
"field_label_nps_value": "Valor NPS",
|
||||
"field_label_passive_count": "Recuento de pasivos",
|
||||
"field_label_promoter_count": "Recuento de promotores",
|
||||
"field_label_response_id": "ID de respuesta",
|
||||
"field_label_sentiment": "Sentimiento",
|
||||
"field_label_source_name": "Nombre de origen",
|
||||
"field_label_source_type": "Tipo de origen",
|
||||
"field_label_topic": "Tema",
|
||||
"field_label_user_identifier": "Identificador de usuario",
|
||||
"filter_data": "Filtrar datos",
|
||||
"filters": "Filtros",
|
||||
"filters_toggle_description": "Incluye solo los datos que cumplan las siguientes condiciones.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularidad",
|
||||
"granularity_day": "Día",
|
||||
"granularity_hour": "Hora",
|
||||
"granularity_month": "Mes",
|
||||
"granularity_quarter": "Trimestre",
|
||||
"granularity_week": "Semana",
|
||||
"granularity_year": "Año",
|
||||
"greater_than": "mayor que",
|
||||
"greater_than_or_equal": "mayor o igual que",
|
||||
"group_by": "Agrupar por",
|
||||
"group_by_description": "Desglosa tus datos por una o más dimensiones. El orden es importante si eliges varias dimensiones.",
|
||||
"group_data": "Agrupar datos",
|
||||
"is_not_set": "no está establecido",
|
||||
"is_set": "está establecido",
|
||||
"less_than": "menor que",
|
||||
"less_than_or_equal": "menor o igual que",
|
||||
"measures": "Medidas",
|
||||
"no_charts_found": "No se encontraron gráficos.",
|
||||
"no_dashboards_available": "No hay paneles de control disponibles",
|
||||
"no_dashboards_create_first": "Crea primero un panel de control para añadirle gráficos.",
|
||||
"no_data_available": "No hay datos disponibles",
|
||||
"no_data_returned": "La consulta no ha devuelto datos",
|
||||
"no_data_returned_for_chart": "No se han devuelto datos para el gráfico",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Ninguno (solo filtro)",
|
||||
"no_valid_data_to_display": "No hay datos válidos para mostrar",
|
||||
"not_contains": "no contiene",
|
||||
"not_equals": "no es igual a",
|
||||
"open_chart": "Abrir gráfico {{name}}",
|
||||
"open_options": "Abrir opciones del gráfico",
|
||||
"or_filter_logic": "O",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Introduce un nombre para el gráfico",
|
||||
"please_enter_filter_values": "Por favor, introduce valores para todos los filtros",
|
||||
"please_select_at_least_one_dimension": "Por favor, selecciona al menos una dimensión o desactiva la agrupación",
|
||||
"please_select_at_least_one_measure": "Selecciona al menos una medida",
|
||||
"please_select_dashboard": "Selecciona un panel de control",
|
||||
"predefined_measures": "Medidas predefinidas",
|
||||
"preset": "Preajuste",
|
||||
"query_executed_successfully": "Consulta ejecutada correctamente",
|
||||
"reset_to_ai_suggestion": "Restablecer a sugerencia de IA",
|
||||
"save_chart": "Guardar gráfico",
|
||||
"save_chart_dialog_title": "Guardar gráfico",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Seleccionar dimensiones...",
|
||||
"select_field": "Seleccionar campo",
|
||||
"select_measures": "Seleccionar medidas...",
|
||||
"select_preset": "Seleccionar preajuste",
|
||||
"showing_first_n_of": "Mostrando las primeras {{n}} de {{count}} filas",
|
||||
"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."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Añadir {count} gráfico(s)",
|
||||
"charts_add_failed": "Error al añadir gráficos al panel",
|
||||
"charts_add_partial_failure": "Error al añadir {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos añadidos al panel",
|
||||
"charts_load_failed": "Error al cargar los gráficos",
|
||||
"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_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.",
|
||||
"dashboard_name": "Nombre del panel de control",
|
||||
"dashboard_name_placeholder": "Mi panel de control",
|
||||
"dashboard_name_required": "El nombre del panel es obligatorio",
|
||||
"dashboard_save_failed": "Error al guardar el panel",
|
||||
"dashboard_saved": "Panel guardado correctamente",
|
||||
"delete_confirmation": "¿Estás seguro de que quieres eliminar este panel de control? Esta acción no se puede deshacer.",
|
||||
"delete_failed": "Error al eliminar el panel de control",
|
||||
"delete_success": "Panel de control eliminado correctamente",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Añadir clave API",
|
||||
"api_key": "Clave API",
|
||||
@@ -2308,6 +2505,7 @@
|
||||
"archive_not_allowed": "No tienes permiso para archivar este directorio.",
|
||||
"are_you_sure_you_want_to_archive": "¿Estás seguro de que quieres archivar este directorio? Los espacios de trabajo ya no tendrán acceso a él.",
|
||||
"assign_workspaces_description": "Controla qué espacios de trabajo pueden acceder a este directorio de registros de feedback.",
|
||||
"connectors_description": "Conectores que envían registros de comentarios a este directorio.",
|
||||
"create_feedback_directory": "Crear directorio de comentarios",
|
||||
"description": "Gestiona los directorios de registros de feedback y sus asignaciones de espacios de trabajo.",
|
||||
"directory_archived_successfully": "Directorio archivado correctamente",
|
||||
@@ -2319,12 +2517,13 @@
|
||||
"directory_unarchived_successfully": "Directorio desarchivado correctamente",
|
||||
"directory_updated_successfully": "Directorio actualizado correctamente",
|
||||
"empty_state": "No se encontraron directorios de registros de feedback. Crea uno para empezar.",
|
||||
"enter_directory_name": "Introduce el nombre del directorio",
|
||||
"error_directory_has_connectors": "No se puede archivar un directorio que tiene conectores vinculados. Elimina primero todos los conectores.",
|
||||
"error_directory_name_duplicate": "Ya existe un directorio de registros de comentarios con este nombre.",
|
||||
"error_directory_name_required": "El nombre del directorio es obligatorio.",
|
||||
"error_directory_workspaces_invalid_org": "Algunos de los espacios de trabajo especificados no pertenecen a esta organización.",
|
||||
"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.",
|
||||
"select_workspaces_placeholder": "Selecciona espacios de trabajo...",
|
||||
"show_archived": "Mostrar archivados",
|
||||
"title": "Directorios de Registros de Feedback",
|
||||
@@ -3368,6 +3567,119 @@
|
||||
"team_name": "Nombre del equipo",
|
||||
"team_settings_description": "Consulta qué equipos pueden acceder a este espacio de trabajo."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Añadir fuente de feedback",
|
||||
"add_source": "Añadir fuente",
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"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",
|
||||
"connector_status_updated_successfully": "Estado del conector actualizado correctamente",
|
||||
"connector_updated_successfully": "Conector actualizado correctamente",
|
||||
"connectors": "Conectores",
|
||||
"create_mapping": "Crear asignación",
|
||||
"created_by": "Creado por",
|
||||
"csv_at_least_one_row": "El CSV debe contener al menos una fila de datos.",
|
||||
"csv_columns": "Columnas CSV",
|
||||
"csv_empty_column_headers": "El CSV contiene encabezados de columna vacíos. Todas las columnas deben tener un nombre.",
|
||||
"csv_file_too_large": "El archivo CSV es demasiado grande. El tamaño máximo es de 2 MB.",
|
||||
"csv_files_only": "Solo archivos CSV",
|
||||
"csv_import": "Importación CSV",
|
||||
"csv_import_complete": "Importación de CSV completada: {successes} correctas, {failures} fallidas, {skipped} omitidas",
|
||||
"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.",
|
||||
"default_connector_name_csv": "Importación CSV",
|
||||
"default_connector_name_formbricks": "Conexión de encuesta de Formbricks",
|
||||
"deselect_all": "Deseleccionar todo",
|
||||
"drop_a_field_here": "Suelta un campo aquí",
|
||||
"drop_field_or": "Suelta el campo o",
|
||||
"edit_csv_mapping": "Editar mapeo de CSV",
|
||||
"edit_source_connection": "Editar conexión de origen",
|
||||
"enter_name_for_source": "Introduce un nombre para este origen",
|
||||
"enter_value": "Introduce un valor...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
|
||||
"feedback_date": "Fecha actual",
|
||||
"feedback_record_directory": "Directorio de Registros de Comentarios",
|
||||
"feedback_record_fields": "Campos de registro de comentarios",
|
||||
"feedback_records": "Registros de comentarios",
|
||||
"feedback_records_refreshed": "Registros de comentarios actualizados",
|
||||
"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_rows": "Importar {count} filas",
|
||||
"importing_data": "Importando datos...",
|
||||
"importing_historical_data": "Importando datos históricos...",
|
||||
"invalid_enum_values": "Valores no válidos en la columna asignada a {field}",
|
||||
"invalid_values_found": "Encontrados: {values} (filas: {rows}) {extra}",
|
||||
"load_sample_csv": "Cargar CSV de muestra",
|
||||
"n_supported_questions": "{count} preguntas compatibles",
|
||||
"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...",
|
||||
"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_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.",
|
||||
"set_value": "establecer valor",
|
||||
"setup_connection": "Configurar conexión",
|
||||
"showing_count_loaded": "Mostrando {count} registros",
|
||||
"showing_rows": "Mostrando 3 de {count} filas",
|
||||
"source": "origen",
|
||||
"source_connect_csv_description": "Importar feedback desde archivos CSV",
|
||||
"source_connect_formbricks_description": "Conectar feedback de tus encuestas de Formbricks",
|
||||
"source_fields": "Campos de origen",
|
||||
"source_name": "Nombre de origen",
|
||||
"source_type": "Tipo de fuente",
|
||||
"source_type_cannot_be_changed": "El tipo de origen no se puede cambiar",
|
||||
"sources": "Orígenes",
|
||||
"status_active": "En progreso",
|
||||
"status_completed": "Completado",
|
||||
"status_draft": "Borrador",
|
||||
"status_error": "Error",
|
||||
"status_paused": "Pausado",
|
||||
"survey_has_no_questions": "Esta encuesta no tiene preguntas",
|
||||
"survey_import_line": "{surveyName}: {responseCount} respuestas × {questionCount} preguntas = {total} registros de feedback",
|
||||
"total_feedback_records": "Total: {checked} de {total} registros de feedback seleccionados en {surveyCount} encuestas",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Actualiza la configuración de mapeo para esta fuente.",
|
||||
"updated_at": "Actualizado el",
|
||||
"upload_csv_data_description": "Sube un archivo CSV para importar datos de comentarios.",
|
||||
"upload_csv_file": "Subir archivo CSV",
|
||||
"user_identifier": "Usuario",
|
||||
"value": "Valor"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Aprovecha cada punto de contacto para entender la facilidad de interacción del cliente.",
|
||||
|
||||
+313
-1
@@ -125,6 +125,8 @@
|
||||
"activity": "Activité",
|
||||
"add": "Ajouter",
|
||||
"add_action": "Ajouter une action",
|
||||
"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",
|
||||
"add_logo": "Ajouter un logo",
|
||||
"add_member": "Ajouter un membre",
|
||||
@@ -136,6 +138,7 @@
|
||||
"allow": "Autoriser",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "Et",
|
||||
"anonymous": "Anonyme",
|
||||
"api_keys": "Clés API",
|
||||
@@ -154,6 +157,8 @@
|
||||
"centered_modal": "Au centre",
|
||||
"change_organization": "Changer d'organisation",
|
||||
"change_workspace": "Changer d'espace de travail",
|
||||
"chart": "Graphique",
|
||||
"charts": "Graphiques",
|
||||
"choices": "Choix",
|
||||
"choose_organization": "Choisir l'organisation",
|
||||
"choose_workspace": "Choisir un projet",
|
||||
@@ -186,6 +191,7 @@
|
||||
"count_questions": "{count, plural, one {{count} question} other {{count} questions}}",
|
||||
"count_responses": "{count, plural, one {{count} réponse} other {{count} réponses}}",
|
||||
"count_selections": "{count, plural, one {{count} sélection} other {{count} sélections}}",
|
||||
"create": "Créer",
|
||||
"create_new_organization": "Créer une nouvelle organisation",
|
||||
"create_segment": "Créer un segment",
|
||||
"create_survey": "Créer un sondage",
|
||||
@@ -195,6 +201,8 @@
|
||||
"created_by": "Créé par",
|
||||
"customer_success": "Succès Client",
|
||||
"dark_overlay": "Foncée",
|
||||
"dashboard": "Tableau de bord",
|
||||
"dashboards": "Tableaux de bord",
|
||||
"date": "Date",
|
||||
"days": "jours",
|
||||
"default": "Par défaut",
|
||||
@@ -218,6 +226,7 @@
|
||||
"edit": "Modifier",
|
||||
"elements": "Éléments",
|
||||
"email": "Email",
|
||||
"enable": "Activer",
|
||||
"ending_card": "Carte de fin",
|
||||
"enter_url": "Saisir l'URL",
|
||||
"enterprise_license": "Licence d'entreprise",
|
||||
@@ -232,6 +241,7 @@
|
||||
"failed_to_copy_to_clipboard": "Échec de la copie dans le presse-papiers",
|
||||
"failed_to_load_organizations": "Échec du chargement des organisations",
|
||||
"failed_to_load_workspaces": "Échec du chargement des projets",
|
||||
"failed_to_parse_csv": "Échec de l'analyse du CSV",
|
||||
"filter": "Filtre",
|
||||
"finish": "Terminer",
|
||||
"first_name": "Prénom",
|
||||
@@ -246,6 +256,7 @@
|
||||
"hidden": "Caché",
|
||||
"hidden_field": "Champ caché",
|
||||
"hidden_fields": "Champs cachés",
|
||||
"hide": "Masquer",
|
||||
"hide_column": "Cacher la colonne",
|
||||
"id": "ID",
|
||||
"image": "Image",
|
||||
@@ -292,6 +303,7 @@
|
||||
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas – tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
|
||||
"mobile_overlay_title": "Oups, écran minuscule détecté!",
|
||||
"months": "mois",
|
||||
"more_options": "Plus d'options",
|
||||
"move_down": "Déplacer vers le bas",
|
||||
"move_up": "Déplacer vers le haut",
|
||||
"multiple_languages": "Plusieurs langues",
|
||||
@@ -300,8 +312,10 @@
|
||||
"new": "Nouveau",
|
||||
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
|
||||
"next": "Suivant",
|
||||
"no": "Non",
|
||||
"no_actions_found": "Aucune action trouvée",
|
||||
"no_background_image_found": "Aucune image de fond trouvée.",
|
||||
"no_changes": "Aucune modification",
|
||||
"no_code": "Sans code",
|
||||
"no_files_uploaded": "Aucun fichier n'a été téléchargé.",
|
||||
"no_overlay": "Aucune superposition",
|
||||
@@ -323,6 +337,7 @@
|
||||
"on": "Sur",
|
||||
"only_one_file_allowed": "Un seul fichier est autorisé",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
|
||||
"open_options": "Ouvrir les options",
|
||||
"option_id": "Identifiant de l'option",
|
||||
"option_ids": "Identifiants des options",
|
||||
"optional": "Facultatif",
|
||||
@@ -365,6 +380,7 @@
|
||||
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
|
||||
"read_docs": "Lire la documentation",
|
||||
"recipients": "Destinataires",
|
||||
"refresh": "Actualiser",
|
||||
"remove": "Retirer",
|
||||
"remove_from_team": "Retirer de l'équipe",
|
||||
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
|
||||
@@ -376,6 +392,7 @@
|
||||
"response_id": "ID de réponse",
|
||||
"responses": "Réponses",
|
||||
"restart": "Recommencer",
|
||||
"retry": "Réessayer",
|
||||
"role": "Rôle",
|
||||
"saas": "SaaS",
|
||||
"sales": "Ventes",
|
||||
@@ -384,6 +401,7 @@
|
||||
"save_changes": "Enregistrer les modifications",
|
||||
"saving": "Sauvegarder",
|
||||
"search": "Recherche",
|
||||
"search_charts": "Rechercher des graphiques...",
|
||||
"security": "Sécurité",
|
||||
"segment": "Segmenter",
|
||||
"segments": "Segments",
|
||||
@@ -447,6 +465,7 @@
|
||||
"trial_one_day_remaining": "1 jour restant dans votre période d'essai",
|
||||
"try_again": "Réessayer",
|
||||
"type": "Type",
|
||||
"unify": "Unifier",
|
||||
"unknown_survey": "Enquête inconnue",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Débloquez plus de projets avec un forfait supérieur.",
|
||||
"update": "Mise à jour",
|
||||
@@ -464,6 +483,7 @@
|
||||
"variables": "Variables",
|
||||
"verified_email": "Email vérifié",
|
||||
"video": "Vidéo",
|
||||
"view": "Afficher",
|
||||
"warning": "Avertissement",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nous n'avons pas pu vérifier votre licence car le serveur de licence est inaccessible.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +502,7 @@
|
||||
"workspace_name_placeholder": "par ex. Formbricks",
|
||||
"workspaces": "Projets",
|
||||
"years": "années",
|
||||
"yes": "Oui",
|
||||
"you": "Vous",
|
||||
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
|
||||
@@ -1610,6 +1631,182 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.",
|
||||
"your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "OU",
|
||||
"add_chart_to_dashboard": "Ajouter le graphique au tableau de bord",
|
||||
"add_chart_to_dashboard_description": "Sélectionnez un tableau de bord pour y ajouter ce graphique. Le graphique sera enregistré automatiquement.",
|
||||
"add_filter": "Ajouter un filtre",
|
||||
"add_to_dashboard": "Ajouter au tableau de bord",
|
||||
"advanced_chart_builder_config_prompt": "Configurez votre graphique et cliquez sur « Exécuter la requête » pour prévisualiser",
|
||||
"ai_query_placeholder": "ex. Combien d'utilisateurs se sont inscrits la semaine dernière ?",
|
||||
"ai_query_section_description": "Décrivez ce que vous souhaitez voir et laissez l'IA créer le graphique.",
|
||||
"ai_query_section_title": "Interrogez vos données",
|
||||
"and_filter_logic": "ET",
|
||||
"apply_changes": "Appliquer les modifications",
|
||||
"chart": "Graphique",
|
||||
"chart_added_to_dashboard": "Graphique ajouté au tableau de bord !",
|
||||
"chart_builder_choose_chart_type": "Choisir le type de graphique",
|
||||
"chart_data": "Données du graphique",
|
||||
"chart_data_tab": "Données",
|
||||
"chart_deleted_successfully": "Graphique supprimé avec succès",
|
||||
"chart_deletion_error": "Échec de la suppression du graphique",
|
||||
"chart_duplicated_successfully": "Graphique dupliqué avec succès",
|
||||
"chart_duplication_error": "Échec de la duplication du graphique",
|
||||
"chart_name": "Nom du graphique",
|
||||
"chart_name_placeholder": "Nom du graphique",
|
||||
"chart_preview": "Aperçu du graphique",
|
||||
"chart_render_error": "Une erreur s'est produite lors du rendu de ce graphique.",
|
||||
"chart_saved_successfully": "Graphique enregistré avec succès !",
|
||||
"chart_type_area": "Graphique en aires",
|
||||
"chart_type_bar": "Graphique à barres",
|
||||
"chart_type_big_number": "Grand nombre",
|
||||
"chart_type_line": "Graphique linéaire",
|
||||
"chart_type_not_supported": "Le type de graphique « {{chartType}} » n'est pas encore pris en charge",
|
||||
"chart_type_pie": "Graphique circulaire",
|
||||
"chart_updated_successfully": "Graphique mis à jour avec succès !",
|
||||
"configure_description": "Modifiez le type de graphique et d'autres paramètres pour cette visualisation.",
|
||||
"configure_title": "Configurer le graphique",
|
||||
"configure_type_label": "Type de graphique",
|
||||
"contains": "contient",
|
||||
"create_chart": "Créer un graphique",
|
||||
"create_chart_description": "Utilisez l'IA pour générer un graphique ou créez-en un manuellement.",
|
||||
"create_chart_with_ai": "Créer un graphique avec l'IA",
|
||||
"custom_range": "Plage personnalisée",
|
||||
"dashboard": "Tableau de bord",
|
||||
"dashboard_select_placeholder": "Sélectionnez un tableau de bord",
|
||||
"data_label": "Données",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "30 derniers jours",
|
||||
"date_preset_last_7_days": "7 derniers jours",
|
||||
"date_preset_last_month": "Le mois dernier",
|
||||
"date_preset_this_month": "Ce mois-ci",
|
||||
"date_preset_this_quarter": "Ce trimestre",
|
||||
"date_preset_this_year": "Cette année",
|
||||
"date_preset_today": "Aujourd'hui",
|
||||
"date_preset_yesterday": "Hier",
|
||||
"date_range": "Plage de dates",
|
||||
"delete_chart_confirmation": "Êtes-vous sûr de vouloir supprimer ce graphique ?",
|
||||
"dimensions": "Dimensions",
|
||||
"dimensions_toggle_description": "Groupe les données par sentiment, type de question et autres dimensions.",
|
||||
"edit_chart_description": "Consultez et modifiez la configuration de votre graphique.",
|
||||
"edit_chart_title": "Modifier le graphique",
|
||||
"enable_time_dimension": "Activer la dimension temporelle",
|
||||
"end_date": "Date de fin",
|
||||
"enter_a_name_for_your_chart": "Saisissez un nom pour votre graphique afin de l'enregistrer.",
|
||||
"enter_value": "Saisissez une valeur",
|
||||
"equals": "égal",
|
||||
"failed_to_add_chart_to_dashboard": "Échec de l'ajout du graphique au tableau de bord",
|
||||
"failed_to_execute_query": "Échec de l'exécution de la requête",
|
||||
"failed_to_load_chart": "Échec du chargement du graphique",
|
||||
"failed_to_load_chart_data": "Échec du chargement des données du graphique",
|
||||
"failed_to_save_chart": "Échec de l'enregistrement du graphique",
|
||||
"field": "Champ",
|
||||
"field_label_average_score": "Score moyen",
|
||||
"field_label_collected_at": "Collecté le",
|
||||
"field_label_count": "Nombre",
|
||||
"field_label_detractor_count": "Nombre de détracteurs",
|
||||
"field_label_emotion": "Émotion",
|
||||
"field_label_field_type": "Type de champ",
|
||||
"field_label_nps_score": "Score NPS",
|
||||
"field_label_nps_value": "Valeur NPS",
|
||||
"field_label_passive_count": "Nombre de passifs",
|
||||
"field_label_promoter_count": "Nombre de promoteurs",
|
||||
"field_label_response_id": "ID de réponse",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Nom de la source",
|
||||
"field_label_source_type": "Type de source",
|
||||
"field_label_topic": "Sujet",
|
||||
"field_label_user_identifier": "Identifiant utilisateur",
|
||||
"filter_data": "Filtrer les données",
|
||||
"filters": "Filtres",
|
||||
"filters_toggle_description": "Inclure uniquement les données qui répondent aux conditions suivantes.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularité",
|
||||
"granularity_day": "Jour",
|
||||
"granularity_hour": "Heure",
|
||||
"granularity_month": "Mois",
|
||||
"granularity_quarter": "Trimestre",
|
||||
"granularity_week": "Semaine",
|
||||
"granularity_year": "Année",
|
||||
"greater_than": "supérieur à",
|
||||
"greater_than_or_equal": "supérieur ou égal à",
|
||||
"group_by": "Regrouper par",
|
||||
"group_by_description": "Décompose tes données selon une ou plusieurs dimensions. L'ordre est important si tu choisis plusieurs dimensions.",
|
||||
"group_data": "Grouper les données",
|
||||
"is_not_set": "n'est pas défini",
|
||||
"is_set": "est défini",
|
||||
"less_than": "inférieur à",
|
||||
"less_than_or_equal": "inférieur ou égal à",
|
||||
"measures": "Mesures",
|
||||
"no_charts_found": "Aucun graphique trouvé.",
|
||||
"no_dashboards_available": "Aucun tableau de bord disponible",
|
||||
"no_dashboards_create_first": "Créez d'abord un tableau de bord pour y ajouter des graphiques.",
|
||||
"no_data_available": "Aucune donnée disponible",
|
||||
"no_data_returned": "Aucune donnée retournée par la requête",
|
||||
"no_data_returned_for_chart": "Aucune donnée retournée pour le graphique",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Aucun (filtre uniquement)",
|
||||
"no_valid_data_to_display": "Aucune donnée valide à afficher",
|
||||
"not_contains": "ne contient pas",
|
||||
"not_equals": "n'est pas égal à",
|
||||
"open_chart": "Ouvrir le graphique {{name}}",
|
||||
"open_options": "Ouvrir les options du graphique",
|
||||
"or_filter_logic": "OU",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Veuillez saisir un nom de graphique",
|
||||
"please_enter_filter_values": "Merci de saisir des valeurs pour tous les filtres",
|
||||
"please_select_at_least_one_dimension": "Merci de sélectionner au moins une dimension ou de désactiver le groupement",
|
||||
"please_select_at_least_one_measure": "Veuillez sélectionner au moins une mesure",
|
||||
"please_select_dashboard": "Veuillez sélectionner un tableau de bord",
|
||||
"predefined_measures": "Mesures prédéfinies",
|
||||
"preset": "Préréglage",
|
||||
"query_executed_successfully": "Requête exécutée avec succès",
|
||||
"reset_to_ai_suggestion": "Réinitialiser à la suggestion IA",
|
||||
"save_chart": "Enregistrer le graphique",
|
||||
"save_chart_dialog_title": "Enregistrer le graphique",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Sélectionner les dimensions...",
|
||||
"select_field": "Sélectionner un champ",
|
||||
"select_measures": "Sélectionner les mesures...",
|
||||
"select_preset": "Sélectionner un préréglage",
|
||||
"showing_first_n_of": "Affichage des {{n}} premières lignes sur {{count}}",
|
||||
"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."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Ajouter {count} graphique(s)",
|
||||
"charts_add_failed": "Échec de l'ajout des graphiques au tableau de bord",
|
||||
"charts_add_partial_failure": "Échec de l'ajout de {count} graphique(s)",
|
||||
"charts_added_to_dashboard": "Graphiques ajoutés au tableau de bord",
|
||||
"charts_load_failed": "Échec du chargement des graphiques",
|
||||
"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_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.",
|
||||
"dashboard_name": "Nom du tableau de bord",
|
||||
"dashboard_name_placeholder": "Mon tableau de bord",
|
||||
"dashboard_name_required": "Le nom du tableau de bord est requis",
|
||||
"dashboard_save_failed": "Échec de l'enregistrement du tableau de bord",
|
||||
"dashboard_saved": "Tableau de bord enregistré avec succès",
|
||||
"delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce tableau de bord ? Cette action est irréversible.",
|
||||
"delete_failed": "Échec de la suppression du tableau de bord",
|
||||
"delete_success": "Tableau de bord supprimé avec succès",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Ajouter une clé API",
|
||||
"api_key": "Clé API",
|
||||
@@ -2308,6 +2505,7 @@
|
||||
"archive_not_allowed": "Vous n'êtes pas autorisé à archiver ce répertoire.",
|
||||
"are_you_sure_you_want_to_archive": "Es-tu sûr de vouloir archiver ce répertoire ? Les espaces de travail n'y auront plus accès.",
|
||||
"assign_workspaces_description": "Contrôle quels espaces de travail peuvent accéder à ce répertoire de feedback.",
|
||||
"connectors_description": "Connecteurs qui envoient des enregistrements de retour d'expérience vers ce répertoire.",
|
||||
"create_feedback_directory": "Créer un répertoire de commentaires",
|
||||
"description": "Gère les répertoires de feedback et leurs affectations aux espaces de travail.",
|
||||
"directory_archived_successfully": "Répertoire archivé avec succès",
|
||||
@@ -2319,12 +2517,13 @@
|
||||
"directory_unarchived_successfully": "Répertoire désarchivé avec succès",
|
||||
"directory_updated_successfully": "Répertoire mis à jour avec succès",
|
||||
"empty_state": "Aucun répertoire de feedback trouvé. Crée-en un pour commencer.",
|
||||
"enter_directory_name": "Saisir le nom du répertoire",
|
||||
"error_directory_has_connectors": "Impossible d'archiver un répertoire auquel des connecteurs sont liés. Supprimez d'abord tous les connecteurs.",
|
||||
"error_directory_name_duplicate": "Un répertoire d'enregistrement de feedback avec ce nom existe déjà.",
|
||||
"error_directory_name_required": "Le nom du répertoire est requis.",
|
||||
"error_directory_workspaces_invalid_org": "Certains espaces de travail spécifiés n'appartiennent pas à cette organisation.",
|
||||
"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.",
|
||||
"select_workspaces_placeholder": "Sélectionner des espaces de travail...",
|
||||
"show_archived": "Afficher les éléments archivés",
|
||||
"title": "Répertoires d'enregistrement des retours",
|
||||
@@ -3368,6 +3567,119 @@
|
||||
"team_name": "Nom de l'équipe",
|
||||
"team_settings_description": "Voir quelles équipes peuvent accéder à cet espace de travail."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Ajouter une source de feedback",
|
||||
"add_source": "Ajouter une source",
|
||||
"allowed_values": "Valeurs autorisées : {values}",
|
||||
"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",
|
||||
"connector_status_updated_successfully": "Statut du connecteur mis à jour avec succès",
|
||||
"connector_updated_successfully": "Connecteur mis à jour avec succès",
|
||||
"connectors": "Connecteurs",
|
||||
"create_mapping": "Créer un mappage",
|
||||
"created_by": "Créé par",
|
||||
"csv_at_least_one_row": "Le CSV doit contenir au moins une ligne de données.",
|
||||
"csv_columns": "Colonnes CSV",
|
||||
"csv_empty_column_headers": "Le CSV contient des en-têtes de colonnes vides. Toutes les colonnes doivent avoir un nom.",
|
||||
"csv_file_too_large": "Le fichier CSV est trop volumineux. La taille maximale est de 2 Mo.",
|
||||
"csv_files_only": "Fichiers CSV uniquement",
|
||||
"csv_import": "Importation CSV",
|
||||
"csv_import_complete": "Importation CSV terminée : {successes} réussies, {failures} échouées, {skipped} ignorées",
|
||||
"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.",
|
||||
"default_connector_name_csv": "Importation CSV",
|
||||
"default_connector_name_formbricks": "Connexion de sondage Formbricks",
|
||||
"deselect_all": "Tout désélectionner",
|
||||
"drop_a_field_here": "Déposez un champ ici",
|
||||
"drop_field_or": "Déposez un champ ou",
|
||||
"edit_csv_mapping": "Modifier le mappage CSV",
|
||||
"edit_source_connection": "Modifier la connexion source",
|
||||
"enter_name_for_source": "Entrez un nom pour cette source",
|
||||
"enter_value": "Saisir une valeur...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
|
||||
"feedback_date": "Date actuelle",
|
||||
"feedback_record_directory": "Répertoire d'enregistrements de retour d'expérience",
|
||||
"feedback_record_fields": "Champs d'enregistrement de feedback",
|
||||
"feedback_records": "Enregistrements de feedback",
|
||||
"feedback_records_refreshed": "Enregistrements de feedback actualisés",
|
||||
"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_rows": "Importer {count} lignes",
|
||||
"importing_data": "Importation des données...",
|
||||
"importing_historical_data": "Importation des données historiques...",
|
||||
"invalid_enum_values": "Valeurs non valides dans la colonne mappée à {field}",
|
||||
"invalid_values_found": "Trouvées : {values} (lignes : {rows}) {extra}",
|
||||
"load_sample_csv": "Charger un exemple de CSV",
|
||||
"n_supported_questions": "{count} questions prises en charge",
|
||||
"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...",
|
||||
"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_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.",
|
||||
"set_value": "définir la valeur",
|
||||
"setup_connection": "Configurer la connexion",
|
||||
"showing_count_loaded": "Affichage de {count} enregistrements",
|
||||
"showing_rows": "Affichage de 3 sur {count} lignes",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "Importer des feedbacks depuis des fichiers CSV",
|
||||
"source_connect_formbricks_description": "Connecter les feedbacks de vos enquêtes Formbricks",
|
||||
"source_fields": "Champs source",
|
||||
"source_name": "Nom de la source",
|
||||
"source_type": "Type de source",
|
||||
"source_type_cannot_be_changed": "Le type de source ne peut pas être modifié",
|
||||
"sources": "Sources",
|
||||
"status_active": "En cours",
|
||||
"status_completed": "Terminé",
|
||||
"status_draft": "Brouillon",
|
||||
"status_error": "Erreur",
|
||||
"status_paused": "En pause",
|
||||
"survey_has_no_questions": "Ce sondage n'a pas de questions",
|
||||
"survey_import_line": "{surveyName} : {responseCount} réponses × {questionCount} questions = {total} enregistrements de feedback",
|
||||
"total_feedback_records": "Total : {checked} sur {total} enregistrements de feedback sélectionnés parmi {surveyCount} sondages",
|
||||
"unify_feedback": "Unifier les retours",
|
||||
"update_mapping_description": "Mettre à jour la configuration de mappage pour cette source.",
|
||||
"updated_at": "Mis à jour à",
|
||||
"upload_csv_data_description": "Téléchargez un fichier CSV pour importer des données de feedback.",
|
||||
"upload_csv_file": "Télécharger un fichier CSV",
|
||||
"user_identifier": "Utilisateur",
|
||||
"value": "Valeur"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Tirez parti de chaque point de contact pour comprendre la facilité d'interaction avec le client.",
|
||||
|
||||
+314
-2
@@ -125,6 +125,8 @@
|
||||
"activity": "Tevékenység",
|
||||
"add": "Hozzáadás",
|
||||
"add_action": "Művelet 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",
|
||||
"add_logo": "Logo hozzáadása",
|
||||
"add_member": "Tag hozzáadása",
|
||||
@@ -136,6 +138,7 @@
|
||||
"allow": "Engedélyezés",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Lehetővé tétel a felhasználók számára, hogy a kérdőíven kívülre kattintva kilépjenek",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "{type} típusok törlésekor ismeretlen hiba történt",
|
||||
"analysis": "Elemzés",
|
||||
"and": "És",
|
||||
"anonymous": "Névtelen",
|
||||
"api_keys": "API-kulcsok",
|
||||
@@ -154,6 +157,8 @@
|
||||
"centered_modal": "Középre helyezett kizárólagos",
|
||||
"change_organization": "Szervezet módosítása",
|
||||
"change_workspace": "Munkaterület módosítása",
|
||||
"chart": "Diagram",
|
||||
"charts": "Diagramok",
|
||||
"choices": "Választási lehetőségek",
|
||||
"choose_organization": "Szervezet kiválasztása",
|
||||
"choose_workspace": "Munkaterület kiválasztása",
|
||||
@@ -167,7 +172,7 @@
|
||||
"code": "Kód",
|
||||
"collapse_rows": "Sorok összecsukása",
|
||||
"completed": "Befejezve",
|
||||
"configuration": "Beállítás",
|
||||
"configuration": "Konfiguráció",
|
||||
"confirm": "Megerősítés",
|
||||
"connect": "Kapcsolódás",
|
||||
"connect_formbricks": "Kapcsolódás a Formbrickshez",
|
||||
@@ -186,6 +191,7 @@
|
||||
"count_questions": "{count, plural, one {{count} kérdés} other {{count} kérdés}}",
|
||||
"count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}",
|
||||
"count_selections": "{count, plural, one {{count} kiválasztás} other {{count} kiválasztás}}",
|
||||
"create": "Létrehozás",
|
||||
"create_new_organization": "Új szervezet létrehozása",
|
||||
"create_segment": "Szakasz létrehozása",
|
||||
"create_survey": "Kérdőív létrehozása",
|
||||
@@ -195,6 +201,8 @@
|
||||
"created_by": "Létrehozta",
|
||||
"customer_success": "Ügyfélsiker",
|
||||
"dark_overlay": "Sötét rávetítés",
|
||||
"dashboard": "Vezérlőpult",
|
||||
"dashboards": "Irányítópultok",
|
||||
"date": "Dátum",
|
||||
"days": "nap",
|
||||
"default": "Alapértelmezett",
|
||||
@@ -218,6 +226,7 @@
|
||||
"edit": "Szerkesztés",
|
||||
"elements": "Elemek",
|
||||
"email": "E-mail",
|
||||
"enable": "Engedélyezés",
|
||||
"ending_card": "Befejező kártya",
|
||||
"enter_url": "URL megadása",
|
||||
"enterprise_license": "Vállalati licenc",
|
||||
@@ -232,6 +241,7 @@
|
||||
"failed_to_copy_to_clipboard": "Nem sikerült másolni a vágólapra",
|
||||
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
|
||||
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
|
||||
"failed_to_parse_csv": "A CSV elemzése sikertelen",
|
||||
"filter": "Szűrő",
|
||||
"finish": "Befejezés",
|
||||
"first_name": "Keresztnév",
|
||||
@@ -246,6 +256,7 @@
|
||||
"hidden": "Rejtett",
|
||||
"hidden_field": "Rejtett mező",
|
||||
"hidden_fields": "Rejtett mezők",
|
||||
"hide": "Elrejtés",
|
||||
"hide_column": "Oszlop elrejtése",
|
||||
"id": "Azonosító",
|
||||
"image": "Kép",
|
||||
@@ -292,6 +303,7 @@
|
||||
"mobile_overlay_surveys_look_good": "Ne aggódjon – a kérdőívei minden eszközön és képernyőméretnél remekül néznek ki!",
|
||||
"mobile_overlay_title": "Hoppá, apró képernyő észlelve!",
|
||||
"months": "hónap",
|
||||
"more_options": "További lehetőségek",
|
||||
"move_down": "Mozgatás le",
|
||||
"move_up": "Mozgatás fel",
|
||||
"multiple_languages": "Több nyelv",
|
||||
@@ -300,8 +312,10 @@
|
||||
"new": "Új",
|
||||
"new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!",
|
||||
"next": "Következő",
|
||||
"no": "Nem",
|
||||
"no_actions_found": "Nem találhatók műveletek",
|
||||
"no_background_image_found": "Nem található háttérkép.",
|
||||
"no_changes": "Nincsenek változások",
|
||||
"no_code": "Kód nélkül",
|
||||
"no_files_uploaded": "Nem lettek fájlok feltöltve",
|
||||
"no_overlay": "Nincs rávetítés",
|
||||
@@ -323,6 +337,7 @@
|
||||
"on": "Be",
|
||||
"only_one_file_allowed": "Csak egy fájl engedélyezett",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
|
||||
"open_options": "Beállítások megnyitása",
|
||||
"option_id": "Választásazonosító",
|
||||
"option_ids": "Választásazonosítók",
|
||||
"optional": "Elhagyható",
|
||||
@@ -365,6 +380,7 @@
|
||||
"quotas_description": "A bizonyos feltételeknek megfelelő résztvevőktől kapott válaszok számának korlátozása.",
|
||||
"read_docs": "Dokumentáció elolvasása",
|
||||
"recipients": "Címzettek",
|
||||
"refresh": "Frissítés",
|
||||
"remove": "Eltávolítás",
|
||||
"remove_from_team": "Eltávolítás a csapatból",
|
||||
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
|
||||
@@ -376,6 +392,7 @@
|
||||
"response_id": "Válaszazonosító",
|
||||
"responses": "Válaszok",
|
||||
"restart": "Újraindítás",
|
||||
"retry": "Újra",
|
||||
"role": "Szerep",
|
||||
"saas": "SaaS",
|
||||
"sales": "Értékesítés",
|
||||
@@ -384,6 +401,7 @@
|
||||
"save_changes": "Változtatások mentése",
|
||||
"saving": "Mentés",
|
||||
"search": "Keresés",
|
||||
"search_charts": "Diagramok keresése...",
|
||||
"security": "Biztonság",
|
||||
"segment": "Szakasz",
|
||||
"segments": "Szakaszok",
|
||||
@@ -447,6 +465,7 @@
|
||||
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakából",
|
||||
"try_again": "Próbálja újra",
|
||||
"type": "Típus",
|
||||
"unify": "Egyesítés",
|
||||
"unknown_survey": "Ismeretlen kérdőív",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Több munkaterület feloldása egy magasabb csomaggal.",
|
||||
"update": "Frissítés",
|
||||
@@ -464,6 +483,7 @@
|
||||
"variables": "Változók",
|
||||
"verified_email": "Ellenőrzött e-mail-cím",
|
||||
"video": "Videó",
|
||||
"view": "Megtekintés",
|
||||
"warning": "Figyelmeztetés",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nem tudtuk ellenőrizni a licencét, mert a licenckiszolgáló nem érhető el.",
|
||||
"webhook": "Webhorog",
|
||||
@@ -482,6 +502,7 @@
|
||||
"workspace_name_placeholder": "például Formbricks",
|
||||
"workspaces": "Munkaterületek",
|
||||
"years": "év",
|
||||
"yes": "Igen",
|
||||
"you": "Ön",
|
||||
"you_are_downgraded_to_the_community_edition": "Visszaváltott a közösségi kiadásra.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Nincs felhatalmazva ennek a műveletnek a végrehajtásához.",
|
||||
@@ -1610,6 +1631,182 @@
|
||||
"your_survey_would_be_shown_on_this_url": "A kérdőív ezen az URL-en jelenne meg.",
|
||||
"your_survey_would_not_be_shown": "A kérdőív nem jelenne meg."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "VAGY",
|
||||
"add_chart_to_dashboard": "Diagram hozzáadása a vezérlőpulthoz",
|
||||
"add_chart_to_dashboard_description": "Válassz egy vezérlőpultot, amelyhez hozzáadod ezt a diagramot. A diagram automatikusan mentésre kerül.",
|
||||
"add_filter": "Szűrő hozzáadása",
|
||||
"add_to_dashboard": "Hozzáadás a vezérlőpulthoz",
|
||||
"advanced_chart_builder_config_prompt": "Állítsd be a diagramot, és kattints a \"Lekérdezés futtatása\" gombra az előnézethez",
|
||||
"ai_query_placeholder": "pl. Hány felhasználó regisztrált a múlt héten?",
|
||||
"ai_query_section_description": "Írd le, mit szeretnél látni, és hagyd, hogy az AI elkészítse a diagramot.",
|
||||
"ai_query_section_title": "Kérdezd meg az adataidat",
|
||||
"and_filter_logic": "ÉS",
|
||||
"apply_changes": "Módosítások alkalmazása",
|
||||
"chart": "Diagram",
|
||||
"chart_added_to_dashboard": "A diagram hozzáadva a vezérlőpulthoz!",
|
||||
"chart_builder_choose_chart_type": "Válassz diagramtípust",
|
||||
"chart_data": "Diagram adatai",
|
||||
"chart_data_tab": "Adatok",
|
||||
"chart_deleted_successfully": "A diagram sikeresen törölve",
|
||||
"chart_deletion_error": "A diagram törlése sikertelen",
|
||||
"chart_duplicated_successfully": "A diagram sikeresen duplikálva",
|
||||
"chart_duplication_error": "A diagram duplikálása sikertelen",
|
||||
"chart_name": "Diagram neve",
|
||||
"chart_name_placeholder": "Diagram neve",
|
||||
"chart_preview": "Diagram előnézete",
|
||||
"chart_render_error": "Hiba történt a diagram megjelenítése során.",
|
||||
"chart_saved_successfully": "A diagram sikeresen mentve!",
|
||||
"chart_type_area": "Területdiagram",
|
||||
"chart_type_bar": "Oszlopdiagram",
|
||||
"chart_type_big_number": "Nagy szám",
|
||||
"chart_type_line": "Vonaldiagram",
|
||||
"chart_type_not_supported": "A(z) \"{{chartType}}\" diagramtípus még nem támogatott",
|
||||
"chart_type_pie": "Kördiagram",
|
||||
"chart_updated_successfully": "A diagram sikeresen frissítve!",
|
||||
"configure_description": "Módosítsd a diagram típusát és egyéb beállításait ehhez a vizualizációhoz.",
|
||||
"configure_title": "Diagram konfigurálása",
|
||||
"configure_type_label": "Diagram típusa",
|
||||
"contains": "tartalmazza",
|
||||
"create_chart": "Diagram létrehozása",
|
||||
"create_chart_description": "Használj mesterséges intelligenciát diagram generálásához, vagy készíts egyet manuálisan.",
|
||||
"create_chart_with_ai": "Diagram létrehozása mesterséges intelligenciával",
|
||||
"custom_range": "Egyéni tartomány",
|
||||
"dashboard": "Vezérlőpult",
|
||||
"dashboard_select_placeholder": "Válassz egy vezérlőpultot",
|
||||
"data_label": "Adat",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Elmúlt 30 nap",
|
||||
"date_preset_last_7_days": "Elmúlt 7 nap",
|
||||
"date_preset_last_month": "Elmúlt hónap",
|
||||
"date_preset_this_month": "Ez a hónap",
|
||||
"date_preset_this_quarter": "Ez a negyedév",
|
||||
"date_preset_this_year": "Ez az év",
|
||||
"date_preset_today": "Ma",
|
||||
"date_preset_yesterday": "Tegnap",
|
||||
"date_range": "Dátumtartomány",
|
||||
"delete_chart_confirmation": "Biztosan törölni szeretnéd ezt a diagramot?",
|
||||
"dimensions": "Dimenziók",
|
||||
"dimensions_toggle_description": "Adatok csoportosítása hangulat, kérdéstípus és egyéb dimenziók szerint.",
|
||||
"edit_chart_description": "Tekintsd meg és szerkeszd a diagram konfigurációját.",
|
||||
"edit_chart_title": "Diagram szerkesztése",
|
||||
"enable_time_dimension": "Időbeli dimenzió engedélyezése",
|
||||
"end_date": "Befejezési dátum",
|
||||
"enter_a_name_for_your_chart": "Add meg a diagram nevét a mentéshez.",
|
||||
"enter_value": "Adj meg értéket",
|
||||
"equals": "egyenlő",
|
||||
"failed_to_add_chart_to_dashboard": "A diagram hozzáadása a vezérlőpulthoz sikertelen",
|
||||
"failed_to_execute_query": "A lekérdezés végrehajtása sikertelen",
|
||||
"failed_to_load_chart": "A diagram betöltése sikertelen",
|
||||
"failed_to_load_chart_data": "A diagram adatainak betöltése sikertelen",
|
||||
"failed_to_save_chart": "A diagram mentése sikertelen",
|
||||
"field": "Mező",
|
||||
"field_label_average_score": "Átlagos pontszám",
|
||||
"field_label_collected_at": "Gyűjtve",
|
||||
"field_label_count": "Darabszám",
|
||||
"field_label_detractor_count": "Kritikusok száma",
|
||||
"field_label_emotion": "Érzelem",
|
||||
"field_label_field_type": "Mező típusa",
|
||||
"field_label_nps_score": "NPS pontszám",
|
||||
"field_label_nps_value": "NPS érték",
|
||||
"field_label_passive_count": "Passzívak száma",
|
||||
"field_label_promoter_count": "Támogatók száma",
|
||||
"field_label_response_id": "Válaszazonosító",
|
||||
"field_label_sentiment": "Hangulat",
|
||||
"field_label_source_name": "Forrás neve",
|
||||
"field_label_source_type": "Forrás típusa",
|
||||
"field_label_topic": "Téma",
|
||||
"field_label_user_identifier": "Felhasználóazonosító",
|
||||
"filter_data": "Adatok szűrése",
|
||||
"filters": "Szűrők",
|
||||
"filters_toggle_description": "Csak azokat az adatokat tartalmazza, amelyek megfelelnek a következő feltételeknek.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Részletesség",
|
||||
"granularity_day": "Nap",
|
||||
"granularity_hour": "óra",
|
||||
"granularity_month": "hónap",
|
||||
"granularity_quarter": "negyedév",
|
||||
"granularity_week": "hét",
|
||||
"granularity_year": "év",
|
||||
"greater_than": "nagyobb mint",
|
||||
"greater_than_or_equal": "nagyobb vagy egyenlő",
|
||||
"group_by": "Csoportosítás",
|
||||
"group_by_description": "Bontsa le adatait egy vagy több dimenzió szerint. A sorrend fontos, ha több dimenziót választ.",
|
||||
"group_data": "Adatok csoportosítása",
|
||||
"is_not_set": "nincs beállítva",
|
||||
"is_set": "beállítva",
|
||||
"less_than": "kisebb mint",
|
||||
"less_than_or_equal": "kisebb vagy egyenlő",
|
||||
"measures": "Mérőszámok",
|
||||
"no_charts_found": "Nem található diagram.",
|
||||
"no_dashboards_available": "Nincsenek elérhető vezérlőpultok",
|
||||
"no_dashboards_create_first": "Először hozz létre egy vezérlőpultot, hogy diagramokat adhass hozzá.",
|
||||
"no_data_available": "Nincsenek elérhető adatok",
|
||||
"no_data_returned": "A lekérdezés nem adott vissza adatokat",
|
||||
"no_data_returned_for_chart": "A diagram nem adott vissza adatokat",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Nincs (csak szűrés)",
|
||||
"no_valid_data_to_display": "Nincsenek megjeleníthető érvényes adatok",
|
||||
"not_contains": "nem tartalmazza",
|
||||
"not_equals": "nem egyenlő",
|
||||
"open_chart": "{{name}} diagram megnyitása",
|
||||
"open_options": "Diagram beállításainak megnyitása",
|
||||
"or_filter_logic": "VAGY",
|
||||
"original": "Eredeti",
|
||||
"please_enter_chart_name": "Kérjük, add meg a diagram nevét",
|
||||
"please_enter_filter_values": "Kérem, adjon meg értékeket az összes szűrőhöz",
|
||||
"please_select_at_least_one_dimension": "Kérem, válasszon legalább egy dimenziót, vagy tiltsa le a csoportosítást",
|
||||
"please_select_at_least_one_measure": "Kérjük, válassz ki legalább egy mérőszámot",
|
||||
"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",
|
||||
"query_executed_successfully": "Lekérdezés sikeresen végrehajtva",
|
||||
"reset_to_ai_suggestion": "Visszaállítás AI javaslatra",
|
||||
"save_chart": "Diagram mentése",
|
||||
"save_chart_dialog_title": "Diagram mentése",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Dimenziók kiválasztása...",
|
||||
"select_field": "Mező kiválasztása",
|
||||
"select_measures": "Mérőszámok kiválasztása...",
|
||||
"select_preset": "Előbeállítás kiválasztása",
|
||||
"showing_first_n_of": "Az első {{n}} sor megjelenítése {{count}} sorból",
|
||||
"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."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} diagram hozzáadása",
|
||||
"charts_add_failed": "A diagramok műszerfalhoz való hozzáadása sikertelen",
|
||||
"charts_add_partial_failure": "{count} diagram hozzáadása sikertelen",
|
||||
"charts_added_to_dashboard": "Diagramok hozzáadva a műszerfalhoz",
|
||||
"charts_load_failed": "A diagramok betöltése sikertelen",
|
||||
"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_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.",
|
||||
"dashboard_name": "Vezérlőpult neve",
|
||||
"dashboard_name_placeholder": "Saját vezérlőpult",
|
||||
"dashboard_name_required": "Az irányítópult neve kötelező",
|
||||
"dashboard_save_failed": "Az irányítópult mentése sikertelen",
|
||||
"dashboard_saved": "Az irányítópult sikeresen mentve",
|
||||
"delete_confirmation": "Biztosan törölni szeretné ezt a vezérlőpultot? Ez a művelet nem vonható vissza.",
|
||||
"delete_failed": "A vezérlőpult törlése sikertelen",
|
||||
"delete_success": "A vezérlőpult sikeresen törölve",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "API-kulcs hozzáadása",
|
||||
"api_key": "API-kulcs",
|
||||
@@ -2308,6 +2505,7 @@
|
||||
"archive_not_allowed": "Nem rendelkezik jogosultsággal ezen könyvtár archiválásához.",
|
||||
"are_you_sure_you_want_to_archive": "Biztosan archiválni kívánja ezt a könyvtárat? A munkaterületek többé nem férhetnek hozzá.",
|
||||
"assign_workspaces_description": "Szabályozza, mely munkaterületek férhetnek hozzá ehhez a visszajelzési nyilvántartási könyvtárhoz.",
|
||||
"connectors_description": "Csatlakozók, amelyek visszajelzési rekordokat küldenek ebbe a könyvtárba.",
|
||||
"create_feedback_directory": "Visszajelzési könyvtár létrehozása",
|
||||
"description": "Visszajelzési nyilvántartási könyvtárak és munkaterület-hozzárendeléseik kezelése.",
|
||||
"directory_archived_successfully": "A könyvtár sikeresen archiválva",
|
||||
@@ -2319,12 +2517,13 @@
|
||||
"directory_unarchived_successfully": "A könyvtár archiválása sikeresen visszavonva",
|
||||
"directory_updated_successfully": "A könyvtár sikeresen frissítve",
|
||||
"empty_state": "Nem található visszajelzési nyilvántartási könyvtár. Hozzon létre egyet a kezdéshez.",
|
||||
"enter_directory_name": "Adja meg a könyvtár nevét",
|
||||
"error_directory_has_connectors": "Nem archiválható olyan könyvtár, amelyhez csatlakozók vannak társítva. Először távolítson el minden csatlakozót.",
|
||||
"error_directory_name_duplicate": "Ezzel a névvel már létezik visszajelzési rekord könyvtár.",
|
||||
"error_directory_name_required": "A könyvtár neve kötelező megadni.",
|
||||
"error_directory_workspaces_invalid_org": "Egyes megadott munkaterületek nem ehhez a szervezethez tartoznak.",
|
||||
"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.",
|
||||
"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",
|
||||
@@ -3368,6 +3567,119 @@
|
||||
"team_name": "Csapat neve",
|
||||
"team_settings_description": "Annak megtekintése, hogy mely csapatok férhetnek hozzá ehhez a munkaterülethez."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Visszajelzési forrás hozzáadása",
|
||||
"add_source": "Forrás hozzáadása",
|
||||
"allowed_values": "Engedélyezett értékek: {values}",
|
||||
"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",
|
||||
"connector_status_updated_successfully": "Csatlakozó állapota sikeresen frissítve",
|
||||
"connector_updated_successfully": "Csatlakozó sikeresen frissítve",
|
||||
"connectors": "Csatlakozók",
|
||||
"create_mapping": "Leképezés létrehozása",
|
||||
"created_by": "Létrehozta",
|
||||
"csv_at_least_one_row": "A CSV-nek legalább egy adatsort kell tartalmaznia.",
|
||||
"csv_columns": "CSV oszlopok",
|
||||
"csv_empty_column_headers": "A CSV üres oszlopfejléceket tartalmaz. Minden oszlopnak rendelkeznie kell névvel.",
|
||||
"csv_file_too_large": "A CSV fájl túl nagy. A maximális méret 2 MB.",
|
||||
"csv_files_only": "Csak CSV fájlok",
|
||||
"csv_import": "CSV importálás",
|
||||
"csv_import_complete": "CSV importálás befejezve: {successes} sikeres, {failures} sikertelen, {skipped} kihagyva",
|
||||
"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.",
|
||||
"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",
|
||||
"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",
|
||||
"edit_source_connection": "Forráskapcsolat szerkesztése",
|
||||
"enter_name_for_source": "Adj nevet ennek a forrásnak",
|
||||
"enter_value": "Érték megadása...",
|
||||
"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_directory": "Visszajelzési Rekord Könyvtár",
|
||||
"feedback_record_fields": "Visszajelzési rekord mezők",
|
||||
"feedback_records": "Visszajelzési rekordok",
|
||||
"feedback_records_refreshed": "Visszajelzési rekordok frissítve",
|
||||
"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_rows": "{count} sor importálása",
|
||||
"importing_data": "Adatok importálása...",
|
||||
"importing_historical_data": "Történeti adatok importálása...",
|
||||
"invalid_enum_values": "Érvénytelen értékek a(z) {field} mezőhöz rendelt oszlopban",
|
||||
"invalid_values_found": "Talált értékek: {values} (sorok: {rows}) {extra}",
|
||||
"load_sample_csv": "Minta CSV betöltése",
|
||||
"n_supported_questions": "{count} támogatott kérdé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...",
|
||||
"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_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.",
|
||||
"set_value": "érték beállítása",
|
||||
"setup_connection": "Kapcsolat beállítása",
|
||||
"showing_count_loaded": "{count} rekord megjelenítése",
|
||||
"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_formbricks_description": "Visszajelzések csatlakoztatása a Formbricks kérdőívekből",
|
||||
"source_fields": "Forrásmezők",
|
||||
"source_name": "Forrásnév",
|
||||
"source_type": "Forrás típus",
|
||||
"source_type_cannot_be_changed": "A forrástípus nem módosítható",
|
||||
"sources": "Források",
|
||||
"status_active": "Folyamatban",
|
||||
"status_completed": "Befejezve",
|
||||
"status_draft": "Piszkozat",
|
||||
"status_error": "Hiba",
|
||||
"status_paused": "Szüneteltetve",
|
||||
"survey_has_no_questions": "Ez a felmérés nem tartalmaz kérdéseket",
|
||||
"survey_import_line": "{surveyName}: {responseCount} válasz × {questionCount} kérdés = {total} visszajelzési rekord",
|
||||
"total_feedback_records": "Összesen: {checked} / {total} visszajelzési rekord kiválasztva {surveyCount} felmérésből",
|
||||
"unify_feedback": "Visszajelzések egyesítése",
|
||||
"update_mapping_description": "Frissítse a leképezési konfigurációt ehhez a forráshoz.",
|
||||
"updated_at": "Frissítve",
|
||||
"upload_csv_data_description": "Tölts fel egy CSV fájlt a visszajelzési adatok importálásához.",
|
||||
"upload_csv_file": "CSV fájl feltöltése",
|
||||
"user_identifier": "Felhasználó",
|
||||
"value": "Érték"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Minden érintkezési pont kihasználása az ügyfelekkel való interakció egyszerűségének megértéséhez.",
|
||||
|
||||
+313
-1
@@ -125,6 +125,8 @@
|
||||
"activity": "アクティビティ",
|
||||
"add": "追加",
|
||||
"add_action": "アクションを追加",
|
||||
"add_charts": "グラフを追加",
|
||||
"add_existing_chart_description": "このダッシュボードに追加するグラフを検索して選択してください。",
|
||||
"add_filter": "フィルターを追加",
|
||||
"add_logo": "ロゴを追加",
|
||||
"add_member": "メンバーを追加",
|
||||
@@ -136,6 +138,7 @@
|
||||
"allow": "許可",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
|
||||
"analysis": "分析",
|
||||
"and": "および",
|
||||
"anonymous": "匿名",
|
||||
"api_keys": "APIキー",
|
||||
@@ -154,6 +157,8 @@
|
||||
"centered_modal": "中央モーダル",
|
||||
"change_organization": "組織を変更",
|
||||
"change_workspace": "ワークスペースを変更",
|
||||
"chart": "チャート",
|
||||
"charts": "チャート",
|
||||
"choices": "選択肢",
|
||||
"choose_organization": "組織を選択",
|
||||
"choose_workspace": "ワークスペースを選択",
|
||||
@@ -186,6 +191,7 @@
|
||||
"count_questions": "{count, plural, other {# 件の質問}}",
|
||||
"count_responses": "{count, plural, other {{count} 件の回答}}",
|
||||
"count_selections": "{count, plural, other {{count} 件の選択}}",
|
||||
"create": "作成",
|
||||
"create_new_organization": "新しい組織を作成",
|
||||
"create_segment": "セグメントを作成",
|
||||
"create_survey": "フォームを作成",
|
||||
@@ -195,6 +201,8 @@
|
||||
"created_by": "作成者",
|
||||
"customer_success": "カスタマーサクセス",
|
||||
"dark_overlay": "暗いオーバーレイ",
|
||||
"dashboard": "ダッシュボード",
|
||||
"dashboards": "ダッシュボード",
|
||||
"date": "日付",
|
||||
"days": "日",
|
||||
"default": "デフォルト",
|
||||
@@ -218,6 +226,7 @@
|
||||
"edit": "編集",
|
||||
"elements": "要素",
|
||||
"email": "メールアドレス",
|
||||
"enable": "有効化",
|
||||
"ending_card": "終了カード",
|
||||
"enter_url": "URLを入力",
|
||||
"enterprise_license": "エンタープライズライセンス",
|
||||
@@ -232,6 +241,7 @@
|
||||
"failed_to_copy_to_clipboard": "クリップボードへのコピーに失敗しました",
|
||||
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
||||
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
|
||||
"failed_to_parse_csv": "CSVの解析に失敗しました",
|
||||
"filter": "フィルター",
|
||||
"finish": "完了",
|
||||
"first_name": "名",
|
||||
@@ -246,6 +256,7 @@
|
||||
"hidden": "非表示",
|
||||
"hidden_field": "非表示フィールド",
|
||||
"hidden_fields": "非表示フィールド",
|
||||
"hide": "非表示",
|
||||
"hide_column": "列を非表示",
|
||||
"id": "ID",
|
||||
"image": "画像",
|
||||
@@ -292,6 +303,7 @@
|
||||
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
|
||||
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
|
||||
"months": "ヶ月",
|
||||
"more_options": "その他のオプション",
|
||||
"move_down": "下に移動",
|
||||
"move_up": "上に移動",
|
||||
"multiple_languages": "多言語",
|
||||
@@ -300,8 +312,10 @@
|
||||
"new": "新規",
|
||||
"new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!",
|
||||
"next": "次へ",
|
||||
"no": "いいえ",
|
||||
"no_actions_found": "アクションが見つかりません",
|
||||
"no_background_image_found": "背景画像が見つかりません。",
|
||||
"no_changes": "変更なし",
|
||||
"no_code": "ノーコード",
|
||||
"no_files_uploaded": "ファイルがアップロードされていません",
|
||||
"no_overlay": "オーバーレイなし",
|
||||
@@ -323,6 +337,7 @@
|
||||
"on": "オン",
|
||||
"only_one_file_allowed": "ファイルは1つのみ許可されています",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "このアクションを実行できるのは、オーナーと管理者のみです。",
|
||||
"open_options": "オプションを開く",
|
||||
"option_id": "オプションID",
|
||||
"option_ids": "オプションID",
|
||||
"optional": "任意",
|
||||
@@ -365,6 +380,7 @@
|
||||
"quotas_description": "特定の基準を満たす参加者からの回答数を制限する",
|
||||
"read_docs": "ドキュメントを読む",
|
||||
"recipients": "受信者",
|
||||
"refresh": "更新",
|
||||
"remove": "削除",
|
||||
"remove_from_team": "チームから削除",
|
||||
"reorder_and_hide_columns": "列の並び替えと非表示",
|
||||
@@ -376,6 +392,7 @@
|
||||
"response_id": "回答ID",
|
||||
"responses": "回答",
|
||||
"restart": "再開",
|
||||
"retry": "再試行",
|
||||
"role": "役割",
|
||||
"saas": "SaaS",
|
||||
"sales": "セールス",
|
||||
@@ -384,6 +401,7 @@
|
||||
"save_changes": "変更を保存",
|
||||
"saving": "保存中",
|
||||
"search": "検索",
|
||||
"search_charts": "グラフを検索...",
|
||||
"security": "セキュリティ",
|
||||
"segment": "セグメント",
|
||||
"segments": "セグメント",
|
||||
@@ -447,6 +465,7 @@
|
||||
"trial_one_day_remaining": "トライアル期間の残り1日",
|
||||
"try_again": "もう一度お試しください",
|
||||
"type": "種類",
|
||||
"unify": "統合",
|
||||
"unknown_survey": "不明なフォーム",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "上位プランでより多くのワークスペースを利用できます。",
|
||||
"update": "更新",
|
||||
@@ -464,6 +483,7 @@
|
||||
"variables": "変数",
|
||||
"verified_email": "認証済みメールアドレス",
|
||||
"video": "動画",
|
||||
"view": "表示",
|
||||
"warning": "警告",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "ライセンスサーバーにアクセスできないため、ライセンスを認証できませんでした。",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +502,7 @@
|
||||
"workspace_name_placeholder": "例: Formbricks",
|
||||
"workspaces": "ワークスペース",
|
||||
"years": "年",
|
||||
"yes": "はい",
|
||||
"you": "あなた",
|
||||
"you_are_downgraded_to_the_community_edition": "コミュニティ版にダウングレードされました。",
|
||||
"you_are_not_authorized_to_perform_this_action": "このアクションを実行する権限がありません。",
|
||||
@@ -1610,6 +1631,182 @@
|
||||
"your_survey_would_be_shown_on_this_url": "あなたのフォームはこのURLに表示されます。",
|
||||
"your_survey_would_not_be_shown": "あなたのフォームは表示されません。"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "OR",
|
||||
"add_chart_to_dashboard": "ダッシュボードにチャートを追加",
|
||||
"add_chart_to_dashboard_description": "このチャートを追加するダッシュボードを選択してください。チャートは自動的に保存されます。",
|
||||
"add_filter": "フィルターを追加",
|
||||
"add_to_dashboard": "ダッシュボードに追加",
|
||||
"advanced_chart_builder_config_prompt": "チャートを設定して「クエリを実行」をクリックしてプレビューを表示",
|
||||
"ai_query_placeholder": "例: 先週何人のユーザーが登録しましたか?",
|
||||
"ai_query_section_description": "表示したい内容を説明すると、AIがチャートを作成します。",
|
||||
"ai_query_section_title": "データに質問する",
|
||||
"and_filter_logic": "AND",
|
||||
"apply_changes": "変更を適用",
|
||||
"chart": "チャート",
|
||||
"chart_added_to_dashboard": "チャートをダッシュボードに追加しました!",
|
||||
"chart_builder_choose_chart_type": "チャートタイプを選択",
|
||||
"chart_data": "チャートデータ",
|
||||
"chart_data_tab": "データ",
|
||||
"chart_deleted_successfully": "チャートを削除しました",
|
||||
"chart_deletion_error": "チャートの削除に失敗しました",
|
||||
"chart_duplicated_successfully": "チャートを複製しました",
|
||||
"chart_duplication_error": "チャートの複製に失敗しました",
|
||||
"chart_name": "チャート名",
|
||||
"chart_name_placeholder": "チャート名",
|
||||
"chart_preview": "チャートプレビュー",
|
||||
"chart_render_error": "このチャートの表示中に問題が発生しました。",
|
||||
"chart_saved_successfully": "チャートを保存しました!",
|
||||
"chart_type_area": "エリアチャート",
|
||||
"chart_type_bar": "棒グラフ",
|
||||
"chart_type_big_number": "大きな数値",
|
||||
"chart_type_line": "折れ線グラフ",
|
||||
"chart_type_not_supported": "チャートタイプ「{{chartType}}」はまだサポートされていません",
|
||||
"chart_type_pie": "円グラフ",
|
||||
"chart_updated_successfully": "チャートを更新しました!",
|
||||
"configure_description": "このビジュアライゼーションのチャートタイプやその他の設定を変更します。",
|
||||
"configure_title": "チャートを設定",
|
||||
"configure_type_label": "チャートタイプ",
|
||||
"contains": "を含む",
|
||||
"create_chart": "グラフを作成",
|
||||
"create_chart_description": "AIを使用してチャートを生成するか、手動で作成します。",
|
||||
"create_chart_with_ai": "AIでグラフを作成",
|
||||
"custom_range": "カスタム範囲",
|
||||
"dashboard": "ダッシュボード",
|
||||
"dashboard_select_placeholder": "ダッシュボードを選択",
|
||||
"data_label": "データ",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "過去30日間",
|
||||
"date_preset_last_7_days": "過去7日間",
|
||||
"date_preset_last_month": "先月",
|
||||
"date_preset_this_month": "今月",
|
||||
"date_preset_this_quarter": "今四半期",
|
||||
"date_preset_this_year": "今年",
|
||||
"date_preset_today": "今日",
|
||||
"date_preset_yesterday": "昨日",
|
||||
"date_range": "日付範囲",
|
||||
"delete_chart_confirmation": "このチャートを削除してもよろしいですか?",
|
||||
"dimensions": "ディメンション",
|
||||
"dimensions_toggle_description": "センチメント、質問タイプ、その他のディメンションでデータをグループ化します。",
|
||||
"edit_chart_description": "チャート設定を表示および編集します。",
|
||||
"edit_chart_title": "チャートを編集",
|
||||
"enable_time_dimension": "時間ディメンションを有効化",
|
||||
"end_date": "終了日",
|
||||
"enter_a_name_for_your_chart": "チャートを保存するには名前を入力してください。",
|
||||
"enter_value": "値を入力",
|
||||
"equals": "と等しい",
|
||||
"failed_to_add_chart_to_dashboard": "ダッシュボードへのチャート追加に失敗しました",
|
||||
"failed_to_execute_query": "クエリの実行に失敗しました",
|
||||
"failed_to_load_chart": "チャートの読み込みに失敗しました",
|
||||
"failed_to_load_chart_data": "チャートデータの読み込みに失敗しました",
|
||||
"failed_to_save_chart": "チャートの保存に失敗しました",
|
||||
"field": "フィールド",
|
||||
"field_label_average_score": "平均スコア",
|
||||
"field_label_collected_at": "収集日時",
|
||||
"field_label_count": "カウント",
|
||||
"field_label_detractor_count": "批判者数",
|
||||
"field_label_emotion": "感情",
|
||||
"field_label_field_type": "フィールドタイプ",
|
||||
"field_label_nps_score": "NPSスコア",
|
||||
"field_label_nps_value": "NPS値",
|
||||
"field_label_passive_count": "中立者数",
|
||||
"field_label_promoter_count": "推奨者数",
|
||||
"field_label_response_id": "回答ID",
|
||||
"field_label_sentiment": "感情分析",
|
||||
"field_label_source_name": "ソース名",
|
||||
"field_label_source_type": "ソースタイプ",
|
||||
"field_label_topic": "トピック",
|
||||
"field_label_user_identifier": "ユーザー識別子",
|
||||
"filter_data": "データをフィルター",
|
||||
"filters": "フィルター",
|
||||
"filters_toggle_description": "以下の条件を満たすデータのみを含めます。",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "粒度",
|
||||
"granularity_day": "日",
|
||||
"granularity_hour": "時間",
|
||||
"granularity_month": "月",
|
||||
"granularity_quarter": "四半期",
|
||||
"granularity_week": "週",
|
||||
"granularity_year": "年",
|
||||
"greater_than": "より大きい",
|
||||
"greater_than_or_equal": "以上",
|
||||
"group_by": "グループ化",
|
||||
"group_by_description": "1つ以上のディメンションでデータを分類します。複数のディメンションを選択する場合、順序が重要です。",
|
||||
"group_data": "データをグループ化",
|
||||
"is_not_set": "設定されていない",
|
||||
"is_set": "設定されている",
|
||||
"less_than": "より小さい",
|
||||
"less_than_or_equal": "以下",
|
||||
"measures": "メジャー",
|
||||
"no_charts_found": "チャートが見つかりません。",
|
||||
"no_dashboards_available": "利用可能なダッシュボードがありません",
|
||||
"no_dashboards_create_first": "チャートを追加するには、まずダッシュボードを作成してください。",
|
||||
"no_data_available": "利用可能なデータがありません",
|
||||
"no_data_returned": "クエリからデータが返されませんでした",
|
||||
"no_data_returned_for_chart": "チャートのデータが返されませんでした",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "なし(フィルターのみ)",
|
||||
"no_valid_data_to_display": "表示する有効なデータがありません",
|
||||
"not_contains": "を含まない",
|
||||
"not_equals": "と等しくない",
|
||||
"open_chart": "チャート{{name}}を開く",
|
||||
"open_options": "チャートオプションを開く",
|
||||
"or_filter_logic": "OR",
|
||||
"original": "オリジナル",
|
||||
"please_enter_chart_name": "チャート名を入力してください",
|
||||
"please_enter_filter_values": "すべてのフィルターに値を入力してください",
|
||||
"please_select_at_least_one_dimension": "少なくとも1つのディメンションを選択するか、グループ化を無効にしてください",
|
||||
"please_select_at_least_one_measure": "少なくとも1つのメジャーを選択してください",
|
||||
"please_select_dashboard": "ダッシュボードを選択してください",
|
||||
"predefined_measures": "事前定義されたメジャー",
|
||||
"preset": "プリセット",
|
||||
"query_executed_successfully": "クエリが正常に実行されました",
|
||||
"reset_to_ai_suggestion": "AIの提案にリセット",
|
||||
"save_chart": "チャートを保存",
|
||||
"save_chart_dialog_title": "チャートを保存",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "ディメンションを選択...",
|
||||
"select_field": "フィールドを選択",
|
||||
"select_measures": "メジャーを選択...",
|
||||
"select_preset": "プリセットを選択",
|
||||
"showing_first_n_of": "{{count}}行中、最初の{{n}}行を表示",
|
||||
"start_date": "開始日",
|
||||
"time_dimension": "時間ディメンション",
|
||||
"time_dimension_title": "時間ベースのグループ化を追加",
|
||||
"time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count}個のグラフを追加",
|
||||
"charts_add_failed": "ダッシュボードへのグラフの追加に失敗しました",
|
||||
"charts_add_partial_failure": "{count}個のグラフの追加に失敗しました",
|
||||
"charts_added_to_dashboard": "グラフをダッシュボードに追加しました",
|
||||
"charts_load_failed": "グラフの読み込みに失敗しました",
|
||||
"create_dashboard": "ダッシュボードを作成",
|
||||
"create_dashboard_description": "新しいダッシュボードの名前を入力してください。",
|
||||
"create_failed": "ダッシュボードの作成に失敗しました",
|
||||
"create_success": "ダッシュボードを正常に作成しました!",
|
||||
"dashboard": "ダッシュボード",
|
||||
"dashboard_delete_confirmation": "このダッシュボードを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"dashboard_name": "ダッシュボード名",
|
||||
"dashboard_name_placeholder": "マイダッシュボード",
|
||||
"dashboard_name_required": "ダッシュボード名は必須です",
|
||||
"dashboard_save_failed": "ダッシュボードの保存に失敗しました",
|
||||
"dashboard_saved": "ダッシュボードを正常に保存しました",
|
||||
"delete_confirmation": "このダッシュボードを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"delete_failed": "ダッシュボードの削除に失敗しました",
|
||||
"delete_success": "ダッシュボードを正常に削除しました",
|
||||
"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": "ダッシュボード名を入力してください"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "APIキーを追加",
|
||||
"api_key": "APIキー",
|
||||
@@ -2308,6 +2505,7 @@
|
||||
"archive_not_allowed": "このディレクトリをアーカイブする権限がありません。",
|
||||
"are_you_sure_you_want_to_archive": "このディレクトリをアーカイブしてもよろしいですか?ワークスペースはアクセスできなくなります。",
|
||||
"assign_workspaces_description": "このフィードバック記録ディレクトリにアクセスできるワークスペースを管理します。",
|
||||
"connectors_description": "このディレクトリにフィードバックレコードを送信するコネクタ。",
|
||||
"create_feedback_directory": "フィードバックディレクトリを作成",
|
||||
"description": "フィードバック記録ディレクトリとワークスペースの割り当てを管理します。",
|
||||
"directory_archived_successfully": "ディレクトリをアーカイブしました",
|
||||
@@ -2319,12 +2517,13 @@
|
||||
"directory_unarchived_successfully": "ディレクトリのアーカイブを解除しました",
|
||||
"directory_updated_successfully": "ディレクトリを更新しました",
|
||||
"empty_state": "フィードバック記録ディレクトリが見つかりません。最初のディレクトリを作成してください。",
|
||||
"enter_directory_name": "ディレクトリ名を入力してください",
|
||||
"error_directory_has_connectors": "コネクタがリンクされているディレクトリはアーカイブできません。まずすべてのコネクタを削除してください。",
|
||||
"error_directory_name_duplicate": "この名前のフィードバック記録ディレクトリは既に存在します。",
|
||||
"error_directory_name_required": "ディレクトリ名は必須です。",
|
||||
"error_directory_workspaces_invalid_org": "指定されたワークスペースの一部がこの組織に属していません。",
|
||||
"nav_label": "フィードバックディレクトリ",
|
||||
"no_access": "フィードバック記録ディレクトリを管理する権限がありません。",
|
||||
"no_connectors": "このディレクトリにリンクされているコネクタはまだありません。",
|
||||
"select_workspaces_placeholder": "ワークスペースを選択...",
|
||||
"show_archived": "アーカイブ済みを表示",
|
||||
"title": "フィードバック記録ディレクトリ",
|
||||
@@ -3368,6 +3567,119 @@
|
||||
"team_name": "チーム名",
|
||||
"team_settings_description": "このワークスペースにアクセスできるチームを確認します。"
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "フィードバックソースを追加",
|
||||
"add_source": "ソースを追加",
|
||||
"allowed_values": "許可される値: {values}",
|
||||
"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": "コネクタが正常に複製されました",
|
||||
"connector_status_updated_successfully": "コネクタのステータスが正常に更新されました",
|
||||
"connector_updated_successfully": "コネクタが正常に更新されました",
|
||||
"connectors": "コネクタ",
|
||||
"create_mapping": "マッピングを作成",
|
||||
"created_by": "作成者",
|
||||
"csv_at_least_one_row": "CSVには少なくとも1行のデータが必要です。",
|
||||
"csv_columns": "CSV列",
|
||||
"csv_empty_column_headers": "CSVに空の列ヘッダーが含まれています。すべての列に名前が必要です。",
|
||||
"csv_file_too_large": "CSVファイルが大きすぎます。最大サイズは2MBです。",
|
||||
"csv_files_only": "CSVファイルのみ",
|
||||
"csv_import": "CSVインポート",
|
||||
"csv_import_complete": "CSVインポート完了: {successes}件成功、{failures}件失敗、{skipped}件スキップ",
|
||||
"csv_import_duplicate_warning": "データを2回インポートすると、重複したレコードが作成されます。",
|
||||
"csv_inconsistent_columns": "行 {row} の列が一致しません。すべての行は同じヘッダーを持つ必要があります。",
|
||||
"csv_max_records": "最大 {max} 件のレコードまで許可されています。",
|
||||
"default_connector_name_csv": "CSVインポート",
|
||||
"default_connector_name_formbricks": "Formbricks フォーム接続",
|
||||
"deselect_all": "すべて選択解除",
|
||||
"drop_a_field_here": "ここにフィールドをドロップ",
|
||||
"drop_field_or": "フィールドをドロップまたは",
|
||||
"edit_csv_mapping": "CSVマッピングを編集",
|
||||
"edit_source_connection": "ソース接続を編集",
|
||||
"enter_name_for_source": "このソースの名前を入力",
|
||||
"enter_value": "値を入力...",
|
||||
"enum": "列挙型",
|
||||
"failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました",
|
||||
"feedback_date": "現在の日付",
|
||||
"feedback_record_directory": "フィードバックレコードディレクトリ",
|
||||
"feedback_record_fields": "フィードバックレコードフィールド",
|
||||
"feedback_records": "フィードバックレコード",
|
||||
"feedback_records_refreshed": "フィードバックレコードを更新しました",
|
||||
"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_rows": "{count}行をインポート",
|
||||
"importing_data": "データをインポート中...",
|
||||
"importing_historical_data": "過去のデータをインポート中...",
|
||||
"invalid_enum_values": "{field}にマッピングされた列に無効な値があります",
|
||||
"invalid_values_found": "検出された値: {values}(行: {rows}){extra}",
|
||||
"load_sample_csv": "サンプルCSVを読み込む",
|
||||
"n_supported_questions": "{count} 件のサポートされている質問",
|
||||
"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": "フィードバックレコードを更新中...",
|
||||
"required": "必須",
|
||||
"save_changes": "変更を保存",
|
||||
"select_a_survey_to_see_questions": "フォームを選択して質問を表示",
|
||||
"select_a_value": "値を選択...",
|
||||
"select_all": "すべて選択",
|
||||
"select_feedback_record_directory": "ディレクトリを選択",
|
||||
"select_questions": "質問を選択",
|
||||
"select_source_type_description": "接続するフィードバックソースの種類を選択してください。",
|
||||
"select_source_type_prompt": "接続するフィードバックソースの種類を選択してください:",
|
||||
"select_survey": "フォームを選択",
|
||||
"select_survey_and_questions": "フォームと質問を選択",
|
||||
"select_survey_questions_description": "フィードバックレコードを作成するフォームの質問を選択してください。",
|
||||
"set_value": "値を設定",
|
||||
"setup_connection": "接続を設定",
|
||||
"showing_count_loaded": "{count}件のレコードを表示中",
|
||||
"showing_rows": "{count}行中3行を表示",
|
||||
"source": "ソース",
|
||||
"source_connect_csv_description": "CSVファイルからフィードバックをインポート",
|
||||
"source_connect_formbricks_description": "Formbricksフォームからフィードバックを接続",
|
||||
"source_fields": "ソースフィールド",
|
||||
"source_name": "ソース名",
|
||||
"source_type": "ソースタイプ",
|
||||
"source_type_cannot_be_changed": "ソースタイプは変更できません",
|
||||
"sources": "ソース",
|
||||
"status_active": "進行中",
|
||||
"status_completed": "完了",
|
||||
"status_draft": "下書き",
|
||||
"status_error": "エラー",
|
||||
"status_paused": "一時停止",
|
||||
"survey_has_no_questions": "このアンケートには質問がありません",
|
||||
"survey_import_line": "{surveyName}: {responseCount}件の回答 × {questionCount}件の質問 = {total}件のフィードバックレコード",
|
||||
"total_feedback_records": "合計: {surveyCount}件のアンケート全体で{total}件中{checked}件のフィードバックレコードが選択されています",
|
||||
"unify_feedback": "フィードバックを統合",
|
||||
"update_mapping_description": "このソースのマッピング設定を更新します。",
|
||||
"updated_at": "更新日時",
|
||||
"upload_csv_data_description": "CSVファイルをアップロードして、フィードバックデータをインポートします。",
|
||||
"upload_csv_file": "CSVファイルをアップロード",
|
||||
"user_identifier": "ユーザー",
|
||||
"value": "値"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "あらゆるタッチポイントを活用して、顧客のインタラクションの容易さを把握します。",
|
||||
|
||||
+313
-1
@@ -125,6 +125,8 @@
|
||||
"activity": "Activiteit",
|
||||
"add": "Toevoegen",
|
||||
"add_action": "Actie toevoegen",
|
||||
"add_charts": "Grafieken toevoegen",
|
||||
"add_existing_chart_description": "Zoek en selecteer grafieken om toe te voegen aan dit dashboard.",
|
||||
"add_filter": "Filter toevoegen",
|
||||
"add_logo": "Logo toevoegen",
|
||||
"add_member": "Lid toevoegen",
|
||||
@@ -136,6 +138,7 @@
|
||||
"allow": "Toestaan",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "En",
|
||||
"anonymous": "Anoniem",
|
||||
"api_keys": "API-sleutels",
|
||||
@@ -154,6 +157,8 @@
|
||||
"centered_modal": "Gecentreerd modaal",
|
||||
"change_organization": "Organisatie wijzigen",
|
||||
"change_workspace": "Werkruimte wijzigen",
|
||||
"chart": "Grafiek",
|
||||
"charts": "Grafieken",
|
||||
"choices": "Keuzes",
|
||||
"choose_organization": "Kies organisatie",
|
||||
"choose_workspace": "Kies werkruimte",
|
||||
@@ -186,6 +191,7 @@
|
||||
"count_questions": "{count, plural, one {{count} vraag} other {{count} vragen}}",
|
||||
"count_responses": "{count, plural, one {{count} reactie} other {{count} reacties}}",
|
||||
"count_selections": "{count, plural, one {{count} selectie} other {{count} selecties}}",
|
||||
"create": "Creëren",
|
||||
"create_new_organization": "Creëer een nieuwe organisatie",
|
||||
"create_segment": "Segment maken",
|
||||
"create_survey": "Enquête maken",
|
||||
@@ -195,6 +201,8 @@
|
||||
"created_by": "Gemaakt door",
|
||||
"customer_success": "Klant succes",
|
||||
"dark_overlay": "Donkere overlay",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Datum",
|
||||
"days": "dagen",
|
||||
"default": "Standaard",
|
||||
@@ -218,6 +226,7 @@
|
||||
"edit": "Bewerking",
|
||||
"elements": "Elementen",
|
||||
"email": "E-mail",
|
||||
"enable": "Inschakelen",
|
||||
"ending_card": "Einde kaart",
|
||||
"enter_url": "URL invoeren",
|
||||
"enterprise_license": "Enterprise-licentie",
|
||||
@@ -232,6 +241,7 @@
|
||||
"failed_to_copy_to_clipboard": "Kopiëren naar klembord mislukt",
|
||||
"failed_to_load_organizations": "Laden van organisaties mislukt",
|
||||
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
|
||||
"failed_to_parse_csv": "Kan CSV niet verwerken",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "Voornaam",
|
||||
@@ -246,6 +256,7 @@
|
||||
"hidden": "Verborgen",
|
||||
"hidden_field": "Verborgen veld",
|
||||
"hidden_fields": "Verborgen velden",
|
||||
"hide": "Verbergen",
|
||||
"hide_column": "Kolom verbergen",
|
||||
"id": "ID",
|
||||
"image": "Afbeelding",
|
||||
@@ -292,6 +303,7 @@
|
||||
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
|
||||
"mobile_overlay_title": "Oeps, klein scherm gedetecteerd!",
|
||||
"months": "maanden",
|
||||
"more_options": "Meer opties",
|
||||
"move_down": "Ga naar beneden",
|
||||
"move_up": "Ga omhoog",
|
||||
"multiple_languages": "Meerdere talen",
|
||||
@@ -300,8 +312,10 @@
|
||||
"new": "Nieuw",
|
||||
"new_version_available": "Formbricks {version} is hier. Upgrade nu!",
|
||||
"next": "Volgende",
|
||||
"no": "Nee",
|
||||
"no_actions_found": "Geen acties gevonden",
|
||||
"no_background_image_found": "Geen achtergrondafbeelding gevonden.",
|
||||
"no_changes": "Geen wijzigingen",
|
||||
"no_code": "Geen code",
|
||||
"no_files_uploaded": "Er zijn geen bestanden geüpload",
|
||||
"no_overlay": "Geen overlay",
|
||||
@@ -323,6 +337,7 @@
|
||||
"on": "Op",
|
||||
"only_one_file_allowed": "Er is slechts één bestand toegestaan",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Alleen eigenaren en beheerders kunnen deze actie uitvoeren.",
|
||||
"open_options": "Opties openen",
|
||||
"option_id": "Optie-ID",
|
||||
"option_ids": "Optie-ID's",
|
||||
"optional": "Optioneel",
|
||||
@@ -365,6 +380,7 @@
|
||||
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
|
||||
"read_docs": "Documentatie lezen",
|
||||
"recipients": "Ontvangers",
|
||||
"refresh": "Vernieuwen",
|
||||
"remove": "Verwijderen",
|
||||
"remove_from_team": "Verwijderen uit team",
|
||||
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
|
||||
@@ -376,6 +392,7 @@
|
||||
"response_id": "Antwoord-ID",
|
||||
"responses": "Reacties",
|
||||
"restart": "Opnieuw opstarten",
|
||||
"retry": "Opnieuw proberen",
|
||||
"role": "Rol",
|
||||
"saas": "SaaS",
|
||||
"sales": "Verkoop",
|
||||
@@ -384,6 +401,7 @@
|
||||
"save_changes": "Wijzigingen opslaan",
|
||||
"saving": "Besparing",
|
||||
"search": "Zoekopdracht",
|
||||
"search_charts": "Zoek grafieken...",
|
||||
"security": "Beveiliging",
|
||||
"segment": "Segment",
|
||||
"segments": "Segmenten",
|
||||
@@ -447,6 +465,7 @@
|
||||
"trial_one_day_remaining": "1 dag over in je proefperiode",
|
||||
"try_again": "Probeer het opnieuw",
|
||||
"type": "Type",
|
||||
"unify": "Verenigen",
|
||||
"unknown_survey": "Onbekende enquête",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Ontgrendel meer werkruimtes met een hoger abonnement.",
|
||||
"update": "Update",
|
||||
@@ -464,6 +483,7 @@
|
||||
"variables": "Variabelen",
|
||||
"verified_email": "Geverifieerde e-mail",
|
||||
"video": "Video",
|
||||
"view": "Bekijken",
|
||||
"warning": "Waarschuwing",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We kunnen uw licentie niet verifiëren omdat de licentieserver niet bereikbaar is.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +502,7 @@
|
||||
"workspace_name_placeholder": "bijv. Formbricks",
|
||||
"workspaces": "Werkruimtes",
|
||||
"years": "jaren",
|
||||
"yes": "Ja",
|
||||
"you": "Jij",
|
||||
"you_are_downgraded_to_the_community_edition": "Je bent gedowngraded naar de Community-editie.",
|
||||
"you_are_not_authorized_to_perform_this_action": "U bent niet geautoriseerd om deze actie uit te voeren.",
|
||||
@@ -1610,6 +1631,182 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Uw enquête wordt op deze URL weergegeven.",
|
||||
"your_survey_would_not_be_shown": "Uw enquête wordt niet getoond."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "OF",
|
||||
"add_chart_to_dashboard": "Grafiek toevoegen aan dashboard",
|
||||
"add_chart_to_dashboard_description": "Selecteer een dashboard om deze grafiek aan toe te voegen. De grafiek wordt automatisch opgeslagen.",
|
||||
"add_filter": "Filter toevoegen",
|
||||
"add_to_dashboard": "Toevoegen aan dashboard",
|
||||
"advanced_chart_builder_config_prompt": "Configureer je grafiek en klik op \"Query uitvoeren\" om een voorbeeld te zien",
|
||||
"ai_query_placeholder": "bijv. Hoeveel gebruikers hebben zich vorige week aangemeld?",
|
||||
"ai_query_section_description": "Beschrijf wat je wilt zien en laat AI de grafiek bouwen.",
|
||||
"ai_query_section_title": "Vraag het aan je data",
|
||||
"and_filter_logic": "EN",
|
||||
"apply_changes": "Wijzigingen toepassen",
|
||||
"chart": "Grafiek",
|
||||
"chart_added_to_dashboard": "Grafiek toegevoegd aan dashboard!",
|
||||
"chart_builder_choose_chart_type": "Kies grafiektype",
|
||||
"chart_data": "Grafiekdata",
|
||||
"chart_data_tab": "Data",
|
||||
"chart_deleted_successfully": "Grafiek succesvol verwijderd",
|
||||
"chart_deletion_error": "Verwijderen van grafiek mislukt",
|
||||
"chart_duplicated_successfully": "Grafiek succesvol gedupliceerd",
|
||||
"chart_duplication_error": "Dupliceren van grafiek mislukt",
|
||||
"chart_name": "Grafieknaam",
|
||||
"chart_name_placeholder": "Grafieknaam",
|
||||
"chart_preview": "Grafiekvoorbeeld",
|
||||
"chart_render_error": "Er is iets misgegaan bij het weergeven van deze grafiek.",
|
||||
"chart_saved_successfully": "Grafiek succesvol opgeslagen!",
|
||||
"chart_type_area": "Vlakdiagram",
|
||||
"chart_type_bar": "Staafdiagram",
|
||||
"chart_type_big_number": "Groot getal",
|
||||
"chart_type_line": "Lijndiagram",
|
||||
"chart_type_not_supported": "Grafiektype \"{{chartType}}\" wordt nog niet ondersteund",
|
||||
"chart_type_pie": "Cirkeldiagram",
|
||||
"chart_updated_successfully": "Grafiek succesvol bijgewerkt!",
|
||||
"configure_description": "Pas het diagramtype en andere instellingen voor deze visualisatie aan.",
|
||||
"configure_title": "Diagram configureren",
|
||||
"configure_type_label": "Diagramtype",
|
||||
"contains": "bevat",
|
||||
"create_chart": "Grafiek maken",
|
||||
"create_chart_description": "Gebruik AI om een diagram te genereren of bouw er handmatig een.",
|
||||
"create_chart_with_ai": "Grafiek maken met AI",
|
||||
"custom_range": "Aangepast bereik",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_select_placeholder": "Selecteer een dashboard",
|
||||
"data_label": "Data",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Laatste 30 dagen",
|
||||
"date_preset_last_7_days": "Laatste 7 dagen",
|
||||
"date_preset_last_month": "Vorige maand",
|
||||
"date_preset_this_month": "Deze maand",
|
||||
"date_preset_this_quarter": "Dit kwartaal",
|
||||
"date_preset_this_year": "Dit jaar",
|
||||
"date_preset_today": "Vandaag",
|
||||
"date_preset_yesterday": "Gisteren",
|
||||
"date_range": "Datumbereik",
|
||||
"delete_chart_confirmation": "Weet je zeker dat je deze grafiek wilt verwijderen?",
|
||||
"dimensions": "Dimensies",
|
||||
"dimensions_toggle_description": "Groepeer data op sentiment, vraagtype en andere dimensies.",
|
||||
"edit_chart_description": "Bekijk en bewerk je diagramconfiguratie.",
|
||||
"edit_chart_title": "Diagram bewerken",
|
||||
"enable_time_dimension": "Tijdsdimensie inschakelen",
|
||||
"end_date": "Einddatum",
|
||||
"enter_a_name_for_your_chart": "Voer een naam in voor je diagram om het op te slaan.",
|
||||
"enter_value": "Voer waarde in",
|
||||
"equals": "is gelijk aan",
|
||||
"failed_to_add_chart_to_dashboard": "Diagram toevoegen aan dashboard mislukt",
|
||||
"failed_to_execute_query": "Query uitvoeren mislukt",
|
||||
"failed_to_load_chart": "Diagram laden mislukt",
|
||||
"failed_to_load_chart_data": "Diagramdata laden mislukt",
|
||||
"failed_to_save_chart": "Opslaan van diagram mislukt",
|
||||
"field": "Veld",
|
||||
"field_label_average_score": "Gemiddelde score",
|
||||
"field_label_collected_at": "Verzameld op",
|
||||
"field_label_count": "Aantal",
|
||||
"field_label_detractor_count": "Aantal detractors",
|
||||
"field_label_emotion": "Emotie",
|
||||
"field_label_field_type": "Veldtype",
|
||||
"field_label_nps_score": "NPS-score",
|
||||
"field_label_nps_value": "NPS-waarde",
|
||||
"field_label_passive_count": "Aantal passieven",
|
||||
"field_label_promoter_count": "Aantal promoters",
|
||||
"field_label_response_id": "Antwoord-ID",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Bronnaam",
|
||||
"field_label_source_type": "Brontype",
|
||||
"field_label_topic": "Onderwerp",
|
||||
"field_label_user_identifier": "Gebruikersidentificatie",
|
||||
"filter_data": "Data filteren",
|
||||
"filters": "Filters",
|
||||
"filters_toggle_description": "Neem alleen gegevens op die aan de volgende voorwaarden voldoen.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granulariteit",
|
||||
"granularity_day": "Dag",
|
||||
"granularity_hour": "Uur",
|
||||
"granularity_month": "Maand",
|
||||
"granularity_quarter": "Kwartaal",
|
||||
"granularity_week": "Week",
|
||||
"granularity_year": "Jaar",
|
||||
"greater_than": "groter dan",
|
||||
"greater_than_or_equal": "groter dan of gelijk aan",
|
||||
"group_by": "Groeperen op",
|
||||
"group_by_description": "Splits je data op aan de hand van één of meerdere dimensies. De volgorde is belangrijk als je meerdere dimensies kiest.",
|
||||
"group_data": "Data groeperen",
|
||||
"is_not_set": "is niet ingesteld",
|
||||
"is_set": "is ingesteld",
|
||||
"less_than": "kleiner dan",
|
||||
"less_than_or_equal": "kleiner dan of gelijk aan",
|
||||
"measures": "Metingen",
|
||||
"no_charts_found": "Geen diagrammen gevonden.",
|
||||
"no_dashboards_available": "Geen dashboards beschikbaar",
|
||||
"no_dashboards_create_first": "Maak eerst een dashboard aan om er diagrammen aan toe te voegen.",
|
||||
"no_data_available": "Geen gegevens beschikbaar",
|
||||
"no_data_returned": "Geen gegevens geretourneerd uit query",
|
||||
"no_data_returned_for_chart": "Geen gegevens geretourneerd voor diagram",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Geen (alleen filteren)",
|
||||
"no_valid_data_to_display": "Geen geldige gegevens om weer te geven",
|
||||
"not_contains": "bevat niet",
|
||||
"not_equals": "is niet gelijk aan",
|
||||
"open_chart": "Open diagram {{name}}",
|
||||
"open_options": "Open diagramopties",
|
||||
"or_filter_logic": "OF",
|
||||
"original": "Origineel",
|
||||
"please_enter_chart_name": "Voer een diagramnaam in",
|
||||
"please_enter_filter_values": "Voer waarden in voor alle filters",
|
||||
"please_select_at_least_one_dimension": "Selecteer minimaal één dimensie of schakel groepering uit",
|
||||
"please_select_at_least_one_measure": "Selecteer ten minste één meting",
|
||||
"please_select_dashboard": "Selecteer een dashboard",
|
||||
"predefined_measures": "Vooraf gedefinieerde metingen",
|
||||
"preset": "Voorinstelling",
|
||||
"query_executed_successfully": "Query succesvol uitgevoerd",
|
||||
"reset_to_ai_suggestion": "Herstel naar AI-suggestie",
|
||||
"save_chart": "Diagram opslaan",
|
||||
"save_chart_dialog_title": "Diagram opslaan",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Selecteer dimensies...",
|
||||
"select_field": "Selecteer veld",
|
||||
"select_measures": "Selecteer metingen...",
|
||||
"select_preset": "Selecteer voorinstelling",
|
||||
"showing_first_n_of": "Eerste {{n}} van {{count}} rijen worden getoond",
|
||||
"start_date": "Startdatum",
|
||||
"time_dimension": "Tijdsdimensie",
|
||||
"time_dimension_title": "Tijdgebaseerde groepering toevoegen",
|
||||
"time_dimension_toggle_description": "Volg trends over tijd."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} grafiek(en) toevoegen",
|
||||
"charts_add_failed": "Grafieken toevoegen aan dashboard mislukt",
|
||||
"charts_add_partial_failure": "{count} grafiek(en) toevoegen mislukt",
|
||||
"charts_added_to_dashboard": "Grafieken toegevoegd aan dashboard",
|
||||
"charts_load_failed": "Grafieken laden mislukt",
|
||||
"create_dashboard": "Dashboard maken",
|
||||
"create_dashboard_description": "Voer een naam in voor je nieuwe dashboard.",
|
||||
"create_failed": "Dashboard creëren mislukt",
|
||||
"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.",
|
||||
"dashboard_name": "Dashboardnaam",
|
||||
"dashboard_name_placeholder": "Mijn dashboard",
|
||||
"dashboard_name_required": "Dashboardnaam is verplicht",
|
||||
"dashboard_save_failed": "Dashboard opslaan mislukt",
|
||||
"dashboard_saved": "Dashboard succesvol opgeslagen",
|
||||
"delete_confirmation": "Weet je zeker dat je dit dashboard wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"delete_failed": "Dashboard verwijderen mislukt",
|
||||
"delete_success": "Dashboard succesvol verwijderd",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "API-sleutel toevoegen",
|
||||
"api_key": "API-sleutel",
|
||||
@@ -2308,6 +2505,7 @@
|
||||
"archive_not_allowed": "Je hebt geen toestemming om deze map te archiveren.",
|
||||
"are_you_sure_you_want_to_archive": "Weet je zeker dat je deze map wilt archiveren? Workspaces hebben er dan geen toegang meer toe.",
|
||||
"assign_workspaces_description": "Bepaal welke workspaces toegang hebben tot deze feedbackregistratiemap.",
|
||||
"connectors_description": "Connectoren die feedbackrecords naar deze map sturen.",
|
||||
"create_feedback_directory": "Feedbackmap maken",
|
||||
"description": "Beheer feedbackregistratiemappen en hun workspace-toewijzingen.",
|
||||
"directory_archived_successfully": "Map succesvol gearchiveerd",
|
||||
@@ -2319,12 +2517,13 @@
|
||||
"directory_unarchived_successfully": "Map succesvol gedearchiveerd",
|
||||
"directory_updated_successfully": "Map succesvol bijgewerkt",
|
||||
"empty_state": "Geen feedbackregistratiemappen gevonden. Maak er een aan om te beginnen.",
|
||||
"enter_directory_name": "Voer mapnaam in",
|
||||
"error_directory_has_connectors": "Kan een map met gekoppelde connectoren niet archiveren. Verwijder eerst alle connectoren.",
|
||||
"error_directory_name_duplicate": "Er bestaat al een feedback-recordmap met deze naam.",
|
||||
"error_directory_name_required": "Mapnaam is verplicht.",
|
||||
"error_directory_workspaces_invalid_org": "Sommige opgegeven werkruimtes behoren niet tot deze organisatie.",
|
||||
"nav_label": "Feedbackmappen",
|
||||
"no_access": "Je hebt geen toestemming om feedbackregistratiemappen te beheren.",
|
||||
"no_connectors": "Nog geen connectoren gekoppeld aan deze map.",
|
||||
"select_workspaces_placeholder": "Selecteer werkruimtes...",
|
||||
"show_archived": "Gearchiveerde weergeven",
|
||||
"title": "Feedbackregistratiemappen",
|
||||
@@ -3368,6 +3567,119 @@
|
||||
"team_name": "Teamnaam",
|
||||
"team_settings_description": "Bekijk welke teams toegang hebben tot deze workspace."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Feedbackbron toevoegen",
|
||||
"add_source": "Bron toevoegen",
|
||||
"allowed_values": "Toegestane waarden: {values}",
|
||||
"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",
|
||||
"connector_status_updated_successfully": "Connectorstatus succesvol bijgewerkt",
|
||||
"connector_updated_successfully": "Connector succesvol bijgewerkt",
|
||||
"connectors": "Connectoren",
|
||||
"create_mapping": "Koppeling aanmaken",
|
||||
"created_by": "Gemaakt door",
|
||||
"csv_at_least_one_row": "CSV moet minimaal één datarij bevatten.",
|
||||
"csv_columns": "CSV kolommen",
|
||||
"csv_empty_column_headers": "CSV bevat lege kolomkoppen. Alle kolommen moeten een naam hebben.",
|
||||
"csv_file_too_large": "CSV-bestand is te groot. Maximale grootte is 2MB.",
|
||||
"csv_files_only": "Alleen CSV bestanden",
|
||||
"csv_import": "CSV import",
|
||||
"csv_import_complete": "CSV-import voltooid: {successes} geslaagd, {failures} mislukt, {skipped} overgeslagen",
|
||||
"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.",
|
||||
"default_connector_name_csv": "CSV import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey verbinding",
|
||||
"deselect_all": "Alles deselecteren",
|
||||
"drop_a_field_here": "Zet hier een veld neer",
|
||||
"drop_field_or": "Zet veld neer of",
|
||||
"edit_csv_mapping": "CSV-mapping bewerken",
|
||||
"edit_source_connection": "Bronverbinding bewerken",
|
||||
"enter_name_for_source": "Voer een naam in voor deze bron",
|
||||
"enter_value": "Voer waarde in...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Kan feedbackrecords niet laden",
|
||||
"feedback_date": "Huidige datum",
|
||||
"feedback_record_directory": "Feedbackrecordmap",
|
||||
"feedback_record_fields": "Feedbackrecordvelden",
|
||||
"feedback_records": "Feedbackrecords",
|
||||
"feedback_records_refreshed": "Feedbackrecords vernieuwd",
|
||||
"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_rows": "{count, plural, one {Importeer 1 rij} other {Importeer # rijen}}",
|
||||
"importing_data": "Gegevens importeren...",
|
||||
"importing_historical_data": "Historische gegevens importeren...",
|
||||
"invalid_enum_values": "Ongeldige waarden in kolom gekoppeld aan {field}",
|
||||
"invalid_values_found": "Gevonden: {values} (rijen: {rows}) {extra}",
|
||||
"load_sample_csv": "Voorbeeld-CSV laden",
|
||||
"n_supported_questions": "{count} ondersteunde vragen",
|
||||
"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...",
|
||||
"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_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.",
|
||||
"set_value": "waarde instellen",
|
||||
"setup_connection": "Verbinding instellen",
|
||||
"showing_count_loaded": "Er worden {count} records weergegeven",
|
||||
"showing_rows": "3 van {count} rijen weergegeven",
|
||||
"source": "bron",
|
||||
"source_connect_csv_description": "Importeer feedback uit CSV-bestanden",
|
||||
"source_connect_formbricks_description": "Verbind feedback van je Formbricks-enquêtes",
|
||||
"source_fields": "Bronvelden",
|
||||
"source_name": "Bronnaam",
|
||||
"source_type": "Brontype",
|
||||
"source_type_cannot_be_changed": "Brontype kan niet worden gewijzigd",
|
||||
"sources": "Bronnen",
|
||||
"status_active": "In uitvoering",
|
||||
"status_completed": "Voltooid",
|
||||
"status_draft": "Voorlopige versie",
|
||||
"status_error": "Fout",
|
||||
"status_paused": "Gepauzeerd",
|
||||
"survey_has_no_questions": "Deze enquête heeft geen vragen",
|
||||
"survey_import_line": "{surveyName}: {responseCount} antwoorden × {questionCount} vragen = {total} feedbackrecords",
|
||||
"total_feedback_records": "Totaal: {checked} van {total} feedbackrecords geselecteerd over {surveyCount} enquêtes",
|
||||
"unify_feedback": "Feedback verenigen",
|
||||
"update_mapping_description": "Werk de mappingconfiguratie voor deze bron bij.",
|
||||
"updated_at": "Bijgewerkt op",
|
||||
"upload_csv_data_description": "Upload een CSV-bestand om feedbackgegevens te importeren.",
|
||||
"upload_csv_file": "CSV-bestand uploaden",
|
||||
"user_identifier": "Gebruiker",
|
||||
"value": "Waarde"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Benut elk contactpunt om inzicht te krijgen in het gemak van klantinteractie.",
|
||||
|
||||
+313
-1
@@ -125,6 +125,8 @@
|
||||
"activity": "Atividade",
|
||||
"add": "Adicionar",
|
||||
"add_action": "Adicionar ação",
|
||||
"add_charts": "Adicionar gráficos",
|
||||
"add_existing_chart_description": "Pesquise e selecione gráficos para adicionar a este painel.",
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_logo": "Adicionar logo",
|
||||
"add_member": "Adicionar membro",
|
||||
@@ -136,6 +138,7 @@
|
||||
"allow": "permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
|
||||
"analysis": "Análise",
|
||||
"and": "E",
|
||||
"anonymous": "Anônimo",
|
||||
"api_keys": "Chaves de API",
|
||||
@@ -154,6 +157,8 @@
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"change_organization": "Alterar organização",
|
||||
"change_workspace": "Alterar espaço de trabalho",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Escolhas",
|
||||
"choose_organization": "Escolher organização",
|
||||
"choose_workspace": "Escolher projeto",
|
||||
@@ -186,6 +191,7 @@
|
||||
"count_questions": "{count, plural, one {{count} pergunta} other {{count} perguntas}}",
|
||||
"count_responses": "{count, plural, one {{count} resposta} other {{count} respostas}}",
|
||||
"count_selections": "{count, plural, one {{count} seleção} other {{count} seleções}}",
|
||||
"create": "Criar",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_segment": "Criar segmento",
|
||||
"create_survey": "Criar pesquisa",
|
||||
@@ -195,6 +201,8 @@
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "sobreposição escura",
|
||||
"dashboard": "Painel",
|
||||
"dashboards": "Painéis",
|
||||
"date": "Encontro",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
@@ -218,6 +226,7 @@
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"enable": "Ativar",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Inserir URL",
|
||||
"enterprise_license": "Licença Empresarial",
|
||||
@@ -232,6 +241,7 @@
|
||||
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
||||
"failed_to_parse_csv": "Falha ao analisar CSV",
|
||||
"filter": "Filtro",
|
||||
"finish": "Terminar",
|
||||
"first_name": "Primeiro nome",
|
||||
@@ -246,6 +256,7 @@
|
||||
"hidden": "Escondido",
|
||||
"hidden_field": "Campo oculto",
|
||||
"hidden_fields": "Campos ocultos",
|
||||
"hide": "Ocultar",
|
||||
"hide_column": "Ocultar coluna",
|
||||
"id": "ID",
|
||||
"image": "imagem",
|
||||
@@ -292,6 +303,7 @@
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
|
||||
"mobile_overlay_title": "Eita, tela pequena detectada!",
|
||||
"months": "meses",
|
||||
"more_options": "Mais opções",
|
||||
"move_down": "Descer",
|
||||
"move_up": "Subir",
|
||||
"multiple_languages": "Vários idiomas",
|
||||
@@ -300,8 +312,10 @@
|
||||
"new": "Novo",
|
||||
"new_version_available": "Formbricks {version} chegou. Atualize agora!",
|
||||
"next": "Próximo",
|
||||
"no": "Não",
|
||||
"no_actions_found": "Nenhuma ação encontrada",
|
||||
"no_background_image_found": "Imagem de fundo não encontrada.",
|
||||
"no_changes": "Sem alterações",
|
||||
"no_code": "Sem código",
|
||||
"no_files_uploaded": "Nenhum arquivo foi enviado",
|
||||
"no_overlay": "Sem sobreposição",
|
||||
@@ -323,6 +337,7 @@
|
||||
"on": "ligado",
|
||||
"only_one_file_allowed": "É permitido apenas um arquivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
|
||||
"open_options": "Abrir opções",
|
||||
"option_id": "ID da opção",
|
||||
"option_ids": "IDs da Opção",
|
||||
"optional": "Opcional",
|
||||
@@ -365,6 +380,7 @@
|
||||
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
|
||||
"read_docs": "Ler documentação",
|
||||
"recipients": "Destinatários",
|
||||
"refresh": "Atualizar",
|
||||
"remove": "remover",
|
||||
"remove_from_team": "Remover da equipe",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
@@ -376,6 +392,7 @@
|
||||
"response_id": "ID da resposta",
|
||||
"responses": "Respostas",
|
||||
"restart": "Reiniciar",
|
||||
"retry": "Tentar novamente",
|
||||
"role": "Rolê",
|
||||
"saas": "SaaS",
|
||||
"sales": "vendas",
|
||||
@@ -384,6 +401,7 @@
|
||||
"save_changes": "Salvar alterações",
|
||||
"saving": "Salvando",
|
||||
"search": "Buscar",
|
||||
"search_charts": "Pesquisar gráficos...",
|
||||
"security": "Segurança",
|
||||
"segment": "segmento",
|
||||
"segments": "Segmentos",
|
||||
@@ -447,6 +465,7 @@
|
||||
"trial_one_day_remaining": "1 dia restante no seu período de teste",
|
||||
"try_again": "Tenta de novo",
|
||||
"type": "Tipo",
|
||||
"unify": "Unificar",
|
||||
"unknown_survey": "Pesquisa desconhecida",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
|
||||
"update": "atualizar",
|
||||
@@ -464,6 +483,7 @@
|
||||
"variables": "Variáveis",
|
||||
"verified_email": "Email Verificado",
|
||||
"video": "vídeo",
|
||||
"view": "Visualizar",
|
||||
"warning": "Aviso",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Não conseguimos verificar sua licença porque o servidor de licenças está inacessível.",
|
||||
"webhook": "webhook",
|
||||
@@ -482,6 +502,7 @@
|
||||
"workspace_name_placeholder": "ex: Formbricks",
|
||||
"workspaces": "Projetos",
|
||||
"years": "anos",
|
||||
"yes": "Sim",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Você não tem autorização para realizar essa ação.",
|
||||
@@ -1610,6 +1631,182 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Sua pesquisa seria exibida neste URL.",
|
||||
"your_survey_would_not_be_shown": "Sua pesquisa não seria exibida."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "OU",
|
||||
"add_chart_to_dashboard": "Adicionar gráfico ao painel",
|
||||
"add_chart_to_dashboard_description": "Selecione um painel para adicionar este gráfico. O gráfico será salvo automaticamente.",
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_to_dashboard": "Adicionar ao painel",
|
||||
"advanced_chart_builder_config_prompt": "Configure seu gráfico e clique em \"Executar consulta\" para visualizar",
|
||||
"ai_query_placeholder": "ex: Quantos usuários se cadastraram na semana passada?",
|
||||
"ai_query_section_description": "Descreva o que você quer ver e deixe a IA construir o gráfico.",
|
||||
"ai_query_section_title": "Pergunte aos seus dados",
|
||||
"and_filter_logic": "E",
|
||||
"apply_changes": "Aplicar alterações",
|
||||
"chart": "Gráfico",
|
||||
"chart_added_to_dashboard": "Gráfico adicionado ao painel!",
|
||||
"chart_builder_choose_chart_type": "Escolher tipo de gráfico",
|
||||
"chart_data": "Dados do gráfico",
|
||||
"chart_data_tab": "Dados",
|
||||
"chart_deleted_successfully": "Gráfico excluído com sucesso",
|
||||
"chart_deletion_error": "Falha ao excluir gráfico",
|
||||
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
|
||||
"chart_duplication_error": "Falha ao duplicar gráfico",
|
||||
"chart_name": "Nome do gráfico",
|
||||
"chart_name_placeholder": "Nome do gráfico",
|
||||
"chart_preview": "Visualização do gráfico",
|
||||
"chart_render_error": "Algo deu errado ao renderizar este gráfico.",
|
||||
"chart_saved_successfully": "Gráfico salvo com sucesso!",
|
||||
"chart_type_area": "Gráfico de área",
|
||||
"chart_type_bar": "Gráfico de barras",
|
||||
"chart_type_big_number": "Número grande",
|
||||
"chart_type_line": "Gráfico de linhas",
|
||||
"chart_type_not_supported": "Tipo de gráfico \"{{chartType}}\" ainda não suportado",
|
||||
"chart_type_pie": "Gráfico de pizza",
|
||||
"chart_updated_successfully": "Gráfico atualizado com sucesso!",
|
||||
"configure_description": "Modifique o tipo de gráfico e outras configurações para esta visualização.",
|
||||
"configure_title": "Configurar gráfico",
|
||||
"configure_type_label": "Tipo de gráfico",
|
||||
"contains": "contém",
|
||||
"create_chart": "Criar gráfico",
|
||||
"create_chart_description": "Use IA para gerar um gráfico ou crie um manualmente.",
|
||||
"create_chart_with_ai": "Criar gráfico com IA",
|
||||
"custom_range": "Intervalo personalizado",
|
||||
"dashboard": "Painel",
|
||||
"dashboard_select_placeholder": "Selecione um painel",
|
||||
"data_label": "Dados",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Últimos 30 dias",
|
||||
"date_preset_last_7_days": "Últimos 7 dias",
|
||||
"date_preset_last_month": "Último mês",
|
||||
"date_preset_this_month": "Este mês",
|
||||
"date_preset_this_quarter": "Este trimestre",
|
||||
"date_preset_this_year": "Este ano",
|
||||
"date_preset_today": "Hoje",
|
||||
"date_preset_yesterday": "Ontem",
|
||||
"date_range": "Intervalo de datas",
|
||||
"delete_chart_confirmation": "Tem certeza de que deseja excluir este gráfico?",
|
||||
"dimensions": "Dimensões",
|
||||
"dimensions_toggle_description": "Agrupe dados por sentimento, tipo de pergunta e outras dimensões.",
|
||||
"edit_chart_description": "Visualize e edite a configuração do seu gráfico.",
|
||||
"edit_chart_title": "Editar gráfico",
|
||||
"enable_time_dimension": "Ativar dimensão de tempo",
|
||||
"end_date": "Data final",
|
||||
"enter_a_name_for_your_chart": "Digite um nome para o seu gráfico para salvá-lo.",
|
||||
"enter_value": "Digite o valor",
|
||||
"equals": "igual",
|
||||
"failed_to_add_chart_to_dashboard": "Falha ao adicionar gráfico ao painel",
|
||||
"failed_to_execute_query": "Falha ao executar consulta",
|
||||
"failed_to_load_chart": "Falha ao carregar gráfico",
|
||||
"failed_to_load_chart_data": "Falha ao carregar dados do gráfico",
|
||||
"failed_to_save_chart": "Falha ao salvar gráfico",
|
||||
"field": "Campo",
|
||||
"field_label_average_score": "Pontuação média",
|
||||
"field_label_collected_at": "Coletado em",
|
||||
"field_label_count": "Contagem",
|
||||
"field_label_detractor_count": "Contagem de detratores",
|
||||
"field_label_emotion": "Emoção",
|
||||
"field_label_field_type": "Tipo de campo",
|
||||
"field_label_nps_score": "Pontuação de NPS",
|
||||
"field_label_nps_value": "Valor de NPS",
|
||||
"field_label_passive_count": "Contagem de passivos",
|
||||
"field_label_promoter_count": "Contagem de promotores",
|
||||
"field_label_response_id": "ID da resposta",
|
||||
"field_label_sentiment": "Sentimento",
|
||||
"field_label_source_name": "Nome da fonte",
|
||||
"field_label_source_type": "Tipo de fonte",
|
||||
"field_label_topic": "Tópico",
|
||||
"field_label_user_identifier": "Identificador do usuário",
|
||||
"filter_data": "Filtrar dados",
|
||||
"filters": "Filtros",
|
||||
"filters_toggle_description": "Incluir apenas dados que atendam às seguintes condições.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularidade",
|
||||
"granularity_day": "Dia",
|
||||
"granularity_hour": "Hora",
|
||||
"granularity_month": "Mês",
|
||||
"granularity_quarter": "Trimestre",
|
||||
"granularity_week": "Semana",
|
||||
"granularity_year": "Ano",
|
||||
"greater_than": "maior que",
|
||||
"greater_than_or_equal": "maior ou igual a",
|
||||
"group_by": "Agrupar por",
|
||||
"group_by_description": "Divida seus dados por uma ou mais dimensões. A ordem é importante se você escolher várias dimensões.",
|
||||
"group_data": "Agrupar dados",
|
||||
"is_not_set": "não está definido",
|
||||
"is_set": "está definido",
|
||||
"less_than": "menor que",
|
||||
"less_than_or_equal": "menor ou igual a",
|
||||
"measures": "Medidas",
|
||||
"no_charts_found": "Nenhum gráfico encontrado.",
|
||||
"no_dashboards_available": "Nenhum painel disponível",
|
||||
"no_dashboards_create_first": "Crie um painel primeiro para adicionar gráficos a ele.",
|
||||
"no_data_available": "Nenhum dado disponível",
|
||||
"no_data_returned": "Nenhum dado retornado da consulta",
|
||||
"no_data_returned_for_chart": "Nenhum dado retornado para o gráfico",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Nenhum (apenas filtro)",
|
||||
"no_valid_data_to_display": "Nenhum dado válido para exibir",
|
||||
"not_contains": "não contém",
|
||||
"not_equals": "diferente de",
|
||||
"open_chart": "Abrir gráfico {{name}}",
|
||||
"open_options": "Abrir opções do gráfico",
|
||||
"or_filter_logic": "OU",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Por favor, insira um nome para o gráfico",
|
||||
"please_enter_filter_values": "Por favor, insira valores para todos os filtros",
|
||||
"please_select_at_least_one_dimension": "Por favor, selecione pelo menos uma dimensão ou desative o agrupamento",
|
||||
"please_select_at_least_one_measure": "Por favor, selecione pelo menos uma medida",
|
||||
"please_select_dashboard": "Por favor, selecione um painel",
|
||||
"predefined_measures": "Medidas predefinidas",
|
||||
"preset": "Predefinição",
|
||||
"query_executed_successfully": "Consulta executada com sucesso",
|
||||
"reset_to_ai_suggestion": "Redefinir para sugestão da IA",
|
||||
"save_chart": "Salvar gráfico",
|
||||
"save_chart_dialog_title": "Salvar gráfico",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Selecionar dimensões...",
|
||||
"select_field": "Selecionar campo",
|
||||
"select_measures": "Selecionar medidas...",
|
||||
"select_preset": "Selecionar predefinição",
|
||||
"showing_first_n_of": "Mostrando os primeiros {{n}} de {{count}} registros",
|
||||
"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."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adicionar {count} gráfico(s)",
|
||||
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
|
||||
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
|
||||
"charts_load_failed": "Falha ao carregar gráficos",
|
||||
"create_dashboard": "Criar painel",
|
||||
"create_dashboard_description": "Digite um nome para o seu novo painel.",
|
||||
"create_failed": "Falha ao criar painel",
|
||||
"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.",
|
||||
"dashboard_name": "Nome do painel",
|
||||
"dashboard_name_placeholder": "Meu painel",
|
||||
"dashboard_name_required": "O nome do painel é obrigatório",
|
||||
"dashboard_save_failed": "Falha ao salvar o painel",
|
||||
"dashboard_saved": "Painel salvo com sucesso",
|
||||
"delete_confirmation": "Tem certeza de que deseja excluir este painel? Esta ação não pode ser desfeita.",
|
||||
"delete_failed": "Falha ao excluir painel",
|
||||
"delete_success": "Painel excluído com sucesso",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Adicionar chave de API",
|
||||
"api_key": "Chave de API",
|
||||
@@ -2308,6 +2505,7 @@
|
||||
"archive_not_allowed": "Você não tem permissão para arquivar este diretório.",
|
||||
"are_you_sure_you_want_to_archive": "Tem certeza de que deseja arquivar este diretório? Os espaços de trabalho não terão mais acesso a ele.",
|
||||
"assign_workspaces_description": "Controle quais espaços de trabalho podem acessar este diretório de registros de feedback.",
|
||||
"connectors_description": "Conectores que enviam registros de feedback para este diretório.",
|
||||
"create_feedback_directory": "Criar diretório de feedback",
|
||||
"description": "Gerencie diretórios de registros de feedback e suas atribuições de espaços de trabalho.",
|
||||
"directory_archived_successfully": "Diretório arquivado com sucesso",
|
||||
@@ -2319,12 +2517,13 @@
|
||||
"directory_unarchived_successfully": "Diretório desarquivado com sucesso",
|
||||
"directory_updated_successfully": "Diretório atualizado com sucesso",
|
||||
"empty_state": "Nenhum diretório de registros de feedback encontrado. Crie um para começar.",
|
||||
"enter_directory_name": "Digite o nome do diretório",
|
||||
"error_directory_has_connectors": "Não é possível arquivar um diretório que tem conectores vinculados a ele. Remova todos os conectores primeiro.",
|
||||
"error_directory_name_duplicate": "Já existe um diretório de registros de feedback com este nome.",
|
||||
"error_directory_name_required": "O nome do diretório é obrigatório.",
|
||||
"error_directory_workspaces_invalid_org": "Alguns espaços de trabalho especificados não pertencem a esta organização.",
|
||||
"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.",
|
||||
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
|
||||
"show_archived": "Mostrar arquivados",
|
||||
"title": "Diretórios de Registros de Feedback",
|
||||
@@ -3368,6 +3567,119 @@
|
||||
"team_name": "Nome da equipe",
|
||||
"team_settings_description": "Veja quais equipes podem acessar este workspace."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Adicionar fonte de feedback",
|
||||
"add_source": "Adicionar fonte",
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"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",
|
||||
"connector_status_updated_successfully": "Status do conector atualizado com sucesso",
|
||||
"connector_updated_successfully": "Conector atualizado com sucesso",
|
||||
"connectors": "Conectores",
|
||||
"create_mapping": "Criar mapeamento",
|
||||
"created_by": "Criado por",
|
||||
"csv_at_least_one_row": "O CSV deve conter pelo menos uma linha de dados.",
|
||||
"csv_columns": "Colunas CSV",
|
||||
"csv_empty_column_headers": "O CSV contém cabeçalhos de coluna vazios. Todas as colunas devem ter um nome.",
|
||||
"csv_file_too_large": "O arquivo CSV é muito grande. O tamanho máximo é 2MB.",
|
||||
"csv_files_only": "Apenas arquivos CSV",
|
||||
"csv_import": "Importação CSV",
|
||||
"csv_import_complete": "Importação de CSV concluída: {successes} bem-sucedidas, {failures} falharam, {skipped} ignoradas",
|
||||
"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.",
|
||||
"default_connector_name_csv": "Importação CSV",
|
||||
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
|
||||
"deselect_all": "Desmarcar tudo",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
"drop_field_or": "Solte o campo ou",
|
||||
"edit_csv_mapping": "Editar mapeamento CSV",
|
||||
"edit_source_connection": "Editar conexão de origem",
|
||||
"enter_name_for_source": "Digite um nome para esta origem",
|
||||
"enter_value": "Digite o valor...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registros de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_record_directory": "Diretório de Registros de Feedback",
|
||||
"feedback_record_fields": "Campos do registro de feedback",
|
||||
"feedback_records": "Registros de feedback",
|
||||
"feedback_records_refreshed": "Registros de feedback atualizados",
|
||||
"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_rows": "Importar {count} linhas",
|
||||
"importing_data": "Importando dados...",
|
||||
"importing_historical_data": "Importando dados históricos...",
|
||||
"invalid_enum_values": "Valores inválidos na coluna mapeada para {field}",
|
||||
"invalid_values_found": "Encontrados: {values} (linhas: {rows}) {extra}",
|
||||
"load_sample_csv": "Carregar CSV de exemplo",
|
||||
"n_supported_questions": "{count} perguntas suportadas",
|
||||
"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...",
|
||||
"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_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.",
|
||||
"set_value": "definir valor",
|
||||
"setup_connection": "Configurar conexão",
|
||||
"showing_count_loaded": "Mostrando {count} registros",
|
||||
"showing_rows": "Mostrando 3 de {count} linhas",
|
||||
"source": "fonte",
|
||||
"source_connect_csv_description": "Importar feedback de arquivos CSV",
|
||||
"source_connect_formbricks_description": "Conectar feedback das suas pesquisas Formbricks",
|
||||
"source_fields": "Campos de origem",
|
||||
"source_name": "Nome da origem",
|
||||
"source_type": "Tipo de fonte",
|
||||
"source_type_cannot_be_changed": "O tipo de origem não pode ser alterado",
|
||||
"sources": "Origens",
|
||||
"status_active": "Em andamento",
|
||||
"status_completed": "Concluído",
|
||||
"status_draft": "Rascunho",
|
||||
"status_error": "Erro",
|
||||
"status_paused": "Pausado",
|
||||
"survey_has_no_questions": "Esta pesquisa não possui perguntas",
|
||||
"survey_import_line": "{surveyName}: {responseCount} respostas × {questionCount} perguntas = {total} registros de feedback",
|
||||
"total_feedback_records": "Total: {checked} de {total} registros de feedback selecionados em {surveyCount} pesquisas",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Atualize a configuração de mapeamento para esta fonte.",
|
||||
"updated_at": "Atualizado em",
|
||||
"upload_csv_data_description": "Faça upload de um arquivo CSV para importar dados de feedback.",
|
||||
"upload_csv_file": "Fazer upload de arquivo CSV",
|
||||
"user_identifier": "Usuário",
|
||||
"value": "Valor"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Aproveite cada ponto de contato para entender a facilidade de interação do cliente.",
|
||||
|
||||
+313
-1
@@ -125,6 +125,8 @@
|
||||
"activity": "Atividade",
|
||||
"add": "Adicionar",
|
||||
"add_action": "Adicionar ação",
|
||||
"add_charts": "Adicionar gráficos",
|
||||
"add_existing_chart_description": "Pesquisa e seleciona gráficos para adicionar a este painel.",
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_logo": "Adicionar logótipo",
|
||||
"add_member": "Adicionar membro",
|
||||
@@ -136,6 +138,7 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
|
||||
"analysis": "Análise",
|
||||
"and": "E",
|
||||
"anonymous": "Anónimo",
|
||||
"api_keys": "Chaves API",
|
||||
@@ -154,6 +157,8 @@
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"change_organization": "Alterar organização",
|
||||
"change_workspace": "Alterar espaço de trabalho",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Escolhas",
|
||||
"choose_organization": "Escolher organização",
|
||||
"choose_workspace": "Escolher projeto",
|
||||
@@ -186,6 +191,7 @@
|
||||
"count_questions": "{count, plural, one {{count} pergunta} other {{count} perguntas}}",
|
||||
"count_responses": "{count, plural, one {{count} resposta} other {{count} respostas}}",
|
||||
"count_selections": "{count, plural, one {{count} seleção} other {{count} seleções}}",
|
||||
"create": "Criar",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_segment": "Criar segmento",
|
||||
"create_survey": "Criar inquérito",
|
||||
@@ -195,6 +201,8 @@
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "Sobreposição escura",
|
||||
"dashboard": "Painel",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Data",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
@@ -218,6 +226,7 @@
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"enable": "Ativar",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Introduzir URL",
|
||||
"enterprise_license": "Licença Enterprise",
|
||||
@@ -232,6 +241,7 @@
|
||||
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
||||
"failed_to_parse_csv": "Falha ao analisar o CSV",
|
||||
"filter": "Filtro",
|
||||
"finish": "Concluir",
|
||||
"first_name": "Primeiro nome",
|
||||
@@ -246,6 +256,7 @@
|
||||
"hidden": "Oculto",
|
||||
"hidden_field": "Campo oculto",
|
||||
"hidden_fields": "Campos ocultos",
|
||||
"hide": "Ocultar",
|
||||
"hide_column": "Ocultar coluna",
|
||||
"id": "ID",
|
||||
"image": "Imagem",
|
||||
@@ -292,6 +303,7 @@
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
|
||||
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
|
||||
"months": "meses",
|
||||
"more_options": "Mais opções",
|
||||
"move_down": "Mover para baixo",
|
||||
"move_up": "Mover para cima",
|
||||
"multiple_languages": "Várias línguas",
|
||||
@@ -300,8 +312,10 @@
|
||||
"new": "Novo",
|
||||
"new_version_available": "Formbricks {version} está aqui. Atualize agora!",
|
||||
"next": "Seguinte",
|
||||
"no": "Não",
|
||||
"no_actions_found": "Nenhuma ação encontrada",
|
||||
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
|
||||
"no_changes": "Sem alterações",
|
||||
"no_code": "Sem código",
|
||||
"no_files_uploaded": "Nenhum ficheiro foi carregado",
|
||||
"no_overlay": "Sem sobreposição",
|
||||
@@ -323,6 +337,7 @@
|
||||
"on": "Ligado",
|
||||
"only_one_file_allowed": "Apenas um ficheiro é permitido",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
|
||||
"open_options": "Abrir opções",
|
||||
"option_id": "ID de Opção",
|
||||
"option_ids": "IDs de Opção",
|
||||
"optional": "Opcional",
|
||||
@@ -365,6 +380,7 @@
|
||||
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
|
||||
"read_docs": "Ler documentação",
|
||||
"recipients": "Destinatários",
|
||||
"refresh": "Atualizar",
|
||||
"remove": "Remover",
|
||||
"remove_from_team": "Remover da equipa",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
@@ -376,6 +392,7 @@
|
||||
"response_id": "ID de resposta",
|
||||
"responses": "Respostas",
|
||||
"restart": "Reiniciar",
|
||||
"retry": "Tentar novamente",
|
||||
"role": "Função",
|
||||
"saas": "SaaS",
|
||||
"sales": "Vendas",
|
||||
@@ -384,6 +401,7 @@
|
||||
"save_changes": "Guardar alterações",
|
||||
"saving": "Guardando",
|
||||
"search": "Procurar",
|
||||
"search_charts": "Pesquisar gráficos...",
|
||||
"security": "Segurança",
|
||||
"segment": "Segmento",
|
||||
"segments": "Segmentos",
|
||||
@@ -447,6 +465,7 @@
|
||||
"trial_one_day_remaining": "1 dia restante no teu período de teste",
|
||||
"try_again": "Tente novamente",
|
||||
"type": "Tipo",
|
||||
"unify": "Unificar",
|
||||
"unknown_survey": "Inquérito desconhecido",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
|
||||
"update": "Atualizar",
|
||||
@@ -464,6 +483,7 @@
|
||||
"variables": "Variáveis",
|
||||
"verified_email": "Email verificado",
|
||||
"video": "Vídeo",
|
||||
"view": "Ver",
|
||||
"warning": "Aviso",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Não foi possível verificar a sua licença porque o servidor de licenças está inacessível.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +502,7 @@
|
||||
"workspace_name_placeholder": "ex. Formbricks",
|
||||
"workspaces": "Projetos",
|
||||
"years": "anos",
|
||||
"yes": "Sim",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Não está autorizado a realizar esta ação.",
|
||||
@@ -1610,6 +1631,182 @@
|
||||
"your_survey_would_be_shown_on_this_url": "O seu inquérito seria mostrado neste URL.",
|
||||
"your_survey_would_not_be_shown": "O seu inquérito não seria mostrado."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "OU",
|
||||
"add_chart_to_dashboard": "Adicionar gráfico ao painel",
|
||||
"add_chart_to_dashboard_description": "Seleciona um painel para adicionar este gráfico. O gráfico será guardado automaticamente.",
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_to_dashboard": "Adicionar ao painel",
|
||||
"advanced_chart_builder_config_prompt": "Configura o teu gráfico e clica em \"Executar consulta\" para pré-visualizar",
|
||||
"ai_query_placeholder": "ex: Quantos utilizadores se registaram na semana passada?",
|
||||
"ai_query_section_description": "Descreve o que queres ver e deixa a IA construir o gráfico.",
|
||||
"ai_query_section_title": "Pergunta aos teus dados",
|
||||
"and_filter_logic": "E",
|
||||
"apply_changes": "Aplicar alterações",
|
||||
"chart": "Gráfico",
|
||||
"chart_added_to_dashboard": "Gráfico adicionado ao painel!",
|
||||
"chart_builder_choose_chart_type": "Escolher tipo de gráfico",
|
||||
"chart_data": "Dados do gráfico",
|
||||
"chart_data_tab": "Dados",
|
||||
"chart_deleted_successfully": "Gráfico eliminado com sucesso",
|
||||
"chart_deletion_error": "Falha ao eliminar gráfico",
|
||||
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
|
||||
"chart_duplication_error": "Falha ao duplicar gráfico",
|
||||
"chart_name": "Nome do gráfico",
|
||||
"chart_name_placeholder": "Nome do gráfico",
|
||||
"chart_preview": "Pré-visualização do gráfico",
|
||||
"chart_render_error": "Algo correu mal ao renderizar este gráfico.",
|
||||
"chart_saved_successfully": "Gráfico guardado com sucesso!",
|
||||
"chart_type_area": "Gráfico de área",
|
||||
"chart_type_bar": "Gráfico de barras",
|
||||
"chart_type_big_number": "Número grande",
|
||||
"chart_type_line": "Gráfico de linhas",
|
||||
"chart_type_not_supported": "Tipo de gráfico \"{{chartType}}\" ainda não suportado",
|
||||
"chart_type_pie": "Gráfico circular",
|
||||
"chart_updated_successfully": "Gráfico atualizado com sucesso!",
|
||||
"configure_description": "Modifique o tipo de gráfico e outras definições para esta visualização.",
|
||||
"configure_title": "Configurar gráfico",
|
||||
"configure_type_label": "Tipo de gráfico",
|
||||
"contains": "contém",
|
||||
"create_chart": "Criar gráfico",
|
||||
"create_chart_description": "Use IA para gerar um gráfico ou crie um manualmente.",
|
||||
"create_chart_with_ai": "Criar gráfico com IA",
|
||||
"custom_range": "Intervalo personalizado",
|
||||
"dashboard": "Painel",
|
||||
"dashboard_select_placeholder": "Selecione um painel",
|
||||
"data_label": "Dados",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Últimos 30 dias",
|
||||
"date_preset_last_7_days": "Últimos 7 dias",
|
||||
"date_preset_last_month": "Último mês",
|
||||
"date_preset_this_month": "Este mês",
|
||||
"date_preset_this_quarter": "Este trimestre",
|
||||
"date_preset_this_year": "Este ano",
|
||||
"date_preset_today": "Hoje",
|
||||
"date_preset_yesterday": "Ontem",
|
||||
"date_range": "Intervalo de datas",
|
||||
"delete_chart_confirmation": "Tens a certeza de que queres eliminar este gráfico?",
|
||||
"dimensions": "Dimensões",
|
||||
"dimensions_toggle_description": "Agrupa dados por sentimento, tipo de pergunta e outras dimensões.",
|
||||
"edit_chart_description": "Visualize e edite a configuração do seu gráfico.",
|
||||
"edit_chart_title": "Editar gráfico",
|
||||
"enable_time_dimension": "Ativar dimensão temporal",
|
||||
"end_date": "Data de fim",
|
||||
"enter_a_name_for_your_chart": "Introduza um nome para o seu gráfico para o guardar.",
|
||||
"enter_value": "Introduza o valor",
|
||||
"equals": "igual",
|
||||
"failed_to_add_chart_to_dashboard": "Falha ao adicionar gráfico ao painel",
|
||||
"failed_to_execute_query": "Falha ao executar consulta",
|
||||
"failed_to_load_chart": "Falha ao carregar gráfico",
|
||||
"failed_to_load_chart_data": "Falha ao carregar dados do gráfico",
|
||||
"failed_to_save_chart": "Falha ao guardar gráfico",
|
||||
"field": "Campo",
|
||||
"field_label_average_score": "Pontuação média",
|
||||
"field_label_collected_at": "Recolhido em",
|
||||
"field_label_count": "Contagem",
|
||||
"field_label_detractor_count": "Contagem de detratores",
|
||||
"field_label_emotion": "Emoção",
|
||||
"field_label_field_type": "Tipo de campo",
|
||||
"field_label_nps_score": "Pontuação NPS",
|
||||
"field_label_nps_value": "Valor NPS",
|
||||
"field_label_passive_count": "Contagem de passivos",
|
||||
"field_label_promoter_count": "Contagem de promotores",
|
||||
"field_label_response_id": "ID de resposta",
|
||||
"field_label_sentiment": "Sentimento",
|
||||
"field_label_source_name": "Nome da origem",
|
||||
"field_label_source_type": "Tipo de origem",
|
||||
"field_label_topic": "Tópico",
|
||||
"field_label_user_identifier": "Identificador de utilizador",
|
||||
"filter_data": "Filtrar dados",
|
||||
"filters": "Filtros",
|
||||
"filters_toggle_description": "Incluir apenas dados que cumpram as seguintes condições.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularidade",
|
||||
"granularity_day": "Dia",
|
||||
"granularity_hour": "Hora",
|
||||
"granularity_month": "Mês",
|
||||
"granularity_quarter": "Trimestre",
|
||||
"granularity_week": "Semana",
|
||||
"granularity_year": "Ano",
|
||||
"greater_than": "maior que",
|
||||
"greater_than_or_equal": "maior ou igual a",
|
||||
"group_by": "Agrupar por",
|
||||
"group_by_description": "Divide os teus dados por uma ou mais dimensões. A ordem é importante se escolheres múltiplas dimensões.",
|
||||
"group_data": "Agrupar dados",
|
||||
"is_not_set": "não está definido",
|
||||
"is_set": "está definido",
|
||||
"less_than": "menor que",
|
||||
"less_than_or_equal": "menor ou igual a",
|
||||
"measures": "Medidas",
|
||||
"no_charts_found": "Nenhum gráfico encontrado.",
|
||||
"no_dashboards_available": "Nenhum painel disponível",
|
||||
"no_dashboards_create_first": "Cria primeiro um painel para adicionar gráficos.",
|
||||
"no_data_available": "Nenhum dado disponível",
|
||||
"no_data_returned": "Nenhum dado devolvido pela consulta",
|
||||
"no_data_returned_for_chart": "Nenhum dado devolvido para o gráfico",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Nenhum (apenas filtro)",
|
||||
"no_valid_data_to_display": "Nenhum dado válido para exibir",
|
||||
"not_contains": "não contém",
|
||||
"not_equals": "não é igual a",
|
||||
"open_chart": "Abrir gráfico {{name}}",
|
||||
"open_options": "Abrir opções do gráfico",
|
||||
"or_filter_logic": "OU",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Por favor, introduz um nome para o gráfico",
|
||||
"please_enter_filter_values": "Por favor, introduz valores para todos os filtros",
|
||||
"please_select_at_least_one_dimension": "Por favor, seleciona pelo menos uma dimensão ou desativa o agrupamento",
|
||||
"please_select_at_least_one_measure": "Por favor, seleciona pelo menos uma medida",
|
||||
"please_select_dashboard": "Por favor, seleciona um painel",
|
||||
"predefined_measures": "Medidas predefinidas",
|
||||
"preset": "Predefinição",
|
||||
"query_executed_successfully": "Consulta executada com sucesso",
|
||||
"reset_to_ai_suggestion": "Repor sugestão da IA",
|
||||
"save_chart": "Guardar gráfico",
|
||||
"save_chart_dialog_title": "Guardar gráfico",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Selecionar dimensões...",
|
||||
"select_field": "Selecionar campo",
|
||||
"select_measures": "Selecionar medidas...",
|
||||
"select_preset": "Selecionar predefinição",
|
||||
"showing_first_n_of": "A mostrar as primeiras {{n}} de {{count}} linhas",
|
||||
"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."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adicionar {count} gráfico(s)",
|
||||
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
|
||||
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
|
||||
"charts_load_failed": "Falha ao carregar gráficos",
|
||||
"create_dashboard": "Criar painel",
|
||||
"create_dashboard_description": "Introduza um nome para o seu novo painel.",
|
||||
"create_failed": "Falha ao criar painel",
|
||||
"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.",
|
||||
"dashboard_name": "Nome do painel",
|
||||
"dashboard_name_placeholder": "O meu painel",
|
||||
"dashboard_name_required": "O nome do painel é obrigatório",
|
||||
"dashboard_save_failed": "Falha ao guardar o painel",
|
||||
"dashboard_saved": "Painel guardado com sucesso",
|
||||
"delete_confirmation": "Tem a certeza de que pretende eliminar este painel? Esta ação não pode ser revertida.",
|
||||
"delete_failed": "Falha ao eliminar painel",
|
||||
"delete_success": "Painel eliminado com sucesso",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Adicionar chave API",
|
||||
"api_key": "Chave API",
|
||||
@@ -2308,6 +2505,7 @@
|
||||
"archive_not_allowed": "Não tens permissão para arquivar este diretório.",
|
||||
"are_you_sure_you_want_to_archive": "Tens a certeza de que queres arquivar este diretório? Os espaços de trabalho deixarão de ter acesso ao mesmo.",
|
||||
"assign_workspaces_description": "Controla quais os espaços de trabalho que podem aceder a este diretório de registos de feedback.",
|
||||
"connectors_description": "Conectores que enviam registos de feedback para este diretório.",
|
||||
"create_feedback_directory": "Criar diretório de feedback",
|
||||
"description": "Gere diretórios de registos de feedback e as suas atribuições de espaços de trabalho.",
|
||||
"directory_archived_successfully": "Diretório arquivado com sucesso",
|
||||
@@ -2319,12 +2517,13 @@
|
||||
"directory_unarchived_successfully": "Diretório desarquivado com sucesso",
|
||||
"directory_updated_successfully": "Diretório atualizado com sucesso",
|
||||
"empty_state": "Não foram encontrados diretórios de registos de feedback. Cria um para começar.",
|
||||
"enter_directory_name": "Insere o nome do diretório",
|
||||
"error_directory_has_connectors": "Não é possível arquivar um diretório que tem conectores associados. Remove todos os conectores primeiro.",
|
||||
"error_directory_name_duplicate": "Já existe um diretório de registos de feedback com este nome.",
|
||||
"error_directory_name_required": "O nome do diretório é obrigatório.",
|
||||
"error_directory_workspaces_invalid_org": "Algumas áreas de trabalho especificadas não pertencem a esta organização.",
|
||||
"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.",
|
||||
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
|
||||
"show_archived": "Mostrar arquivados",
|
||||
"title": "Diretórios de Registos de Feedback",
|
||||
@@ -3368,6 +3567,119 @@
|
||||
"team_name": "Nome da equipa",
|
||||
"team_settings_description": "Veja quais as equipas que podem aceder a este espaço de trabalho."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Adicionar fonte de feedback",
|
||||
"add_source": "Adicionar fonte",
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"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",
|
||||
"connector_status_updated_successfully": "Estado do conector atualizado com sucesso",
|
||||
"connector_updated_successfully": "Conector atualizado com sucesso",
|
||||
"connectors": "Conectores",
|
||||
"create_mapping": "Criar mapeamento",
|
||||
"created_by": "Criado por",
|
||||
"csv_at_least_one_row": "O CSV deve conter pelo menos uma linha de dados.",
|
||||
"csv_columns": "Colunas CSV",
|
||||
"csv_empty_column_headers": "O CSV contém cabeçalhos de coluna vazios. Todas as colunas devem ter um nome.",
|
||||
"csv_file_too_large": "O ficheiro CSV é demasiado grande. O tamanho máximo é 2MB.",
|
||||
"csv_files_only": "Apenas ficheiros CSV",
|
||||
"csv_import": "Importação CSV",
|
||||
"csv_import_complete": "Importação de CSV concluída: {successes} com sucesso, {failures} falharam, {skipped} ignorados",
|
||||
"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.",
|
||||
"default_connector_name_csv": "Importação CSV",
|
||||
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
|
||||
"deselect_all": "Desselecionar tudo",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
"drop_field_or": "Solte o campo ou",
|
||||
"edit_csv_mapping": "Editar mapeamento CSV",
|
||||
"edit_source_connection": "Editar ligação de origem",
|
||||
"enter_name_for_source": "Introduz um nome para esta origem",
|
||||
"enter_value": "Introduzir valor...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registos de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_record_directory": "Diretório de Registos de Feedback",
|
||||
"feedback_record_fields": "Campos de registo de feedback",
|
||||
"feedback_records": "Registos de feedback",
|
||||
"feedback_records_refreshed": "Registos de feedback atualizados",
|
||||
"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_rows": "Importar {count} linhas",
|
||||
"importing_data": "A importar dados...",
|
||||
"importing_historical_data": "A importar dados históricos...",
|
||||
"invalid_enum_values": "Valores inválidos na coluna mapeada para {field}",
|
||||
"invalid_values_found": "Encontrados: {values} (linhas: {rows}) {extra}",
|
||||
"load_sample_csv": "Carregar CSV de exemplo",
|
||||
"n_supported_questions": "{count} perguntas suportadas",
|
||||
"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...",
|
||||
"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_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.",
|
||||
"set_value": "definir valor",
|
||||
"setup_connection": "Configurar ligação",
|
||||
"showing_count_loaded": "A mostrar {count} registos",
|
||||
"showing_rows": "A mostrar 3 de {count} linhas",
|
||||
"source": "fonte",
|
||||
"source_connect_csv_description": "Importar feedback de ficheiros CSV",
|
||||
"source_connect_formbricks_description": "Conectar feedback dos seus inquéritos Formbricks",
|
||||
"source_fields": "Campos da fonte",
|
||||
"source_name": "Nome da fonte",
|
||||
"source_type": "Tipo de fonte",
|
||||
"source_type_cannot_be_changed": "O tipo de fonte não pode ser alterado",
|
||||
"sources": "Fontes",
|
||||
"status_active": "Em progresso",
|
||||
"status_completed": "Concluído",
|
||||
"status_draft": "Rascunho",
|
||||
"status_error": "Erro",
|
||||
"status_paused": "Em pausa",
|
||||
"survey_has_no_questions": "Este inquérito não tem perguntas",
|
||||
"survey_import_line": "{surveyName}: {responseCount} respostas × {questionCount} perguntas = {total} registos de feedback",
|
||||
"total_feedback_records": "Total: {checked} de {total} registos de feedback selecionados em {surveyCount} inquéritos",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Atualiza a configuração de mapeamento para esta origem.",
|
||||
"updated_at": "Atualizado em",
|
||||
"upload_csv_data_description": "Carrega um ficheiro CSV para importar dados de feedback.",
|
||||
"upload_csv_file": "Carregar ficheiro CSV",
|
||||
"user_identifier": "Utilizador",
|
||||
"value": "Valor"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Aproveite todos os pontos de contato para entender a facilidade de interação do cliente.",
|
||||
|
||||
+313
-1
@@ -125,6 +125,8 @@
|
||||
"activity": "Activitate",
|
||||
"add": "Adaugă",
|
||||
"add_action": "Adăugați acțiune",
|
||||
"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",
|
||||
"add_logo": "Adaugă logo",
|
||||
"add_member": "Adaugă membru",
|
||||
@@ -136,6 +138,7 @@
|
||||
"allow": "Permite",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
|
||||
"analysis": "Analiză",
|
||||
"and": "Și",
|
||||
"anonymous": "Anonim",
|
||||
"api_keys": "Chei API",
|
||||
@@ -154,6 +157,8 @@
|
||||
"centered_modal": "Modală centralizată",
|
||||
"change_organization": "Schimbă organizația",
|
||||
"change_workspace": "Schimbă spațiul de lucru",
|
||||
"chart": "Grafic",
|
||||
"charts": "Grafice",
|
||||
"choices": "Alegeri",
|
||||
"choose_organization": "Alege organizația",
|
||||
"choose_workspace": "Alege workspace",
|
||||
@@ -186,6 +191,7 @@
|
||||
"count_questions": "{count, plural, one {# întrebare} few {# întrebări} other {# de întrebări}}",
|
||||
"count_responses": "{count, plural, one {{count} răspuns} few {{count} răspunsuri} other {{count} de răspunsuri}}",
|
||||
"count_selections": "{count, plural, one {{count} selecție} few {{count} selecții} other {{count} de selecții}}",
|
||||
"create": "Creează",
|
||||
"create_new_organization": "Creează organizație nouă",
|
||||
"create_segment": "Creați segment",
|
||||
"create_survey": "Creează sondaj",
|
||||
@@ -195,6 +201,8 @@
|
||||
"created_by": "Creat de",
|
||||
"customer_success": "Succesul Clientului",
|
||||
"dark_overlay": "Suprapunere întunecată",
|
||||
"dashboard": "Tablou de bord",
|
||||
"dashboards": "Tablouri de bord",
|
||||
"date": "Dată",
|
||||
"days": "zile",
|
||||
"default": "Implicit",
|
||||
@@ -218,6 +226,7 @@
|
||||
"edit": "Editare",
|
||||
"elements": "Elemente",
|
||||
"email": "Email",
|
||||
"enable": "Activează",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"enter_url": "Introduceți URL-ul",
|
||||
"enterprise_license": "Licență Întreprindere",
|
||||
@@ -232,6 +241,7 @@
|
||||
"failed_to_copy_to_clipboard": "Nu s-a reușit copierea în clipboard",
|
||||
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
|
||||
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
|
||||
"failed_to_parse_csv": "Nu s-a putut procesa fișierul CSV",
|
||||
"filter": "Filtru",
|
||||
"finish": "Finalizează",
|
||||
"first_name": "Prenume",
|
||||
@@ -246,6 +256,7 @@
|
||||
"hidden": "Ascuns",
|
||||
"hidden_field": "Câmp ascuns",
|
||||
"hidden_fields": "Câmpuri ascunse",
|
||||
"hide": "Ascunde",
|
||||
"hide_column": "Ascunde coloana",
|
||||
"id": "ID",
|
||||
"image": "Imagine",
|
||||
@@ -292,6 +303,7 @@
|
||||
"mobile_overlay_surveys_look_good": "Nu vă faceți griji – chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
|
||||
"mobile_overlay_title": "Ups, ecran mic detectat!",
|
||||
"months": "luni",
|
||||
"more_options": "Mai multe opțiuni",
|
||||
"move_down": "Mută în jos",
|
||||
"move_up": "Mută sus",
|
||||
"multiple_languages": "Mai multe limbi",
|
||||
@@ -300,8 +312,10 @@
|
||||
"new": "Nou",
|
||||
"new_version_available": "Formbricks {version} este disponibil. Actualizați acum!",
|
||||
"next": "Următorul",
|
||||
"no": "Nu",
|
||||
"no_actions_found": "Nu au fost găsite acțiuni",
|
||||
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
|
||||
"no_changes": "Nicio modificare",
|
||||
"no_code": "Fără Cod",
|
||||
"no_files_uploaded": "Nu au fost încărcate fișiere",
|
||||
"no_overlay": "Fără overlay",
|
||||
@@ -323,6 +337,7 @@
|
||||
"on": "Pe",
|
||||
"only_one_file_allowed": "Este permis doar un fișier",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.",
|
||||
"open_options": "Deschide opțiunile",
|
||||
"option_id": "ID opțiune",
|
||||
"option_ids": "ID-uri opțiuni",
|
||||
"optional": "Opțional",
|
||||
@@ -365,6 +380,7 @@
|
||||
"quotas_description": "Limitați numărul de răspunsuri primite de la participanții care îndeplinesc anumite criterii.",
|
||||
"read_docs": "Citește documentația",
|
||||
"recipients": "Destinatari",
|
||||
"refresh": "Reîmprospătează",
|
||||
"remove": "Șterge",
|
||||
"remove_from_team": "Elimină din echipă",
|
||||
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
|
||||
@@ -376,6 +392,7 @@
|
||||
"response_id": "ID răspuns",
|
||||
"responses": "Răspunsuri",
|
||||
"restart": "Repornește",
|
||||
"retry": "Reîncearcă",
|
||||
"role": "Rolul",
|
||||
"saas": "SaaS",
|
||||
"sales": "Vânzări",
|
||||
@@ -384,6 +401,7 @@
|
||||
"save_changes": "Salvează modificările",
|
||||
"saving": "Salvare",
|
||||
"search": "Căutare",
|
||||
"search_charts": "Caută grafice...",
|
||||
"security": "Securitate",
|
||||
"segment": "Segment",
|
||||
"segments": "Segment",
|
||||
@@ -447,6 +465,7 @@
|
||||
"trial_one_day_remaining": "1 zi rămasă în perioada ta de probă",
|
||||
"try_again": "Încearcă din nou",
|
||||
"type": "Tip",
|
||||
"unify": "Unifică",
|
||||
"unknown_survey": "Chestionar necunoscut",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Deblochează mai multe workspaces cu un plan superior.",
|
||||
"update": "Actualizare",
|
||||
@@ -464,6 +483,7 @@
|
||||
"variables": "Variante",
|
||||
"verified_email": "Email verificat",
|
||||
"video": "Video",
|
||||
"view": "Vezi",
|
||||
"warning": "Avertisment",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nu am putut verifica licența dvs. deoarece serverul de licențe este inaccesibil.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +502,7 @@
|
||||
"workspace_name_placeholder": "ex: Formbricks",
|
||||
"workspaces": "Workspaces",
|
||||
"years": "ani",
|
||||
"yes": "Da",
|
||||
"you": "Tu",
|
||||
"you_are_downgraded_to_the_community_edition": "Ai fost retrogradat la ediția Community.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Nu sunteți autorizat să efectuați această acțiune.",
|
||||
@@ -1610,6 +1631,182 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Sondajul dumneavoastră ar fi afișat pe acest URL.",
|
||||
"your_survey_would_not_be_shown": "Sondajul dumneavoastră nu va fi afișat."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "SAU",
|
||||
"add_chart_to_dashboard": "Adaugă grafic la Tablou de Bord",
|
||||
"add_chart_to_dashboard_description": "Selectează un tablou de bord la care să adaugi acest grafic. Graficul va fi salvat automat.",
|
||||
"add_filter": "Adaugă filtru",
|
||||
"add_to_dashboard": "Adaugă la Tablou de Bord",
|
||||
"advanced_chart_builder_config_prompt": "Configurează graficul și apasă pe \"Rulează interogarea\" pentru previzualizare",
|
||||
"ai_query_placeholder": "ex: Câți utilizatori s-au înscris săptămâna trecută?",
|
||||
"ai_query_section_description": "Descrie ce vrei să vezi și lasă AI-ul să construiască graficul.",
|
||||
"ai_query_section_title": "Întreabă-ți datele",
|
||||
"and_filter_logic": "ȘI",
|
||||
"apply_changes": "Aplică modificările",
|
||||
"chart": "Grafic",
|
||||
"chart_added_to_dashboard": "Grafic adăugat la tablou de bord!",
|
||||
"chart_builder_choose_chart_type": "Alege tipul de grafic",
|
||||
"chart_data": "Datele graficului",
|
||||
"chart_data_tab": "Date",
|
||||
"chart_deleted_successfully": "Graficul a fost șters cu succes",
|
||||
"chart_deletion_error": "Nu s-a putut șterge graficul",
|
||||
"chart_duplicated_successfully": "Graficul a fost duplicat cu succes",
|
||||
"chart_duplication_error": "Nu s-a putut duplica graficul",
|
||||
"chart_name": "Numele graficului",
|
||||
"chart_name_placeholder": "Numele graficului",
|
||||
"chart_preview": "Previzualizare grafic",
|
||||
"chart_render_error": "Ceva nu a mers bine la afișarea acestui grafic.",
|
||||
"chart_saved_successfully": "Graficul a fost salvat cu succes!",
|
||||
"chart_type_area": "Grafic de tip arie",
|
||||
"chart_type_bar": "Grafic de tip bară",
|
||||
"chart_type_big_number": "Număr mare",
|
||||
"chart_type_line": "Grafic de tip linie",
|
||||
"chart_type_not_supported": "Tipul de grafic \"{{chartType}}\" nu este încă suportat",
|
||||
"chart_type_pie": "Grafic de tip plăcintă",
|
||||
"chart_updated_successfully": "Graficul a fost actualizat cu succes!",
|
||||
"configure_description": "Modifică tipul graficului și alte setări pentru această vizualizare.",
|
||||
"configure_title": "Configurează graficul",
|
||||
"configure_type_label": "Tip grafic",
|
||||
"contains": "conține",
|
||||
"create_chart": "Creează grafic",
|
||||
"create_chart_description": "Folosește AI pentru a genera un grafic sau creează unul manual.",
|
||||
"create_chart_with_ai": "Creează grafic cu AI",
|
||||
"custom_range": "Interval personalizat",
|
||||
"dashboard": "Tablou de bord",
|
||||
"dashboard_select_placeholder": "Selectează un tablou de bord",
|
||||
"data_label": "Date",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Ultimele 30 de zile",
|
||||
"date_preset_last_7_days": "Ultimele 7 zile",
|
||||
"date_preset_last_month": "Ultima lună",
|
||||
"date_preset_this_month": "Luna aceasta",
|
||||
"date_preset_this_quarter": "Trimestrul acesta",
|
||||
"date_preset_this_year": "Anul acesta",
|
||||
"date_preset_today": "Astăzi",
|
||||
"date_preset_yesterday": "Ieri",
|
||||
"date_range": "Interval de date",
|
||||
"delete_chart_confirmation": "Ești sigur că vrei să ștergi acest grafic?",
|
||||
"dimensions": "Dimensiuni",
|
||||
"dimensions_toggle_description": "Grupează datele după sentiment, tipul întrebării și alte dimensiuni.",
|
||||
"edit_chart_description": "Vezi și editează configurația graficului tău.",
|
||||
"edit_chart_title": "Editează graficul",
|
||||
"enable_time_dimension": "Activează dimensiunea de timp",
|
||||
"end_date": "Data de sfârșit",
|
||||
"enter_a_name_for_your_chart": "Introdu un nume pentru grafic ca să îl salvezi.",
|
||||
"enter_value": "Introdu valoarea",
|
||||
"equals": "egal",
|
||||
"failed_to_add_chart_to_dashboard": "Nu s-a putut adăuga graficul în tablou de bord",
|
||||
"failed_to_execute_query": "Nu s-a putut executa interogarea",
|
||||
"failed_to_load_chart": "Nu s-a putut încărca graficul",
|
||||
"failed_to_load_chart_data": "Nu s-au putut încărca datele graficului",
|
||||
"failed_to_save_chart": "Nu s-a putut salva graficul",
|
||||
"field": "Câmp",
|
||||
"field_label_average_score": "Scor mediu",
|
||||
"field_label_collected_at": "Colectat la",
|
||||
"field_label_count": "Număr",
|
||||
"field_label_detractor_count": "Număr de detractori",
|
||||
"field_label_emotion": "Emoție",
|
||||
"field_label_field_type": "Tip câmp",
|
||||
"field_label_nps_score": "Scor NPS",
|
||||
"field_label_nps_value": "Valoare NPS",
|
||||
"field_label_passive_count": "Număr de pasivi",
|
||||
"field_label_promoter_count": "Număr de promotori",
|
||||
"field_label_response_id": "ID răspuns",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Nume sursă",
|
||||
"field_label_source_type": "Tip sursă",
|
||||
"field_label_topic": "Subiect",
|
||||
"field_label_user_identifier": "Identificator utilizator",
|
||||
"filter_data": "Filtrează datele",
|
||||
"filters": "Filtre",
|
||||
"filters_toggle_description": "Include doar datele care îndeplinesc următoarele condiții.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularitate",
|
||||
"granularity_day": "Zi",
|
||||
"granularity_hour": "Oră",
|
||||
"granularity_month": "Lună",
|
||||
"granularity_quarter": "Trimestru",
|
||||
"granularity_week": "Săptămână",
|
||||
"granularity_year": "An",
|
||||
"greater_than": "mai mare decât",
|
||||
"greater_than_or_equal": "mai mare sau egal cu",
|
||||
"group_by": "Grupează după",
|
||||
"group_by_description": "Descompune datele după una sau mai multe dimensiuni. Ordinea este importantă dacă alegi mai multe dimensiuni.",
|
||||
"group_data": "Grupează datele",
|
||||
"is_not_set": "nu este setat",
|
||||
"is_set": "este setat",
|
||||
"less_than": "mai mic decât",
|
||||
"less_than_or_equal": "mai mic sau egal",
|
||||
"measures": "Măsurători",
|
||||
"no_charts_found": "Nu s-au găsit grafice.",
|
||||
"no_dashboards_available": "Nu există tablouri de bord disponibile",
|
||||
"no_dashboards_create_first": "Creează mai întâi un tablou de bord pentru a adăuga grafice.",
|
||||
"no_data_available": "Nu există date disponibile",
|
||||
"no_data_returned": "Nu au fost returnate date din interogare",
|
||||
"no_data_returned_for_chart": "Nu au fost returnate date pentru grafic",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Niciuna (doar filtru)",
|
||||
"no_valid_data_to_display": "Nu există date valide de afișat",
|
||||
"not_contains": "nu conține",
|
||||
"not_equals": "nu este egal",
|
||||
"open_chart": "Deschide graficul {{name}}",
|
||||
"open_options": "Deschide opțiunile graficului",
|
||||
"or_filter_logic": "SAU",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Te rugăm să introduci un nume pentru grafic",
|
||||
"please_enter_filter_values": "Te rugăm să introduci valori pentru toate filtrele",
|
||||
"please_select_at_least_one_dimension": "Te rugăm să selectezi cel puțin o dimensiune sau să dezactivezi gruparea",
|
||||
"please_select_at_least_one_measure": "Te rugăm să selectezi cel puțin o măsurătoare",
|
||||
"please_select_dashboard": "Te rugăm să selectezi un tablou de bord",
|
||||
"predefined_measures": "Măsurători predefinite",
|
||||
"preset": "Presetare",
|
||||
"query_executed_successfully": "Interogarea a fost executată cu succes",
|
||||
"reset_to_ai_suggestion": "Resetează la sugestia AI",
|
||||
"save_chart": "Salvează graficul",
|
||||
"save_chart_dialog_title": "Salvează graficul",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Selectează dimensiuni...",
|
||||
"select_field": "Selectează câmpul",
|
||||
"select_measures": "Selectează măsuri...",
|
||||
"select_preset": "Selectează presetarea",
|
||||
"showing_first_n_of": "Se afișează primele {{n}} din {{count}} rânduri",
|
||||
"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."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adaugă {count} grafic(e)",
|
||||
"charts_add_failed": "Nu s-au putut adăuga graficele la panoul de control",
|
||||
"charts_add_partial_failure": "Nu s-au putut adăuga {count} grafic(e)",
|
||||
"charts_added_to_dashboard": "Grafice adăugate la panoul de control",
|
||||
"charts_load_failed": "Nu s-au putut încărca graficele",
|
||||
"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_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ă.",
|
||||
"dashboard_name": "Nume tablou de bord",
|
||||
"dashboard_name_placeholder": "Tabloul meu de bord",
|
||||
"dashboard_name_required": "Numele tabloului de bord este obligatoriu",
|
||||
"dashboard_save_failed": "Salvarea tabloului de bord a eșuat",
|
||||
"dashboard_saved": "Tabloul de bord a fost salvat cu succes",
|
||||
"delete_confirmation": "Ești sigur că vrei să ștergi acest tablou de bord? Această acțiune nu poate fi anulată.",
|
||||
"delete_failed": "Ștergerea tabloului de bord a eșuat",
|
||||
"delete_success": "Tablou de bord șters cu succes",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Adaugă cheie API",
|
||||
"api_key": "Cheie API",
|
||||
@@ -2308,6 +2505,7 @@
|
||||
"archive_not_allowed": "Nu ai permisiunea să arhivezi acest director.",
|
||||
"are_you_sure_you_want_to_archive": "Ești sigur că vrei să arhivezi acest director? Spațiile de lucru nu vor mai avea acces la el.",
|
||||
"assign_workspaces_description": "Controlează care spații de lucru pot accesa acest director de înregistrări de feedback.",
|
||||
"connectors_description": "Conectori care trimit înregistrări de feedback către acest director.",
|
||||
"create_feedback_directory": "Creează director de feedback",
|
||||
"description": "Gestionează directoarele de înregistrări de feedback și atribuirile lor la spații de lucru.",
|
||||
"directory_archived_successfully": "Directorul a fost arhivat cu succes",
|
||||
@@ -2319,12 +2517,13 @@
|
||||
"directory_unarchived_successfully": "Directorul a fost dezarhivat cu succes",
|
||||
"directory_updated_successfully": "Directorul a fost actualizat cu succes",
|
||||
"empty_state": "Nu au fost găsite directoare de înregistrări de feedback. Creează unul pentru a începe.",
|
||||
"enter_directory_name": "Introdu numele directorului",
|
||||
"error_directory_has_connectors": "Nu poți arhiva un director care are conectori asociați. Elimină mai întâi toți conectorii.",
|
||||
"error_directory_name_duplicate": "Există deja un director de înregistrări feedback cu acest nume.",
|
||||
"error_directory_name_required": "Numele directorului este obligatoriu.",
|
||||
"error_directory_workspaces_invalid_org": "Unele spații de lucru specificate nu aparțin acestei organizații.",
|
||||
"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ă.",
|
||||
"select_workspaces_placeholder": "Selectează spații de lucru...",
|
||||
"show_archived": "Afișează arhivate",
|
||||
"title": "Directoare de Înregistrări Feedback",
|
||||
@@ -3368,6 +3567,119 @@
|
||||
"team_name": "Nume echipă",
|
||||
"team_settings_description": "Vedeți ce echipe pot accesa acest spațiu de lucru."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Adaugă sursă de feedback",
|
||||
"add_source": "Adaugă sursă",
|
||||
"allowed_values": "Valori permise: {values}",
|
||||
"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",
|
||||
"connector_status_updated_successfully": "Statusul conectorului a fost actualizat cu succes",
|
||||
"connector_updated_successfully": "Conector actualizat cu succes",
|
||||
"connectors": "Conectori",
|
||||
"create_mapping": "Creează mapare",
|
||||
"created_by": "Creat de",
|
||||
"csv_at_least_one_row": "CSV-ul trebuie să conțină cel puțin un rând de date.",
|
||||
"csv_columns": "Coloane CSV",
|
||||
"csv_empty_column_headers": "CSV-ul conține antete de coloană goale. Toate coloanele trebuie să aibă un nume.",
|
||||
"csv_file_too_large": "Fișierul CSV este prea mare. Dimensiunea maximă este de 2 MB.",
|
||||
"csv_files_only": "Doar fișiere CSV",
|
||||
"csv_import": "Import CSV",
|
||||
"csv_import_complete": "Import CSV finalizat: {successes} reușite, {failures} eșuate, {skipped} omise",
|
||||
"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.",
|
||||
"default_connector_name_csv": "Import CSV",
|
||||
"default_connector_name_formbricks": "Conexiune chestionar Formbricks",
|
||||
"deselect_all": "Deselectează tot",
|
||||
"drop_a_field_here": "Trage un câmp aici",
|
||||
"drop_field_or": "Trage câmpul sau",
|
||||
"edit_csv_mapping": "Editează maparea CSV",
|
||||
"edit_source_connection": "Editează conexiunea sursei",
|
||||
"enter_name_for_source": "Introdu un nume pentru această sursă",
|
||||
"enter_value": "Introdu valoarea...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback",
|
||||
"feedback_date": "Data curentă",
|
||||
"feedback_record_directory": "Director de înregistrări feedback",
|
||||
"feedback_record_fields": "Câmpuri înregistrare feedback",
|
||||
"feedback_records": "Înregistrări de feedback",
|
||||
"feedback_records_refreshed": "Înregistrările de feedback au fost actualizate",
|
||||
"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_rows": "Importă {count, plural, one {# rând} few {# rânduri} other {# de rânduri}}",
|
||||
"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",
|
||||
"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...",
|
||||
"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_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.",
|
||||
"set_value": "setează valoare",
|
||||
"setup_connection": "Configurează conexiunea",
|
||||
"showing_count_loaded": "Se afișează {count} înregistrări",
|
||||
"showing_rows": "Se afișează 3 din {count} rânduri",
|
||||
"source": "sursă",
|
||||
"source_connect_csv_description": "Importă feedback din fișiere CSV",
|
||||
"source_connect_formbricks_description": "Conectează feedback din sondajele Formbricks",
|
||||
"source_fields": "Câmpuri sursă",
|
||||
"source_name": "Nume sursă",
|
||||
"source_type": "Tip sursă",
|
||||
"source_type_cannot_be_changed": "Tipul sursei nu poate fi schimbat",
|
||||
"sources": "Surse",
|
||||
"status_active": "În progres",
|
||||
"status_completed": "Finalizat",
|
||||
"status_draft": "Schiță",
|
||||
"status_error": "Eroare",
|
||||
"status_paused": "Pauzat",
|
||||
"survey_has_no_questions": "Acest sondaj nu are întrebări",
|
||||
"survey_import_line": "{surveyName}: {responseCount} răspunsuri × {questionCount} întrebări = {total} Feedback Records",
|
||||
"total_feedback_records": "Total: {checked} din {total} Feedback Records selectate în {surveyCount} sondaje",
|
||||
"unify_feedback": "Unify Feedback",
|
||||
"update_mapping_description": "Actualizează configurația de mapare pentru această sursă.",
|
||||
"updated_at": "Actualizat la",
|
||||
"upload_csv_data_description": "Încarcă un fișier CSV pentru a importa date de feedback.",
|
||||
"upload_csv_file": "Încarcă fișier CSV",
|
||||
"user_identifier": "Utilizator",
|
||||
"value": "Valoare"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Valorificați fiecare punct de contact pentru a înțelege ușurința interacțiunilor cu clienții.",
|
||||
|
||||
+313
-1
@@ -125,6 +125,8 @@
|
||||
"activity": "Активность",
|
||||
"add": "Добавить",
|
||||
"add_action": "Добавить действие",
|
||||
"add_charts": "Добавить графики",
|
||||
"add_existing_chart_description": "Найдите и выберите графики для добавления на этот дашборд.",
|
||||
"add_filter": "Добавить фильтр",
|
||||
"add_logo": "Добавить логотип",
|
||||
"add_member": "Добавить участника",
|
||||
@@ -136,6 +138,7 @@
|
||||
"allow": "Разрешить",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
|
||||
"analysis": "Аналитика",
|
||||
"and": "и",
|
||||
"anonymous": "Аноним",
|
||||
"api_keys": "API-ключи",
|
||||
@@ -154,6 +157,8 @@
|
||||
"centered_modal": "Центрированное модальное окно",
|
||||
"change_organization": "Сменить организацию",
|
||||
"change_workspace": "Сменить рабочее пространство",
|
||||
"chart": "График",
|
||||
"charts": "Графики",
|
||||
"choices": "Варианты",
|
||||
"choose_organization": "Выберите организацию",
|
||||
"choose_workspace": "Выбрать рабочее пространство",
|
||||
@@ -186,6 +191,7 @@
|
||||
"count_questions": "{count, plural, one {{count} вопрос} few {{count} вопроса} many {{count} вопросов} other {{count} вопросов}}",
|
||||
"count_responses": "{count, plural, one {{count} ответ} few {{count} ответа} many {{count} ответов} other {{count} ответа}}",
|
||||
"count_selections": "{count, plural, one {{count} выбор} few {{count} выбора} many {{count} выборов} other {{count} выбора}}",
|
||||
"create": "Создать",
|
||||
"create_new_organization": "Создать новую организацию",
|
||||
"create_segment": "Создать сегмент",
|
||||
"create_survey": "Создать опрос",
|
||||
@@ -195,6 +201,8 @@
|
||||
"created_by": "Создано пользователем",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Тёмный оверлей",
|
||||
"dashboard": "Панель управления",
|
||||
"dashboards": "Дашборды",
|
||||
"date": "Дата",
|
||||
"days": "дни",
|
||||
"default": "По умолчанию",
|
||||
@@ -218,6 +226,7 @@
|
||||
"edit": "Редактировать",
|
||||
"elements": "Элементы",
|
||||
"email": "Email",
|
||||
"enable": "Включить",
|
||||
"ending_card": "Завершающая карточка",
|
||||
"enter_url": "Введите URL",
|
||||
"enterprise_license": "Корпоративная лицензия",
|
||||
@@ -232,6 +241,7 @@
|
||||
"failed_to_copy_to_clipboard": "Не удалось скопировать в буфер обмена",
|
||||
"failed_to_load_organizations": "Не удалось загрузить организации",
|
||||
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
|
||||
"failed_to_parse_csv": "Не удалось обработать CSV",
|
||||
"filter": "Фильтр",
|
||||
"finish": "Завершить",
|
||||
"first_name": "Имя",
|
||||
@@ -246,6 +256,7 @@
|
||||
"hidden": "Скрыто",
|
||||
"hidden_field": "Скрытое поле",
|
||||
"hidden_fields": "Скрытые поля",
|
||||
"hide": "Скрыть",
|
||||
"hide_column": "Скрыть столбец",
|
||||
"id": "ID",
|
||||
"image": "Изображение",
|
||||
@@ -292,6 +303,7 @@
|
||||
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
|
||||
"mobile_overlay_title": "Ой, обнаружен маленький экран!",
|
||||
"months": "месяцы",
|
||||
"more_options": "Дополнительные опции",
|
||||
"move_down": "Переместить вниз",
|
||||
"move_up": "Переместить вверх",
|
||||
"multiple_languages": "Несколько языков",
|
||||
@@ -300,8 +312,10 @@
|
||||
"new": "Новый",
|
||||
"new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!",
|
||||
"next": "Далее",
|
||||
"no": "Нет",
|
||||
"no_actions_found": "Действия не найдены",
|
||||
"no_background_image_found": "Фоновое изображение не найдено.",
|
||||
"no_changes": "Нет изменений",
|
||||
"no_code": "Нет кода",
|
||||
"no_files_uploaded": "Файлы не были загружены",
|
||||
"no_overlay": "Без наложения",
|
||||
@@ -323,6 +337,7 @@
|
||||
"on": "Вкл.",
|
||||
"only_one_file_allowed": "Разрешён только один файл",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Только владельцы и менеджеры могут выполнять это действие.",
|
||||
"open_options": "Открыть параметры",
|
||||
"option_id": "ID опции",
|
||||
"option_ids": "ID опций",
|
||||
"optional": "Необязательно",
|
||||
@@ -365,6 +380,7 @@
|
||||
"quotas_description": "Ограничьте количество ответов, которые вы получаете от участников, соответствующих определённым критериям.",
|
||||
"read_docs": "Читать документацию",
|
||||
"recipients": "Получатели",
|
||||
"refresh": "Обновить",
|
||||
"remove": "Удалить",
|
||||
"remove_from_team": "Удалить из команды",
|
||||
"reorder_and_hide_columns": "Изменить порядок и скрыть столбцы",
|
||||
@@ -376,6 +392,7 @@
|
||||
"response_id": "ID ответа",
|
||||
"responses": "Ответы",
|
||||
"restart": "Перезапустить",
|
||||
"retry": "Повторить",
|
||||
"role": "Роль",
|
||||
"saas": "SaaS",
|
||||
"sales": "Продажи",
|
||||
@@ -384,6 +401,7 @@
|
||||
"save_changes": "Сохранить изменения",
|
||||
"saving": "Сохранение",
|
||||
"search": "Поиск",
|
||||
"search_charts": "Поиск графиков...",
|
||||
"security": "Безопасность",
|
||||
"segment": "Сегмент",
|
||||
"segments": "Сегменты",
|
||||
@@ -447,6 +465,7 @@
|
||||
"trial_one_day_remaining": "Остался 1 день пробного периода",
|
||||
"try_again": "Попробуйте ещё раз",
|
||||
"type": "Тип",
|
||||
"unify": "Объединить",
|
||||
"unknown_survey": "Неизвестный опрос",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Откройте больше рабочих пространств с более высоким тарифом.",
|
||||
"update": "Обновить",
|
||||
@@ -464,6 +483,7 @@
|
||||
"variables": "Переменные",
|
||||
"verified_email": "Подтверждённый email",
|
||||
"video": "Видео",
|
||||
"view": "Просмотреть",
|
||||
"warning": "Предупреждение",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Не удалось проверить вашу лицензию, так как сервер лицензий недоступен.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +502,7 @@
|
||||
"workspace_name_placeholder": "например, Formbricks",
|
||||
"workspaces": "Рабочие пространства",
|
||||
"years": "годы",
|
||||
"yes": "Да",
|
||||
"you": "Вы",
|
||||
"you_are_downgraded_to_the_community_edition": "Ваша версия понижена до Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "У вас нет прав для выполнения этого действия.",
|
||||
@@ -1610,6 +1631,182 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Ваш опрос будет отображаться по этому URL.",
|
||||
"your_survey_would_not_be_shown": "Ваш опрос не будет отображаться."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "ИЛИ",
|
||||
"add_chart_to_dashboard": "Добавить график на панель",
|
||||
"add_chart_to_dashboard_description": "Выбери панель, чтобы добавить на неё этот график. График будет сохранён автоматически.",
|
||||
"add_filter": "Добавить фильтр",
|
||||
"add_to_dashboard": "Добавить на панель",
|
||||
"advanced_chart_builder_config_prompt": "Настрой график и нажми «Выполнить запрос», чтобы посмотреть предварительный просмотр",
|
||||
"ai_query_placeholder": "например: Сколько пользователей зарегистрировались на прошлой неделе?",
|
||||
"ai_query_section_description": "Опиши, что хочешь увидеть, и AI построит график.",
|
||||
"ai_query_section_title": "Спроси свои данные",
|
||||
"and_filter_logic": "И",
|
||||
"apply_changes": "Применить изменения",
|
||||
"chart": "График",
|
||||
"chart_added_to_dashboard": "График добавлен на панель!",
|
||||
"chart_builder_choose_chart_type": "Выбери тип графика",
|
||||
"chart_data": "Данные графика",
|
||||
"chart_data_tab": "Данные",
|
||||
"chart_deleted_successfully": "График успешно удалён",
|
||||
"chart_deletion_error": "Не удалось удалить график",
|
||||
"chart_duplicated_successfully": "График успешно дублирован",
|
||||
"chart_duplication_error": "Не удалось дублировать график",
|
||||
"chart_name": "Название графика",
|
||||
"chart_name_placeholder": "Название графика",
|
||||
"chart_preview": "Предпросмотр графика",
|
||||
"chart_render_error": "Что-то пошло не так при отображении этой диаграммы.",
|
||||
"chart_saved_successfully": "График успешно сохранён!",
|
||||
"chart_type_area": "График областью",
|
||||
"chart_type_bar": "Столбчатая диаграмма",
|
||||
"chart_type_big_number": "Большое число",
|
||||
"chart_type_line": "Линейный график",
|
||||
"chart_type_not_supported": "Тип графика «{{chartType}}» пока не поддерживается",
|
||||
"chart_type_pie": "Круговая диаграмма",
|
||||
"chart_updated_successfully": "График успешно обновлён!",
|
||||
"configure_description": "Измени тип графика и другие настройки этой визуализации.",
|
||||
"configure_title": "Настроить график",
|
||||
"configure_type_label": "Тип графика",
|
||||
"contains": "содержит",
|
||||
"create_chart": "Создать график",
|
||||
"create_chart_description": "Используй ИИ для создания графика или собери его вручную.",
|
||||
"create_chart_with_ai": "Создать график с помощью ИИ",
|
||||
"custom_range": "Произвольный диапазон",
|
||||
"dashboard": "Панель управления",
|
||||
"dashboard_select_placeholder": "Выбери панель управления",
|
||||
"data_label": "Данные",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Последние 30 дней",
|
||||
"date_preset_last_7_days": "Последние 7 дней",
|
||||
"date_preset_last_month": "Прошлый месяц",
|
||||
"date_preset_this_month": "В этом месяце",
|
||||
"date_preset_this_quarter": "В этом квартале",
|
||||
"date_preset_this_year": "В этом году",
|
||||
"date_preset_today": "Сегодня",
|
||||
"date_preset_yesterday": "Вчера",
|
||||
"date_range": "Диапазон дат",
|
||||
"delete_chart_confirmation": "Ты уверен, что хочешь удалить этот график?",
|
||||
"dimensions": "Измерения",
|
||||
"dimensions_toggle_description": "Группируйте данные по настроению, типу вопроса и другим измерениям.",
|
||||
"edit_chart_description": "Просмотри и измени настройки своего графика.",
|
||||
"edit_chart_title": "Редактировать график",
|
||||
"enable_time_dimension": "Включить временное измерение",
|
||||
"end_date": "Дата окончания",
|
||||
"enter_a_name_for_your_chart": "Введи название для графика, чтобы сохранить его.",
|
||||
"enter_value": "Введи значение",
|
||||
"equals": "равно",
|
||||
"failed_to_add_chart_to_dashboard": "Не удалось добавить график на панель управления",
|
||||
"failed_to_execute_query": "Не удалось выполнить запрос",
|
||||
"failed_to_load_chart": "Не удалось загрузить график",
|
||||
"failed_to_load_chart_data": "Не удалось загрузить данные графика",
|
||||
"failed_to_save_chart": "Не удалось сохранить график",
|
||||
"field": "Поле",
|
||||
"field_label_average_score": "Средний балл",
|
||||
"field_label_collected_at": "Дата сбора",
|
||||
"field_label_count": "Количество",
|
||||
"field_label_detractor_count": "Количество критиков",
|
||||
"field_label_emotion": "Эмоция",
|
||||
"field_label_field_type": "Тип поля",
|
||||
"field_label_nps_score": "Оценка NPS",
|
||||
"field_label_nps_value": "Значение NPS",
|
||||
"field_label_passive_count": "Количество пассивных",
|
||||
"field_label_promoter_count": "Количество промоутеров",
|
||||
"field_label_response_id": "ID ответа",
|
||||
"field_label_sentiment": "Тональность",
|
||||
"field_label_source_name": "Название источника",
|
||||
"field_label_source_type": "Тип источника",
|
||||
"field_label_topic": "Тема",
|
||||
"field_label_user_identifier": "Идентификатор пользователя",
|
||||
"filter_data": "Фильтровать данные",
|
||||
"filters": "Фильтры",
|
||||
"filters_toggle_description": "Включай только те данные, которые соответствуют следующим условиям.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Детализация",
|
||||
"granularity_day": "День",
|
||||
"granularity_hour": "Час",
|
||||
"granularity_month": "Месяц",
|
||||
"granularity_quarter": "Квартал",
|
||||
"granularity_week": "Неделя",
|
||||
"granularity_year": "Год",
|
||||
"greater_than": "больше чем",
|
||||
"greater_than_or_equal": "больше или равно",
|
||||
"group_by": "Группировать по",
|
||||
"group_by_description": "Разбейте данные по одному или нескольким измерениям. Порядок важен, если вы выбираете несколько измерений.",
|
||||
"group_data": "Группировать данные",
|
||||
"is_not_set": "не задано",
|
||||
"is_set": "задано",
|
||||
"less_than": "меньше чем",
|
||||
"less_than_or_equal": "меньше или равно",
|
||||
"measures": "Показатели",
|
||||
"no_charts_found": "Графики не найдены.",
|
||||
"no_dashboards_available": "Нет доступных панелей управления",
|
||||
"no_dashboards_create_first": "Сначала создай панель управления, чтобы добавить на неё графики.",
|
||||
"no_data_available": "Нет доступных данных",
|
||||
"no_data_returned": "Запрос не вернул данных",
|
||||
"no_data_returned_for_chart": "Для графика не получено данных",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Нет (только фильтр)",
|
||||
"no_valid_data_to_display": "Нет корректных данных для отображения",
|
||||
"not_contains": "не содержит",
|
||||
"not_equals": "не равно",
|
||||
"open_chart": "Открыть график {{name}}",
|
||||
"open_options": "Открыть настройки графика",
|
||||
"or_filter_logic": "ИЛИ",
|
||||
"original": "Оригинал",
|
||||
"please_enter_chart_name": "Пожалуйста, введи название графика",
|
||||
"please_enter_filter_values": "Пожалуйста, введите значения для всех фильтров",
|
||||
"please_select_at_least_one_dimension": "Пожалуйста, выберите хотя бы одно измерение или отключите группировку",
|
||||
"please_select_at_least_one_measure": "Пожалуйста, выбери хотя бы один показатель",
|
||||
"please_select_dashboard": "Пожалуйста, выбери панель управления",
|
||||
"predefined_measures": "Предустановленные показатели",
|
||||
"preset": "Пресет",
|
||||
"query_executed_successfully": "Запрос успешно выполнен",
|
||||
"reset_to_ai_suggestion": "Сбросить к предложению ИИ",
|
||||
"save_chart": "Сохранить график",
|
||||
"save_chart_dialog_title": "Сохранить график",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Выберите измерения...",
|
||||
"select_field": "Выберите поле",
|
||||
"select_measures": "Выберите показатели...",
|
||||
"select_preset": "Выберите пресет",
|
||||
"showing_first_n_of": "Показаны первые {{n}} из {{count}} строк",
|
||||
"start_date": "Дата начала",
|
||||
"time_dimension": "Временное измерение",
|
||||
"time_dimension_title": "Добавить группировку по времени",
|
||||
"time_dimension_toggle_description": "Отслеживайте тренды с течением времени."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Добавить {count} график(ов)",
|
||||
"charts_add_failed": "Не удалось добавить графики на дашборд",
|
||||
"charts_add_partial_failure": "Не удалось добавить {count} график(ов)",
|
||||
"charts_added_to_dashboard": "Графики добавлены на дашборд",
|
||||
"charts_load_failed": "Не удалось загрузить графики",
|
||||
"create_dashboard": "Создать дашборд",
|
||||
"create_dashboard_description": "Введите название для новой панели управления.",
|
||||
"create_failed": "Не удалось создать панель управления",
|
||||
"create_success": "Панель управления успешно создана!",
|
||||
"dashboard": "Дашборд",
|
||||
"dashboard_delete_confirmation": "Вы уверены, что хотите удалить этот дашборд? Это действие нельзя отменить.",
|
||||
"dashboard_name": "Название панели управления",
|
||||
"dashboard_name_placeholder": "Моя панель управления",
|
||||
"dashboard_name_required": "Необходимо указать название дашборда",
|
||||
"dashboard_save_failed": "Не удалось сохранить дашборд",
|
||||
"dashboard_saved": "Дашборд успешно сохранён",
|
||||
"delete_confirmation": "Ты уверен, что хочешь удалить эту панель управления? Это действие нельзя отменить.",
|
||||
"delete_failed": "Не удалось удалить панель управления",
|
||||
"delete_success": "Панель управления успешно удалена",
|
||||
"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": "Пожалуйста, введите название панели управления"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Добавить API-ключ",
|
||||
"api_key": "API-ключ",
|
||||
@@ -2308,6 +2505,7 @@
|
||||
"archive_not_allowed": "У тебя нет прав для архивирования этого каталога.",
|
||||
"are_you_sure_you_want_to_archive": "Ты уверен, что хочешь архивировать этот каталог? Рабочие пространства больше не будут иметь к нему доступа.",
|
||||
"assign_workspaces_description": "Управляй тем, какие рабочие пространства могут получить доступ к этому каталогу записей отзывов.",
|
||||
"connectors_description": "Коннекторы, которые отправляют записи обратной связи в этот каталог.",
|
||||
"create_feedback_directory": "Создать директорию для отзывов",
|
||||
"description": "Управляй каталогами записей отзывов и их назначением рабочим пространствам.",
|
||||
"directory_archived_successfully": "Каталог успешно архивирован",
|
||||
@@ -2319,12 +2517,13 @@
|
||||
"directory_unarchived_successfully": "Каталог успешно разархивирован",
|
||||
"directory_updated_successfully": "Каталог успешно обновлён",
|
||||
"empty_state": "Каталоги записей отзывов не найдены. Создай один, чтобы начать.",
|
||||
"enter_directory_name": "Введи название каталога",
|
||||
"error_directory_has_connectors": "Невозможно архивировать каталог, к которому привязаны коннекторы. Сначала удалите все коннекторы.",
|
||||
"error_directory_name_duplicate": "Директория с записями обратной связи с таким именем уже существует.",
|
||||
"error_directory_name_required": "Необходимо указать имя директории.",
|
||||
"error_directory_workspaces_invalid_org": "Некоторые указанные рабочие пространства не принадлежат этой организации.",
|
||||
"nav_label": "Каталоги отзывов",
|
||||
"no_access": "У тебя нет прав для управления каталогами записей отзывов.",
|
||||
"no_connectors": "К этому каталогу пока не привязано ни одного коннектора.",
|
||||
"select_workspaces_placeholder": "Выберите рабочие области...",
|
||||
"show_archived": "Показать архивные",
|
||||
"title": "Директории записей обратной связи",
|
||||
@@ -3368,6 +3567,119 @@
|
||||
"team_name": "Название команды",
|
||||
"team_settings_description": "Посмотрите, какие команды имеют доступ к этому рабочему пространству."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Добавить источник отзывов",
|
||||
"add_source": "Добавить источник",
|
||||
"allowed_values": "Допустимые значения: {values}",
|
||||
"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": "Коннектор успешно дублирован",
|
||||
"connector_status_updated_successfully": "Статус коннектора успешно обновлён",
|
||||
"connector_updated_successfully": "Коннектор успешно обновлён",
|
||||
"connectors": "Коннекторы",
|
||||
"create_mapping": "Создать сопоставление",
|
||||
"created_by": "Создано пользователем",
|
||||
"csv_at_least_one_row": "CSV должен содержать хотя бы одну строку с данными.",
|
||||
"csv_columns": "Столбцы CSV",
|
||||
"csv_empty_column_headers": "В CSV есть пустые заголовки столбцов. У всех столбцов должно быть имя.",
|
||||
"csv_file_too_large": "Файл CSV слишком большой. Максимальный размер — 2 МБ.",
|
||||
"csv_files_only": "Только файлы CSV",
|
||||
"csv_import": "Импорт CSV",
|
||||
"csv_import_complete": "Импорт CSV завершён: {successes} успешно, {failures} с ошибками, {skipped} пропущено",
|
||||
"csv_import_duplicate_warning": "Импорт уже загруженных данных может создать дубликаты записей.",
|
||||
"csv_inconsistent_columns": "В строке {row} несоответствие столбцов. Во всех строках должны быть одинаковые заголовки.",
|
||||
"csv_max_records": "Допустимо не более {max} записей.",
|
||||
"default_connector_name_csv": "Импорт CSV",
|
||||
"default_connector_name_formbricks": "Подключение опроса Formbricks",
|
||||
"deselect_all": "Снять выделение со всех",
|
||||
"drop_a_field_here": "Перетащи сюда поле",
|
||||
"drop_field_or": "Перетащи поле или",
|
||||
"edit_csv_mapping": "Редактировать сопоставление CSV",
|
||||
"edit_source_connection": "Редактировать подключение источника",
|
||||
"enter_name_for_source": "Введи имя для этого источника",
|
||||
"enter_value": "Введите значение...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Не удалось загрузить отзывы",
|
||||
"feedback_date": "Текущая дата",
|
||||
"feedback_record_directory": "Каталог записей обратной связи",
|
||||
"feedback_record_fields": "Поля записи отзыва",
|
||||
"feedback_records": "Записи отзывов",
|
||||
"feedback_records_refreshed": "Записи отзывов обновлены",
|
||||
"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_rows": "Импортировать {count, plural, one {# строку} few {# строки} many {# строк} other {# строки}}",
|
||||
"importing_data": "Импорт данных...",
|
||||
"importing_historical_data": "Импорт исторических данных...",
|
||||
"invalid_enum_values": "Недопустимые значения в столбце, сопоставленном с {field}",
|
||||
"invalid_values_found": "Найдено: {values} (строки: {rows}) {extra}",
|
||||
"load_sample_csv": "Загрузить пример CSV",
|
||||
"n_supported_questions": "Поддерживается {count} вопрос(ов)",
|
||||
"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": "Обновляем записи отзывов...",
|
||||
"required": "Обязательно",
|
||||
"save_changes": "Сохранить изменения",
|
||||
"select_a_survey_to_see_questions": "Выберите опрос, чтобы увидеть его вопросы",
|
||||
"select_a_value": "Выберите значение...",
|
||||
"select_all": "Выбрать все",
|
||||
"select_feedback_record_directory": "Выберите каталог",
|
||||
"select_questions": "Выберите вопросы",
|
||||
"select_source_type_description": "Выберите тип источника отзывов, который хотите подключить.",
|
||||
"select_source_type_prompt": "Выберите тип источника отзывов, который хотите подключить:",
|
||||
"select_survey": "Выбрать опрос",
|
||||
"select_survey_and_questions": "Выбрать опрос и вопросы",
|
||||
"select_survey_questions_description": "Выберите, какие вопросы опроса должны создавать FeedbackRecords.",
|
||||
"set_value": "установить значение",
|
||||
"setup_connection": "Настроить подключение",
|
||||
"showing_count_loaded": "Показано записей: {count}",
|
||||
"showing_rows": "Показано 3 из {count} строк",
|
||||
"source": "источник",
|
||||
"source_connect_csv_description": "Импортировать отзывы из CSV-файлов",
|
||||
"source_connect_formbricks_description": "Подключить отзывы из ваших опросов Formbricks",
|
||||
"source_fields": "Поля источника",
|
||||
"source_name": "Имя источника",
|
||||
"source_type": "Тип источника",
|
||||
"source_type_cannot_be_changed": "Тип источника нельзя изменить",
|
||||
"sources": "Источники",
|
||||
"status_active": "В процессе",
|
||||
"status_completed": "Завершён",
|
||||
"status_draft": "Черновик",
|
||||
"status_error": "Ошибка",
|
||||
"status_paused": "Приостановлен",
|
||||
"survey_has_no_questions": "В этом опросе нет вопросов",
|
||||
"survey_import_line": "{surveyName}: {responseCount} ответов × {questionCount} вопросов = {total} записей обратной связи",
|
||||
"total_feedback_records": "Всего: выбрано {checked} из {total} записей обратной связи в {surveyCount} опросах",
|
||||
"unify_feedback": "Обратная связь Unify",
|
||||
"update_mapping_description": "Обнови настройки сопоставления для этого источника.",
|
||||
"updated_at": "Обновлено",
|
||||
"upload_csv_data_description": "Загрузи CSV-файл, чтобы импортировать данные отзывов.",
|
||||
"upload_csv_file": "Загрузить CSV-файл",
|
||||
"user_identifier": "Пользователь",
|
||||
"value": "Значение"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Используйте каждый контакт с клиентом, чтобы понять, насколько легко с вами взаимодействовать.",
|
||||
|
||||
+313
-1
@@ -125,6 +125,8 @@
|
||||
"activity": "Aktivitet",
|
||||
"add": "Lägg till",
|
||||
"add_action": "Lägg till åtgärd",
|
||||
"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",
|
||||
"add_logo": "Lägg till logotyp",
|
||||
"add_member": "Lägg till medlem",
|
||||
@@ -136,6 +138,7 @@
|
||||
"allow": "Tillåt",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
|
||||
"analysis": "Analys",
|
||||
"and": "Och",
|
||||
"anonymous": "Anonym",
|
||||
"api_keys": "API-nycklar",
|
||||
@@ -154,6 +157,8 @@
|
||||
"centered_modal": "Centrerad modal",
|
||||
"change_organization": "Byt organisation",
|
||||
"change_workspace": "Byt arbetsyta",
|
||||
"chart": "Diagram",
|
||||
"charts": "Diagram",
|
||||
"choices": "Val",
|
||||
"choose_organization": "Välj organisation",
|
||||
"choose_workspace": "Välj arbetsyta",
|
||||
@@ -186,6 +191,7 @@
|
||||
"count_questions": "{count, plural, one {{count} fråga} other {{count} frågor}}",
|
||||
"count_responses": "{count, plural, one {{count} svar} other {{count} svar}}",
|
||||
"count_selections": "{count, plural, one {{count} val} other {{count} val}}",
|
||||
"create": "Skapa",
|
||||
"create_new_organization": "Skapa ny organisation",
|
||||
"create_segment": "Skapa segment",
|
||||
"create_survey": "Skapa enkät",
|
||||
@@ -195,6 +201,8 @@
|
||||
"created_by": "Skapad av",
|
||||
"customer_success": "Kundframgång",
|
||||
"dark_overlay": "Mörkt överlägg",
|
||||
"dashboard": "Instrumentpanel",
|
||||
"dashboards": "Instrumentpaneler",
|
||||
"date": "Datum",
|
||||
"days": "dagar",
|
||||
"default": "Standard",
|
||||
@@ -218,6 +226,7 @@
|
||||
"edit": "Redigera",
|
||||
"elements": "Element",
|
||||
"email": "E-post",
|
||||
"enable": "Aktivera",
|
||||
"ending_card": "Avslutningskort",
|
||||
"enter_url": "Ange URL",
|
||||
"enterprise_license": "Företagslicens",
|
||||
@@ -232,6 +241,7 @@
|
||||
"failed_to_copy_to_clipboard": "Misslyckades att kopiera till urklipp",
|
||||
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
|
||||
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
|
||||
"failed_to_parse_csv": "Det gick inte att tolka CSV-filen",
|
||||
"filter": "Filter",
|
||||
"finish": "Slutför",
|
||||
"first_name": "Förnamn",
|
||||
@@ -246,6 +256,7 @@
|
||||
"hidden": "Dold",
|
||||
"hidden_field": "Dolt fält",
|
||||
"hidden_fields": "Dolda fält",
|
||||
"hide": "Dölj",
|
||||
"hide_column": "Dölj kolumn",
|
||||
"id": "ID",
|
||||
"image": "Bild",
|
||||
@@ -292,6 +303,7 @@
|
||||
"mobile_overlay_surveys_look_good": "Oroa dig inte – dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
|
||||
"mobile_overlay_title": "Hoppsan, liten skärm upptäckt!",
|
||||
"months": "månader",
|
||||
"more_options": "Fler alternativ",
|
||||
"move_down": "Flytta ner",
|
||||
"move_up": "Flytta upp",
|
||||
"multiple_languages": "Flera språk",
|
||||
@@ -300,8 +312,10 @@
|
||||
"new": "Ny",
|
||||
"new_version_available": "Formbricks {version} är här. Uppgradera nu!",
|
||||
"next": "Nästa",
|
||||
"no": "Nej",
|
||||
"no_actions_found": "Inga åtgärder hittades",
|
||||
"no_background_image_found": "Ingen bakgrundsbild hittades.",
|
||||
"no_changes": "Inga ändringar",
|
||||
"no_code": "Ingen kod",
|
||||
"no_files_uploaded": "Inga filer laddades upp",
|
||||
"no_overlay": "Ingen overlay",
|
||||
@@ -323,6 +337,7 @@
|
||||
"on": "På",
|
||||
"only_one_file_allowed": "Endast en fil är tillåten",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Endast ägare och chefer kan utföra denna åtgärd.",
|
||||
"open_options": "Öppna alternativ",
|
||||
"option_id": "Alternativ-ID",
|
||||
"option_ids": "Alternativ-ID:n",
|
||||
"optional": "Valfritt",
|
||||
@@ -365,6 +380,7 @@
|
||||
"quotas_description": "Begränsa antalet svar du får från deltagare som uppfyller vissa kriterier.",
|
||||
"read_docs": "Läs dokumentation",
|
||||
"recipients": "Mottagare",
|
||||
"refresh": "Uppdatera",
|
||||
"remove": "Ta bort",
|
||||
"remove_from_team": "Ta bort från teamet",
|
||||
"reorder_and_hide_columns": "Ordna om och dölj kolumner",
|
||||
@@ -376,6 +392,7 @@
|
||||
"response_id": "Svar-ID",
|
||||
"responses": "Svar",
|
||||
"restart": "Starta om",
|
||||
"retry": "Försök igen",
|
||||
"role": "Roll",
|
||||
"saas": "SaaS",
|
||||
"sales": "Försäljning",
|
||||
@@ -384,6 +401,7 @@
|
||||
"save_changes": "Spara ändringar",
|
||||
"saving": "Sparar",
|
||||
"search": "Sök",
|
||||
"search_charts": "Sök diagram...",
|
||||
"security": "Säkerhet",
|
||||
"segment": "Segment",
|
||||
"segments": "Segment",
|
||||
@@ -447,6 +465,7 @@
|
||||
"trial_one_day_remaining": "1 dag kvar av din provperiod",
|
||||
"try_again": "Försök igen",
|
||||
"type": "Typ",
|
||||
"unify": "Förena",
|
||||
"unknown_survey": "Okänd enkät",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Lås upp fler arbetsytor med ett högre abonnemang.",
|
||||
"update": "Uppdatera",
|
||||
@@ -464,6 +483,7 @@
|
||||
"variables": "Variabler",
|
||||
"verified_email": "Verifierad e-post",
|
||||
"video": "Video",
|
||||
"view": "Visa",
|
||||
"warning": "Varning",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Vi kunde inte verifiera din licens eftersom licensservern inte kan nås.",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +502,7 @@
|
||||
"workspace_name_placeholder": "t.ex. Formbricks",
|
||||
"workspaces": "Arbetsytor",
|
||||
"years": "år",
|
||||
"yes": "Ja",
|
||||
"you": "Du",
|
||||
"you_are_downgraded_to_the_community_edition": "Du har nedgraderats till Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Du har inte behörighet att utföra denna åtgärd.",
|
||||
@@ -1610,6 +1631,182 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Din enkät skulle visas på denna URL.",
|
||||
"your_survey_would_not_be_shown": "Din enkät skulle inte visas."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "ELLER",
|
||||
"add_chart_to_dashboard": "Lägg till diagram på instrumentpanelen",
|
||||
"add_chart_to_dashboard_description": "Välj en instrumentpanel att lägga till det här diagrammet på. Diagrammet sparas automatiskt.",
|
||||
"add_filter": "Lägg till filter",
|
||||
"add_to_dashboard": "Lägg till på instrumentpanelen",
|
||||
"advanced_chart_builder_config_prompt": "Konfigurera ditt diagram och klicka på \"Kör fråga\" för att förhandsgranska",
|
||||
"ai_query_placeholder": "t.ex. Hur många användare registrerade sig förra veckan?",
|
||||
"ai_query_section_description": "Beskriv vad du vill se så bygger AI diagrammet åt dig.",
|
||||
"ai_query_section_title": "Fråga din data",
|
||||
"and_filter_logic": "OCH",
|
||||
"apply_changes": "Verkställ ändringar",
|
||||
"chart": "Diagram",
|
||||
"chart_added_to_dashboard": "Diagram tillagt på instrumentpanelen!",
|
||||
"chart_builder_choose_chart_type": "Välj diagramtyp",
|
||||
"chart_data": "Diagramdata",
|
||||
"chart_data_tab": "Data",
|
||||
"chart_deleted_successfully": "Diagrammet har tagits bort",
|
||||
"chart_deletion_error": "Det gick inte att ta bort diagrammet",
|
||||
"chart_duplicated_successfully": "Diagrammet har duplicerats",
|
||||
"chart_duplication_error": "Det gick inte att duplicera diagrammet",
|
||||
"chart_name": "Diagramnamn",
|
||||
"chart_name_placeholder": "Diagramnamn",
|
||||
"chart_preview": "Förhandsgranska diagram",
|
||||
"chart_render_error": "Något gick fel när diagrammet skulle visas.",
|
||||
"chart_saved_successfully": "Diagram sparat!",
|
||||
"chart_type_area": "Ytdiagram",
|
||||
"chart_type_bar": "Stapeldiagram",
|
||||
"chart_type_big_number": "Stort tal",
|
||||
"chart_type_line": "Linjediagram",
|
||||
"chart_type_not_supported": "Diagramtypen \"{{chartType}}\" stöds inte än",
|
||||
"chart_type_pie": "Cirkeldiagram",
|
||||
"chart_updated_successfully": "Diagram uppdaterat!",
|
||||
"configure_description": "Ändra diagramtyp och andra inställningar för den här visualiseringen.",
|
||||
"configure_title": "Konfigurera diagram",
|
||||
"configure_type_label": "Diagramtyp",
|
||||
"contains": "innehåller",
|
||||
"create_chart": "Skapa diagram",
|
||||
"create_chart_description": "Använd AI för att skapa ett diagram eller bygg ett manuellt.",
|
||||
"create_chart_with_ai": "Skapa diagram med AI",
|
||||
"custom_range": "Anpassat intervall",
|
||||
"dashboard": "Instrumentpanel",
|
||||
"dashboard_select_placeholder": "Välj en instrumentpanel",
|
||||
"data_label": "Data",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Senaste 30 dagarna",
|
||||
"date_preset_last_7_days": "Senaste 7 dagarna",
|
||||
"date_preset_last_month": "Senaste månaden",
|
||||
"date_preset_this_month": "Denna månad",
|
||||
"date_preset_this_quarter": "Detta kvartal",
|
||||
"date_preset_this_year": "Detta år",
|
||||
"date_preset_today": "Idag",
|
||||
"date_preset_yesterday": "Igår",
|
||||
"date_range": "Datumintervall",
|
||||
"delete_chart_confirmation": "Är du säker på att du vill ta bort det här diagrammet?",
|
||||
"dimensions": "Dimensioner",
|
||||
"dimensions_toggle_description": "Gruppera data efter sentiment, frågetyp och andra dimensioner.",
|
||||
"edit_chart_description": "Visa och redigera din diagramkonfiguration.",
|
||||
"edit_chart_title": "Redigera diagram",
|
||||
"enable_time_dimension": "Aktivera tidsdimension",
|
||||
"end_date": "Slutdatum",
|
||||
"enter_a_name_for_your_chart": "Ange ett namn för ditt diagram för att spara det.",
|
||||
"enter_value": "Ange värde",
|
||||
"equals": "är lika med",
|
||||
"failed_to_add_chart_to_dashboard": "Det gick inte att lägga till diagrammet i instrumentpanelen",
|
||||
"failed_to_execute_query": "Det gick inte att köra frågan",
|
||||
"failed_to_load_chart": "Det gick inte att ladda diagrammet",
|
||||
"failed_to_load_chart_data": "Det gick inte att ladda diagramdata",
|
||||
"failed_to_save_chart": "Det gick inte att spara diagrammet",
|
||||
"field": "Fält",
|
||||
"field_label_average_score": "Genomsnittligt betyg",
|
||||
"field_label_collected_at": "Insamlad",
|
||||
"field_label_count": "Antal",
|
||||
"field_label_detractor_count": "Antal kritiker",
|
||||
"field_label_emotion": "Känsla",
|
||||
"field_label_field_type": "Fälttyp",
|
||||
"field_label_nps_score": "NPS-poäng",
|
||||
"field_label_nps_value": "NPS-värde",
|
||||
"field_label_passive_count": "Antal passiva",
|
||||
"field_label_promoter_count": "Antal förespråkare",
|
||||
"field_label_response_id": "Svar-ID",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Källnamn",
|
||||
"field_label_source_type": "Källtyp",
|
||||
"field_label_topic": "Ämne",
|
||||
"field_label_user_identifier": "Användar-ID",
|
||||
"filter_data": "Filtrera data",
|
||||
"filters": "Filter",
|
||||
"filters_toggle_description": "Inkludera bara data som uppfyller följande villkor.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Detaljnivå",
|
||||
"granularity_day": "Dag",
|
||||
"granularity_hour": "timme",
|
||||
"granularity_month": "månad",
|
||||
"granularity_quarter": "kvartal",
|
||||
"granularity_week": "vecka",
|
||||
"granularity_year": "år",
|
||||
"greater_than": "större än",
|
||||
"greater_than_or_equal": "större än eller lika med",
|
||||
"group_by": "Gruppera efter",
|
||||
"group_by_description": "Dela upp din data med en eller flera dimensioner. Ordningen är viktig om du väljer flera dimensioner.",
|
||||
"group_data": "Gruppera data",
|
||||
"is_not_set": "är inte satt",
|
||||
"is_set": "är satt",
|
||||
"less_than": "mindre än",
|
||||
"less_than_or_equal": "mindre än eller lika med",
|
||||
"measures": "Mått",
|
||||
"no_charts_found": "Inga diagram hittades.",
|
||||
"no_dashboards_available": "Inga instrumentpaneler tillgängliga",
|
||||
"no_dashboards_create_first": "Skapa först en instrumentpanel för att lägga till diagram.",
|
||||
"no_data_available": "Ingen data tillgänglig",
|
||||
"no_data_returned": "Ingen data returnerades från frågan",
|
||||
"no_data_returned_for_chart": "Ingen data returnerades för diagrammet",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Ingen (endast filter)",
|
||||
"no_valid_data_to_display": "Ingen giltig data att visa",
|
||||
"not_contains": "innehåller inte",
|
||||
"not_equals": "är inte lika med",
|
||||
"open_chart": "Öppna diagram {{name}}",
|
||||
"open_options": "Öppna diagramalternativ",
|
||||
"or_filter_logic": "ELLER",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Ange ett diagramnamn",
|
||||
"please_enter_filter_values": "Vänligen ange värden för alla filter",
|
||||
"please_select_at_least_one_dimension": "Vänligen välj minst en dimension eller inaktivera gruppering",
|
||||
"please_select_at_least_one_measure": "Välj minst ett mått",
|
||||
"please_select_dashboard": "Välj en instrumentpanel",
|
||||
"predefined_measures": "Fördefinierade mått",
|
||||
"preset": "Förinställning",
|
||||
"query_executed_successfully": "Frågan kördes utan problem",
|
||||
"reset_to_ai_suggestion": "Återställ till AI-förslag",
|
||||
"save_chart": "Spara diagram",
|
||||
"save_chart_dialog_title": "Spara diagram",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Välj dimensioner...",
|
||||
"select_field": "Välj fält",
|
||||
"select_measures": "Välj mått...",
|
||||
"select_preset": "Välj förinställning",
|
||||
"showing_first_n_of": "Visar de första {{n}} av {{count}} raderna",
|
||||
"start_date": "Startdatum",
|
||||
"time_dimension": "Tidsdimension",
|
||||
"time_dimension_title": "Lägg till tidsbaserad gruppering",
|
||||
"time_dimension_toggle_description": "Övervaka trender över tid."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Lägg till {count} diagram",
|
||||
"charts_add_failed": "Misslyckades med att lägga till diagram i instrumentpanelen",
|
||||
"charts_add_partial_failure": "Misslyckades med att lägga till {count} diagram",
|
||||
"charts_added_to_dashboard": "Diagram tillagda i instrumentpanelen",
|
||||
"charts_load_failed": "Misslyckades med att ladda diagram",
|
||||
"create_dashboard": "Skapa instrumentpanel",
|
||||
"create_dashboard_description": "Ange ett namn för din nya instrumentpanel.",
|
||||
"create_failed": "Det gick inte att skapa instrumentpanelen",
|
||||
"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.",
|
||||
"dashboard_name": "Instrumentpanelens namn",
|
||||
"dashboard_name_placeholder": "Min instrumentpanel",
|
||||
"dashboard_name_required": "Instrumentpanelens namn krävs",
|
||||
"dashboard_save_failed": "Det gick inte att spara instrumentpanelen",
|
||||
"dashboard_saved": "Instrumentpanelen sparades",
|
||||
"delete_confirmation": "Är du säker på att du vill ta bort den här instrumentpanelen? Den här åtgärden kan inte ångras.",
|
||||
"delete_failed": "Det gick inte att ta bort instrumentpanelen",
|
||||
"delete_success": "Instrumentpanelen har tagits bort",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Lägg till API-nyckel",
|
||||
"api_key": "API-nyckel",
|
||||
@@ -2308,6 +2505,7 @@
|
||||
"archive_not_allowed": "Du har inte behörighet att arkivera den här katalogen.",
|
||||
"are_you_sure_you_want_to_archive": "Är du säker på att du vill arkivera den här katalogen? Arbetsytor kommer inte längre ha tillgång till den.",
|
||||
"assign_workspaces_description": "Styr vilka arbetsytor som kan komma åt den här katalogen för feedbackposter.",
|
||||
"connectors_description": "Kopplingar som skickar feedbackposter till den här katalogen.",
|
||||
"create_feedback_directory": "Skapa feedbackkatalog",
|
||||
"description": "Hantera kataloger för feedbackposter och deras arbetsytstilldelningar.",
|
||||
"directory_archived_successfully": "Katalogen arkiverades",
|
||||
@@ -2319,12 +2517,13 @@
|
||||
"directory_unarchived_successfully": "Katalogen återställdes från arkivet",
|
||||
"directory_updated_successfully": "Katalogen uppdaterades",
|
||||
"empty_state": "Inga kataloger för feedbackposter hittades. Skapa en för att komma igång.",
|
||||
"enter_directory_name": "Ange katalognamn",
|
||||
"error_directory_has_connectors": "Kan inte arkivera en katalog som har kopplingar kopplade till den. Ta bort alla kopplingar först.",
|
||||
"error_directory_name_duplicate": "En katalog för återkopplingsregister med detta namn finns redan.",
|
||||
"error_directory_name_required": "Katalognamn krävs.",
|
||||
"error_directory_workspaces_invalid_org": "Vissa angivna arbetsytor tillhör inte denna organisation.",
|
||||
"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.",
|
||||
"select_workspaces_placeholder": "Välj arbetsytor...",
|
||||
"show_archived": "Visa arkiverade",
|
||||
"title": "Feedbackkataloger",
|
||||
@@ -3368,6 +3567,119 @@
|
||||
"team_name": "Teamnamn",
|
||||
"team_settings_description": "Se vilka team som har tillgång till denna arbetsyta."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Lägg till feedbackkälla",
|
||||
"add_source": "Lägg till källa",
|
||||
"allowed_values": "Tillåtna värden: {values}",
|
||||
"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",
|
||||
"connector_status_updated_successfully": "Kopplingens status har uppdaterats",
|
||||
"connector_updated_successfully": "Kopplingen uppdaterades",
|
||||
"connectors": "Kopplingar",
|
||||
"create_mapping": "Skapa mappning",
|
||||
"created_by": "Skapad av",
|
||||
"csv_at_least_one_row": "CSV-filen måste innehålla minst en datarad.",
|
||||
"csv_columns": "CSV-kolumner",
|
||||
"csv_empty_column_headers": "CSV-filen innehåller tomma kolumnrubriker. Alla kolumner måste ha ett namn.",
|
||||
"csv_file_too_large": "CSV-filen är för stor. Maxstorlek är 2 MB.",
|
||||
"csv_files_only": "Endast CSV-filer",
|
||||
"csv_import": "CSV-import",
|
||||
"csv_import_complete": "CSV-import klar: {successes} lyckades, {failures} misslyckades, {skipped} hoppades över",
|
||||
"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.",
|
||||
"default_connector_name_csv": "CSV-import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey-anslutning",
|
||||
"deselect_all": "Avmarkera alla",
|
||||
"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",
|
||||
"edit_source_connection": "Redigera källans anslutning",
|
||||
"enter_name_for_source": "Ange ett namn för denna källa",
|
||||
"enter_value": "Ange värde...",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter",
|
||||
"feedback_date": "Aktuellt datum",
|
||||
"feedback_record_directory": "Katalog för feedbackposter",
|
||||
"feedback_record_fields": "Fält för feedbackpost",
|
||||
"feedback_records": "Feedbackposter",
|
||||
"feedback_records_refreshed": "Feedbackposter har uppdaterats",
|
||||
"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_rows": "Importera {count} rader",
|
||||
"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",
|
||||
"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...",
|
||||
"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_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.",
|
||||
"set_value": "ange värde",
|
||||
"setup_connection": "Ställ in anslutning",
|
||||
"showing_count_loaded": "Visar {count} poster",
|
||||
"showing_rows": "Visar 3 av {count} rader",
|
||||
"source": "källa",
|
||||
"source_connect_csv_description": "Importera feedback från CSV-filer",
|
||||
"source_connect_formbricks_description": "Anslut feedback från dina Formbricks-enkäter",
|
||||
"source_fields": "Källfält",
|
||||
"source_name": "Källnamn",
|
||||
"source_type": "Källtyp",
|
||||
"source_type_cannot_be_changed": "Källtyp kan inte ändras",
|
||||
"sources": "Källor",
|
||||
"status_active": "Pågående",
|
||||
"status_completed": "Slutförd",
|
||||
"status_draft": "Utkast",
|
||||
"status_error": "Fel",
|
||||
"status_paused": "Pausad",
|
||||
"survey_has_no_questions": "Den här enkäten har inga frågor",
|
||||
"survey_import_line": "{surveyName}: {responseCount} svar × {questionCount} frågor = {total} feedbackposter",
|
||||
"total_feedback_records": "Totalt: {checked} av {total} feedbackposter valda i {surveyCount} enkäter",
|
||||
"unify_feedback": "Samla feedback",
|
||||
"update_mapping_description": "Uppdatera mappningskonfigurationen för den här källan.",
|
||||
"updated_at": "Uppdaterad",
|
||||
"upload_csv_data_description": "Ladda upp en CSV-fil för att importera feedbackdata.",
|
||||
"upload_csv_file": "Ladda upp CSV-fil",
|
||||
"user_identifier": "Användare",
|
||||
"value": "Värde"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "Utnyttja varje kontaktpunkt för att förstå hur enkel kundinteraktionen är.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"activity": "活动",
|
||||
"add": "添加",
|
||||
"add_action": "添加 操作",
|
||||
"add_charts": "添加图表",
|
||||
"add_existing_chart_description": "搜索并选择要添加到此仪表板的图表。",
|
||||
"add_filter": "添加 过滤器",
|
||||
"add_logo": "添加徽标",
|
||||
"add_member": "添加成员",
|
||||
@@ -136,6 +138,7 @@
|
||||
"allow": "允许",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
|
||||
"analysis": "分析",
|
||||
"and": "和",
|
||||
"anonymous": "匿名",
|
||||
"api_keys": "API 密钥",
|
||||
@@ -154,6 +157,8 @@
|
||||
"centered_modal": "居中 模态",
|
||||
"change_organization": "切换组织",
|
||||
"change_workspace": "切换工作区",
|
||||
"chart": "图表",
|
||||
"charts": "图表",
|
||||
"choices": "选项",
|
||||
"choose_organization": "选择 组织",
|
||||
"choose_workspace": "选择工作区",
|
||||
@@ -186,6 +191,7 @@
|
||||
"count_questions": "共{count}个问题",
|
||||
"count_responses": "{count, plural, other {{count} 回复} }",
|
||||
"count_selections": "{count, plural, other {已选择{count}项}}",
|
||||
"create": "创建",
|
||||
"create_new_organization": "创建 新的 组织",
|
||||
"create_segment": "创建 细分",
|
||||
"create_survey": "创建 调查",
|
||||
@@ -195,6 +201,8 @@
|
||||
"created_by": "由 创建",
|
||||
"customer_success": "客户成功",
|
||||
"dark_overlay": "深色遮罩层",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "仪表盘",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "默认",
|
||||
@@ -218,6 +226,7 @@
|
||||
"edit": "编辑",
|
||||
"elements": "元素",
|
||||
"email": "邮箱",
|
||||
"enable": "启用",
|
||||
"ending_card": "结尾卡片",
|
||||
"enter_url": "输入 URL",
|
||||
"enterprise_license": "企业 许可证",
|
||||
@@ -232,6 +241,7 @@
|
||||
"failed_to_copy_to_clipboard": "复制到剪贴板失败",
|
||||
"failed_to_load_organizations": "加载组织失败",
|
||||
"failed_to_load_workspaces": "加载工作区失败",
|
||||
"failed_to_parse_csv": "CSV 解析失败",
|
||||
"filter": "筛选",
|
||||
"finish": "完成",
|
||||
"first_name": "名字",
|
||||
@@ -246,6 +256,7 @@
|
||||
"hidden": "隐藏",
|
||||
"hidden_field": "隐藏 字段",
|
||||
"hidden_fields": "隐藏 字段",
|
||||
"hide": "隐藏",
|
||||
"hide_column": "隐藏 列",
|
||||
"id": "ID",
|
||||
"image": "图片",
|
||||
@@ -292,6 +303,7 @@
|
||||
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
|
||||
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
|
||||
"months": "月",
|
||||
"more_options": "更多选项",
|
||||
"move_down": "下移",
|
||||
"move_up": "上移",
|
||||
"multiple_languages": "多种 语言",
|
||||
@@ -300,8 +312,10 @@
|
||||
"new": "新建",
|
||||
"new_version_available": "Formbricks {version} 在 这里。立即 升级!",
|
||||
"next": "下一步",
|
||||
"no": "否",
|
||||
"no_actions_found": "未找到操作",
|
||||
"no_background_image_found": "未找到 背景 图片。",
|
||||
"no_changes": "无变更",
|
||||
"no_code": "无代码",
|
||||
"no_files_uploaded": "没有 文件 被 上传",
|
||||
"no_overlay": "无覆盖层",
|
||||
@@ -323,6 +337,7 @@
|
||||
"on": "开启",
|
||||
"only_one_file_allowed": "只 允许 一个 文件",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有 所有者 和 管理者 可以 执行 此 操作。",
|
||||
"open_options": "打开选项",
|
||||
"option_id": "选项 ID",
|
||||
"option_ids": "选项 ID",
|
||||
"optional": "可选",
|
||||
@@ -365,6 +380,7 @@
|
||||
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
|
||||
"read_docs": "阅读文档",
|
||||
"recipients": "收件人",
|
||||
"refresh": "刷新",
|
||||
"remove": "移除",
|
||||
"remove_from_team": "从团队中移除",
|
||||
"reorder_and_hide_columns": "重新排序和隐藏列",
|
||||
@@ -376,6 +392,7 @@
|
||||
"response_id": "响应 ID",
|
||||
"responses": "反馈",
|
||||
"restart": "重新启动",
|
||||
"retry": "重试",
|
||||
"role": "角色",
|
||||
"saas": "SaaS",
|
||||
"sales": "销售",
|
||||
@@ -384,6 +401,7 @@
|
||||
"save_changes": "保存 更改",
|
||||
"saving": "保存",
|
||||
"search": "搜索",
|
||||
"search_charts": "搜索图表...",
|
||||
"security": "安全",
|
||||
"segment": "细分",
|
||||
"segments": "细分",
|
||||
@@ -447,6 +465,7 @@
|
||||
"trial_one_day_remaining": "试用期还剩 1 天",
|
||||
"try_again": "再试一次",
|
||||
"type": "类型",
|
||||
"unify": "统一",
|
||||
"unknown_survey": "未知调查",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "升级套餐以解锁更多工作区。",
|
||||
"update": "更新",
|
||||
@@ -464,6 +483,7 @@
|
||||
"variables": "变量",
|
||||
"verified_email": "已验证 电子邮件",
|
||||
"video": "视频",
|
||||
"view": "查看",
|
||||
"warning": "警告",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我们无法验证您的许可证,因为许可证服务器无法访问。",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +502,7 @@
|
||||
"workspace_name_placeholder": "例如:Formbricks",
|
||||
"workspaces": "工作区",
|
||||
"years": "年",
|
||||
"yes": "是",
|
||||
"you": "你 ",
|
||||
"you_are_downgraded_to_the_community_edition": "您已降级到社区版。",
|
||||
"you_are_not_authorized_to_perform_this_action": "您无权执行此操作。",
|
||||
@@ -1610,6 +1631,182 @@
|
||||
"your_survey_would_be_shown_on_this_url": "您的 调查 会 显示 在 此 URL 上",
|
||||
"your_survey_would_not_be_shown": "您的 调查 不会 显示。"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "或",
|
||||
"add_chart_to_dashboard": "添加图表到 Dashboard",
|
||||
"add_chart_to_dashboard_description": "选择一个 Dashboard,将此图表添加进去。图表会自动保存。",
|
||||
"add_filter": "添加过滤器",
|
||||
"add_to_dashboard": "添加到 Dashboard",
|
||||
"advanced_chart_builder_config_prompt": "配置你的图表,然后点击“运行查询”预览",
|
||||
"ai_query_placeholder": "例如:上周有多少用户注册?",
|
||||
"ai_query_section_description": "描述你想要看到的内容,让 AI 帮你生成图表。",
|
||||
"ai_query_section_title": "向你的数据提问",
|
||||
"and_filter_logic": "且",
|
||||
"apply_changes": "应用更改",
|
||||
"chart": "图表",
|
||||
"chart_added_to_dashboard": "图表已添加到 Dashboard!",
|
||||
"chart_builder_choose_chart_type": "选择图表类型",
|
||||
"chart_data": "图表数据",
|
||||
"chart_data_tab": "数据",
|
||||
"chart_deleted_successfully": "图表删除成功",
|
||||
"chart_deletion_error": "图表删除失败",
|
||||
"chart_duplicated_successfully": "图表复制成功",
|
||||
"chart_duplication_error": "图表复制失败",
|
||||
"chart_name": "图表名称",
|
||||
"chart_name_placeholder": "图表名称",
|
||||
"chart_preview": "图表预览",
|
||||
"chart_render_error": "渲染此图表时出现了问题。",
|
||||
"chart_saved_successfully": "图表保存成功!",
|
||||
"chart_type_area": "面积图",
|
||||
"chart_type_bar": "柱状图",
|
||||
"chart_type_big_number": "大数字",
|
||||
"chart_type_line": "折线图",
|
||||
"chart_type_not_supported": "暂不支持图表类型“{{chartType}}”",
|
||||
"chart_type_pie": "饼图",
|
||||
"chart_updated_successfully": "图表更新成功!",
|
||||
"configure_description": "修改此可视化的图表类型和其他设置。",
|
||||
"configure_title": "配置图表",
|
||||
"configure_type_label": "图表类型",
|
||||
"contains": "包含",
|
||||
"create_chart": "创建图表",
|
||||
"create_chart_description": "使用 AI 生成图表或手动创建。",
|
||||
"create_chart_with_ai": "使用 AI 创建图表",
|
||||
"custom_range": "自定义范围",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_select_placeholder": "请选择一个 Dashboard",
|
||||
"data_label": "数据",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "最近 30 天",
|
||||
"date_preset_last_7_days": "最近 7 天",
|
||||
"date_preset_last_month": "上个月",
|
||||
"date_preset_this_month": "本月",
|
||||
"date_preset_this_quarter": "本季度",
|
||||
"date_preset_this_year": "今年",
|
||||
"date_preset_today": "今天",
|
||||
"date_preset_yesterday": "昨天",
|
||||
"date_range": "日期范围",
|
||||
"delete_chart_confirmation": "你确定要删除这个图表吗?",
|
||||
"dimensions": "维度",
|
||||
"dimensions_toggle_description": "按情感、问题类型和其他维度对数据进行分组。",
|
||||
"edit_chart_description": "查看并编辑你的图表配置。",
|
||||
"edit_chart_title": "编辑图表",
|
||||
"enable_time_dimension": "启用时间维度",
|
||||
"end_date": "结束日期",
|
||||
"enter_a_name_for_your_chart": "请输入图表名称以保存。",
|
||||
"enter_value": "输入值",
|
||||
"equals": "等于",
|
||||
"failed_to_add_chart_to_dashboard": "添加图表到 Dashboard 失败",
|
||||
"failed_to_execute_query": "查询执行失败",
|
||||
"failed_to_load_chart": "加载图表失败",
|
||||
"failed_to_load_chart_data": "加载图表数据失败",
|
||||
"failed_to_save_chart": "图表保存失败",
|
||||
"field": "字段",
|
||||
"field_label_average_score": "平均分",
|
||||
"field_label_collected_at": "收集时间",
|
||||
"field_label_count": "数量",
|
||||
"field_label_detractor_count": "贬损者数量",
|
||||
"field_label_emotion": "情感",
|
||||
"field_label_field_type": "字段类型",
|
||||
"field_label_nps_score": "NPS 得分",
|
||||
"field_label_nps_value": "NPS 值",
|
||||
"field_label_passive_count": "中立者数量",
|
||||
"field_label_promoter_count": "推荐者数量",
|
||||
"field_label_response_id": "响应 ID",
|
||||
"field_label_sentiment": "情绪",
|
||||
"field_label_source_name": "来源名称",
|
||||
"field_label_source_type": "来源类型",
|
||||
"field_label_topic": "主题",
|
||||
"field_label_user_identifier": "用户标识",
|
||||
"filter_data": "筛选数据",
|
||||
"filters": "筛选条件",
|
||||
"filters_toggle_description": "仅包含符合以下条件的数据。",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "粒度",
|
||||
"granularity_day": "天",
|
||||
"granularity_hour": "小时",
|
||||
"granularity_month": "月",
|
||||
"granularity_quarter": "季度",
|
||||
"granularity_week": "周",
|
||||
"granularity_year": "年",
|
||||
"greater_than": "大于",
|
||||
"greater_than_or_equal": "大于或等于",
|
||||
"group_by": "分组依据",
|
||||
"group_by_description": "按一个或多个维度细分你的数据。如果选择多个维度,顺序很重要。",
|
||||
"group_data": "分组数据",
|
||||
"is_not_set": "未设置",
|
||||
"is_set": "已设置",
|
||||
"less_than": "小于",
|
||||
"less_than_or_equal": "小于或等于",
|
||||
"measures": "度量",
|
||||
"no_charts_found": "未找到图表。",
|
||||
"no_dashboards_available": "暂无可用 Dashboard",
|
||||
"no_dashboards_create_first": "请先创建一个 Dashboard,然后再添加图表。",
|
||||
"no_data_available": "暂无数据",
|
||||
"no_data_returned": "查询未返回数据",
|
||||
"no_data_returned_for_chart": "该图表未返回数据",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "无(仅筛选)",
|
||||
"no_valid_data_to_display": "无有效数据可显示",
|
||||
"not_contains": "不包含",
|
||||
"not_equals": "不等于",
|
||||
"open_chart": "打开图表 {{name}}",
|
||||
"open_options": "打开图表选项",
|
||||
"or_filter_logic": "或",
|
||||
"original": "原始",
|
||||
"please_enter_chart_name": "请输入图表名称",
|
||||
"please_enter_filter_values": "请为所有筛选条件输入值",
|
||||
"please_select_at_least_one_dimension": "请至少选择一个维度或禁用分组",
|
||||
"please_select_at_least_one_measure": "请至少选择一个度量",
|
||||
"please_select_dashboard": "请选择一个 Dashboard",
|
||||
"predefined_measures": "预设度量",
|
||||
"preset": "预设",
|
||||
"query_executed_successfully": "查询执行成功",
|
||||
"reset_to_ai_suggestion": "重置为 AI 建议",
|
||||
"save_chart": "保存图表",
|
||||
"save_chart_dialog_title": "保存图表",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "选择维度...",
|
||||
"select_field": "选择字段",
|
||||
"select_measures": "选择度量...",
|
||||
"select_preset": "选择预设",
|
||||
"showing_first_n_of": "显示前 {{n}} 行,共 {{count}} 行",
|
||||
"start_date": "开始日期",
|
||||
"time_dimension": "时间维度",
|
||||
"time_dimension_title": "添加基于时间的分组",
|
||||
"time_dimension_toggle_description": "监控随时间变化的趋势。"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "添加 {count} 个图表",
|
||||
"charts_add_failed": "添加图表到仪表板失败",
|
||||
"charts_add_partial_failure": "添加 {count} 个图表失败",
|
||||
"charts_added_to_dashboard": "图表已添加到仪表板",
|
||||
"charts_load_failed": "加载图表失败",
|
||||
"create_dashboard": "创建仪表板",
|
||||
"create_dashboard_description": "请输入新 Dashboard 的名称。",
|
||||
"create_failed": "创建 Dashboard 失败",
|
||||
"create_success": "Dashboard 创建成功!",
|
||||
"dashboard": "仪表板",
|
||||
"dashboard_delete_confirmation": "你确定要删除此仪表板吗?此操作无法撤销。",
|
||||
"dashboard_name": "Dashboard 名称",
|
||||
"dashboard_name_placeholder": "我的 Dashboard",
|
||||
"dashboard_name_required": "仪表板名称为必填项",
|
||||
"dashboard_save_failed": "保存仪表板失败",
|
||||
"dashboard_saved": "仪表板保存成功",
|
||||
"delete_confirmation": "确定要删除此 Dashboard 吗?此操作无法撤销。",
|
||||
"delete_failed": "删除 Dashboard 失败",
|
||||
"delete_success": "Dashboard 删除成功",
|
||||
"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 名称"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "添加 API 密钥",
|
||||
"api_key": "API 密钥",
|
||||
@@ -2308,6 +2505,7 @@
|
||||
"archive_not_allowed": "你无权归档此目录。",
|
||||
"are_you_sure_you_want_to_archive": "确定要归档此目录吗?工作区将无法再访问它。",
|
||||
"assign_workspaces_description": "控制哪些工作区可以访问此反馈记录目录。",
|
||||
"connectors_description": "将反馈记录发送到此目录的连接器。",
|
||||
"create_feedback_directory": "创建反馈目录",
|
||||
"description": "管理反馈记录目录及其工作区分配。",
|
||||
"directory_archived_successfully": "目录已成功归档",
|
||||
@@ -2319,12 +2517,13 @@
|
||||
"directory_unarchived_successfully": "目录已成功取消归档",
|
||||
"directory_updated_successfully": "目录已成功更新",
|
||||
"empty_state": "未找到反馈记录目录。创建一个开始使用吧。",
|
||||
"enter_directory_name": "输入目录名称",
|
||||
"error_directory_has_connectors": "无法归档已链接连接器的目录。请先移除所有连接器。",
|
||||
"error_directory_name_duplicate": "已存在同名的反馈记录目录。",
|
||||
"error_directory_name_required": "目录名称为必填项。",
|
||||
"error_directory_workspaces_invalid_org": "某些指定的工作区不属于此组织。",
|
||||
"nav_label": "反馈目录",
|
||||
"no_access": "你没有管理反馈记录目录的权限。",
|
||||
"no_connectors": "此目录尚未链接任何连接器。",
|
||||
"select_workspaces_placeholder": "选择工作区...",
|
||||
"show_archived": "显示已归档",
|
||||
"title": "反馈记录目录",
|
||||
@@ -3368,6 +3567,119 @@
|
||||
"team_name": "团队名称",
|
||||
"team_settings_description": "查看哪些团队可以访问此工作区。"
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "添加反馈来源",
|
||||
"add_source": "添加来源",
|
||||
"allowed_values": "允许的值:{values}",
|
||||
"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": "连接器复制成功",
|
||||
"connector_status_updated_successfully": "连接器状态更新成功",
|
||||
"connector_updated_successfully": "连接器更新成功",
|
||||
"connectors": "连接器",
|
||||
"create_mapping": "创建映射",
|
||||
"created_by": "由 创建",
|
||||
"csv_at_least_one_row": "CSV 文件中至少要有一行数据。",
|
||||
"csv_columns": "CSV 列",
|
||||
"csv_empty_column_headers": "CSV 文件包含空的列标题。所有列都必须有名称。",
|
||||
"csv_file_too_large": "CSV 文件过大,最大支持 2MB。",
|
||||
"csv_files_only": "仅限 CSV 文件",
|
||||
"csv_import": "CSV 导入",
|
||||
"csv_import_complete": "CSV 导入完成:{successes} 个成功,{failures} 个失败,{skipped} 个跳过",
|
||||
"csv_import_duplicate_warning": "重复导入数据会产生重复记录。",
|
||||
"csv_inconsistent_columns": "第 {row} 行的列数不一致。所有行必须有相同的表头。",
|
||||
"csv_max_records": "最多允许 {max} 条记录。",
|
||||
"default_connector_name_csv": "CSV 导入",
|
||||
"default_connector_name_formbricks": "Formbricks 调查连接",
|
||||
"deselect_all": "取消全选",
|
||||
"drop_a_field_here": "将字段拖到这里",
|
||||
"drop_field_or": "拖放字段或",
|
||||
"edit_csv_mapping": "编辑 CSV 映射",
|
||||
"edit_source_connection": "编辑源连接",
|
||||
"enter_name_for_source": "为此来源输入名称",
|
||||
"enter_value": "请输入值...",
|
||||
"enum": "枚举",
|
||||
"failed_to_load_feedback_records": "加载反馈记录失败",
|
||||
"feedback_date": "当前日期",
|
||||
"feedback_record_directory": "反馈记录目录",
|
||||
"feedback_record_fields": "反馈记录字段",
|
||||
"feedback_records": "反馈记录",
|
||||
"feedback_records_refreshed": "反馈记录已刷新",
|
||||
"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_rows": "导入{count}行数据",
|
||||
"importing_data": "正在导入数据…",
|
||||
"importing_historical_data": "正在导入历史数据…",
|
||||
"invalid_enum_values": "映射到 {field} 的列中存在无效值",
|
||||
"invalid_values_found": "发现:{values}(行:{rows}){extra}",
|
||||
"load_sample_csv": "加载示例 CSV",
|
||||
"n_supported_questions": "{count} 个支持的问题",
|
||||
"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": "正在刷新反馈记录…",
|
||||
"required": "必填",
|
||||
"save_changes": "保存更改",
|
||||
"select_a_survey_to_see_questions": "请选择一个调查以查看其问题",
|
||||
"select_a_value": "选择一个值...",
|
||||
"select_all": "全选",
|
||||
"select_feedback_record_directory": "选择目录",
|
||||
"select_questions": "选择问题",
|
||||
"select_source_type_description": "请选择你想要连接的反馈来源类型。",
|
||||
"select_source_type_prompt": "请选择你想要连接的反馈来源类型:",
|
||||
"select_survey": "选择调查",
|
||||
"select_survey_and_questions": "选择调查和问题",
|
||||
"select_survey_questions_description": "选择哪些调查问题会创建反馈记录。",
|
||||
"set_value": "设置值",
|
||||
"setup_connection": "设置连接",
|
||||
"showing_count_loaded": "显示 {count} 条记录",
|
||||
"showing_rows": "显示 {count} 行中的 3 行",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "从 CSV 文件导入反馈",
|
||||
"source_connect_formbricks_description": "连接来自你 Formbricks 调查的反馈",
|
||||
"source_fields": "来源字段",
|
||||
"source_name": "来源名称",
|
||||
"source_type": "来源类型",
|
||||
"source_type_cannot_be_changed": "来源类型无法更改",
|
||||
"sources": "来源",
|
||||
"status_active": "进行中",
|
||||
"status_completed": "已完成",
|
||||
"status_draft": "草稿",
|
||||
"status_error": "错误",
|
||||
"status_paused": "已暂停",
|
||||
"survey_has_no_questions": "该调查没有任何问题",
|
||||
"survey_import_line": "{surveyName}:{responseCount} 份答卷 × {questionCount} 个问题 = {total} 条反馈记录",
|
||||
"total_feedback_records": "总计:{surveyCount} 个调查中已选 {checked} / {total} 条反馈记录",
|
||||
"unify_feedback": "统一反馈",
|
||||
"update_mapping_description": "更新此来源的映射配置。",
|
||||
"updated_at": "更新于",
|
||||
"upload_csv_data_description": "上传 CSV 文件以导入反馈数据。",
|
||||
"upload_csv_file": "上传 CSV 文件",
|
||||
"user_identifier": "用户",
|
||||
"value": "值"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "客户努力评分",
|
||||
"ces_description": "利用 每个 接触点 来 了解 客户 互动 的 轻松 程度",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"activity": "活動",
|
||||
"add": "新增",
|
||||
"add_action": "新增操作",
|
||||
"add_charts": "新增圖表",
|
||||
"add_existing_chart_description": "搜尋並選擇要新增至此儀表板的圖表。",
|
||||
"add_filter": "新增篩選器",
|
||||
"add_logo": "新增標誌",
|
||||
"add_member": "新增成員",
|
||||
@@ -136,6 +138,7 @@
|
||||
"allow": "允許",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
|
||||
"analysis": "分析",
|
||||
"and": "且",
|
||||
"anonymous": "匿名",
|
||||
"api_keys": "API 金鑰",
|
||||
@@ -154,6 +157,8 @@
|
||||
"centered_modal": "置中彈窗",
|
||||
"change_organization": "變更組織",
|
||||
"change_workspace": "變更工作區",
|
||||
"chart": "圖表",
|
||||
"charts": "圖表",
|
||||
"choices": "選項",
|
||||
"choose_organization": "選擇 組織",
|
||||
"choose_workspace": "選擇工作區",
|
||||
@@ -167,7 +172,7 @@
|
||||
"code": "程式碼",
|
||||
"collapse_rows": "摺疊列",
|
||||
"completed": "已完成",
|
||||
"configuration": "組態",
|
||||
"configuration": "設定",
|
||||
"confirm": "確認",
|
||||
"connect": "連線",
|
||||
"connect_formbricks": "連線 Formbricks",
|
||||
@@ -186,6 +191,7 @@
|
||||
"count_questions": "{count, plural, other {{count} 個問題}}",
|
||||
"count_responses": "{count, plural, other {{count} 答覆}}",
|
||||
"count_selections": "{count, plural, other {{count} 個選擇}}",
|
||||
"create": "建立",
|
||||
"create_new_organization": "建立新組織",
|
||||
"create_segment": "建立區隔",
|
||||
"create_survey": "建立問卷",
|
||||
@@ -195,6 +201,8 @@
|
||||
"created_by": "建立者",
|
||||
"customer_success": "客戶成功",
|
||||
"dark_overlay": "深色覆蓋",
|
||||
"dashboard": "儀表板",
|
||||
"dashboards": "儀表板",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "預設",
|
||||
@@ -218,6 +226,7 @@
|
||||
"edit": "編輯",
|
||||
"elements": "元素",
|
||||
"email": "電子郵件",
|
||||
"enable": "啟用",
|
||||
"ending_card": "結尾卡片",
|
||||
"enter_url": "輸入 URL",
|
||||
"enterprise_license": "企業授權",
|
||||
@@ -232,6 +241,7 @@
|
||||
"failed_to_copy_to_clipboard": "無法複製到剪貼簿",
|
||||
"failed_to_load_organizations": "無法載入組織",
|
||||
"failed_to_load_workspaces": "載入工作區失敗",
|
||||
"failed_to_parse_csv": "CSV 解析失敗",
|
||||
"filter": "篩選",
|
||||
"finish": "完成",
|
||||
"first_name": "名字",
|
||||
@@ -246,6 +256,7 @@
|
||||
"hidden": "隱藏",
|
||||
"hidden_field": "隱藏欄位",
|
||||
"hidden_fields": "隱藏欄位",
|
||||
"hide": "隱藏",
|
||||
"hide_column": "隱藏欄位",
|
||||
"id": "ID",
|
||||
"image": "圖片",
|
||||
@@ -292,6 +303,7 @@
|
||||
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
|
||||
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
|
||||
"months": "月",
|
||||
"more_options": "更多選項",
|
||||
"move_down": "下移",
|
||||
"move_up": "上移",
|
||||
"multiple_languages": "多種語言",
|
||||
@@ -300,8 +312,10 @@
|
||||
"new": "新增",
|
||||
"new_version_available": "Formbricks '{'version'}' 已推出。立即升級!",
|
||||
"next": "下一步",
|
||||
"no": "否",
|
||||
"no_actions_found": "找不到動作",
|
||||
"no_background_image_found": "找不到背景圖片。",
|
||||
"no_changes": "無變更",
|
||||
"no_code": "無程式碼",
|
||||
"no_files_uploaded": "沒有上傳任何檔案",
|
||||
"no_overlay": "無覆蓋層",
|
||||
@@ -323,6 +337,7 @@
|
||||
"on": "開啟",
|
||||
"only_one_file_allowed": "僅允許一個檔案",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
|
||||
"open_options": "開啟選項",
|
||||
"option_id": "選項 ID",
|
||||
"option_ids": "選項 IDs",
|
||||
"optional": "選填",
|
||||
@@ -365,6 +380,7 @@
|
||||
"quotas_description": "限制 擁有 特定 條件 的 參與者 所 提供 的 回應 數量。",
|
||||
"read_docs": "閱讀文件",
|
||||
"recipients": "收件者",
|
||||
"refresh": "重新整理",
|
||||
"remove": "移除",
|
||||
"remove_from_team": "從團隊中移除",
|
||||
"reorder_and_hide_columns": "重新排序和隱藏欄位",
|
||||
@@ -376,6 +392,7 @@
|
||||
"response_id": "回應 ID",
|
||||
"responses": "回應",
|
||||
"restart": "重新開始",
|
||||
"retry": "重試",
|
||||
"role": "角色",
|
||||
"saas": "SaaS",
|
||||
"sales": "銷售",
|
||||
@@ -384,6 +401,7 @@
|
||||
"save_changes": "儲存變更",
|
||||
"saving": "儲存",
|
||||
"search": "搜尋",
|
||||
"search_charts": "搜尋圖表...",
|
||||
"security": "安全性",
|
||||
"segment": "區隔",
|
||||
"segments": "區隔",
|
||||
@@ -447,6 +465,7 @@
|
||||
"trial_one_day_remaining": "試用期剩餘 1 天",
|
||||
"try_again": "再試一次",
|
||||
"type": "類型",
|
||||
"unify": "統一",
|
||||
"unknown_survey": "未知問卷",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "升級方案以解鎖更多工作區。",
|
||||
"update": "更新",
|
||||
@@ -464,6 +483,7 @@
|
||||
"variables": "變數",
|
||||
"verified_email": "已驗證的電子郵件",
|
||||
"video": "影片",
|
||||
"view": "檢視",
|
||||
"warning": "警告",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我們無法驗證您的授權,因為授權伺服器無法連線。",
|
||||
"webhook": "Webhook",
|
||||
@@ -482,6 +502,7 @@
|
||||
"workspace_name_placeholder": "例如:Formbricks",
|
||||
"workspaces": "工作區",
|
||||
"years": "年",
|
||||
"yes": "是",
|
||||
"you": "您",
|
||||
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
|
||||
"you_are_not_authorized_to_perform_this_action": "您沒有執行此操作的權限。",
|
||||
@@ -1610,6 +1631,182 @@
|
||||
"your_survey_would_be_shown_on_this_url": "您的問卷將顯示在此網址。",
|
||||
"your_survey_would_not_be_shown": "您的問卷將不會顯示。"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "或",
|
||||
"add_chart_to_dashboard": "新增圖表到儀表板",
|
||||
"add_chart_to_dashboard_description": "請選擇一個儀表板來新增此圖表。圖表會自動儲存。",
|
||||
"add_filter": "新增篩選器",
|
||||
"add_to_dashboard": "新增到儀表板",
|
||||
"advanced_chart_builder_config_prompt": "設定你的圖表,然後點擊「執行查詢」預覽",
|
||||
"ai_query_placeholder": "例如:上週有多少用戶註冊?",
|
||||
"ai_query_section_description": "描述你想看到的內容,讓 AI 幫你建立圖表。",
|
||||
"ai_query_section_title": "詢問你的數據",
|
||||
"and_filter_logic": "且",
|
||||
"apply_changes": "套用變更",
|
||||
"chart": "圖表",
|
||||
"chart_added_to_dashboard": "圖表已新增到儀表板!",
|
||||
"chart_builder_choose_chart_type": "選擇圖表類型",
|
||||
"chart_data": "圖表資料",
|
||||
"chart_data_tab": "資料",
|
||||
"chart_deleted_successfully": "圖表已成功刪除",
|
||||
"chart_deletion_error": "刪除圖表失敗",
|
||||
"chart_duplicated_successfully": "圖表已成功複製",
|
||||
"chart_duplication_error": "圖表複製失敗",
|
||||
"chart_name": "圖表名稱",
|
||||
"chart_name_placeholder": "圖表名稱",
|
||||
"chart_preview": "圖表預覽",
|
||||
"chart_render_error": "這個圖表在顯示時發生錯誤。",
|
||||
"chart_saved_successfully": "圖表已成功儲存!",
|
||||
"chart_type_area": "區域圖",
|
||||
"chart_type_bar": "長條圖",
|
||||
"chart_type_big_number": "大數字",
|
||||
"chart_type_line": "折線圖",
|
||||
"chart_type_not_supported": "尚未支援圖表類型「{{chartType}}」",
|
||||
"chart_type_pie": "圓餅圖",
|
||||
"chart_updated_successfully": "圖表已成功更新!",
|
||||
"configure_description": "修改此視覺化的圖表類型及其他設定。",
|
||||
"configure_title": "設定圖表",
|
||||
"configure_type_label": "圖表類型",
|
||||
"contains": "包含",
|
||||
"create_chart": "建立圖表",
|
||||
"create_chart_description": "使用 AI 產生圖表或手動建立。",
|
||||
"create_chart_with_ai": "使用 AI 建立圖表",
|
||||
"custom_range": "自訂範圍",
|
||||
"dashboard": "儀表板",
|
||||
"dashboard_select_placeholder": "請選擇儀表板",
|
||||
"data_label": "資料",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "過去 30 天",
|
||||
"date_preset_last_7_days": "過去 7 天",
|
||||
"date_preset_last_month": "上個月",
|
||||
"date_preset_this_month": "本月",
|
||||
"date_preset_this_quarter": "本季",
|
||||
"date_preset_this_year": "今年",
|
||||
"date_preset_today": "今天",
|
||||
"date_preset_yesterday": "昨天",
|
||||
"date_range": "日期範圍",
|
||||
"delete_chart_confirmation": "你確定要刪除此圖表嗎?",
|
||||
"dimensions": "維度",
|
||||
"dimensions_toggle_description": "依情感、問題類型及其他維度分組資料。",
|
||||
"edit_chart_description": "檢視並編輯你的圖表設定。",
|
||||
"edit_chart_title": "編輯圖表",
|
||||
"enable_time_dimension": "啟用時間維度",
|
||||
"end_date": "結束日期",
|
||||
"enter_a_name_for_your_chart": "請輸入圖表名稱以儲存。",
|
||||
"enter_value": "請輸入數值",
|
||||
"equals": "等於",
|
||||
"failed_to_add_chart_to_dashboard": "新增圖表到儀表板失敗",
|
||||
"failed_to_execute_query": "查詢執行失敗",
|
||||
"failed_to_load_chart": "載入圖表失敗",
|
||||
"failed_to_load_chart_data": "載入圖表資料失敗",
|
||||
"failed_to_save_chart": "儲存圖表失敗",
|
||||
"field": "欄位",
|
||||
"field_label_average_score": "平均分數",
|
||||
"field_label_collected_at": "收集時間",
|
||||
"field_label_count": "數量",
|
||||
"field_label_detractor_count": "批評者數量",
|
||||
"field_label_emotion": "情緒",
|
||||
"field_label_field_type": "欄位類型",
|
||||
"field_label_nps_score": "NPS 分數",
|
||||
"field_label_nps_value": "NPS 值",
|
||||
"field_label_passive_count": "中立者數量",
|
||||
"field_label_promoter_count": "推廣者數量",
|
||||
"field_label_response_id": "回應 ID",
|
||||
"field_label_sentiment": "情感",
|
||||
"field_label_source_name": "來源名稱",
|
||||
"field_label_source_type": "來源類型",
|
||||
"field_label_topic": "主題",
|
||||
"field_label_user_identifier": "使用者識別碼",
|
||||
"filter_data": "篩選資料",
|
||||
"filters": "篩選條件",
|
||||
"filters_toggle_description": "只包含符合下列條件的資料。",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "粒度",
|
||||
"granularity_day": "天",
|
||||
"granularity_hour": "小時",
|
||||
"granularity_month": "月",
|
||||
"granularity_quarter": "季",
|
||||
"granularity_week": "週",
|
||||
"granularity_year": "年",
|
||||
"greater_than": "大於",
|
||||
"greater_than_or_equal": "大於或等於",
|
||||
"group_by": "分組依據",
|
||||
"group_by_description": "依一個或多個維度細分你的資料。若選擇多個維度,順序很重要。",
|
||||
"group_data": "分組資料",
|
||||
"is_not_set": "未設定",
|
||||
"is_set": "已設定",
|
||||
"less_than": "小於",
|
||||
"less_than_or_equal": "小於或等於",
|
||||
"measures": "指標",
|
||||
"no_charts_found": "找不到圖表。",
|
||||
"no_dashboards_available": "沒有可用的儀表板",
|
||||
"no_dashboards_create_first": "請先建立儀表板,才能新增圖表。",
|
||||
"no_data_available": "沒有可用資料",
|
||||
"no_data_returned": "查詢沒有回傳資料",
|
||||
"no_data_returned_for_chart": "此圖表沒有回傳資料",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "無(僅篩選)",
|
||||
"no_valid_data_to_display": "沒有可顯示的有效資料",
|
||||
"not_contains": "不包含",
|
||||
"not_equals": "不等於",
|
||||
"open_chart": "開啟圖表 {{name}}",
|
||||
"open_options": "開啟圖表選項",
|
||||
"or_filter_logic": "或",
|
||||
"original": "原始",
|
||||
"please_enter_chart_name": "請輸入圖表名稱",
|
||||
"please_enter_filter_values": "請為所有篩選條件輸入值",
|
||||
"please_select_at_least_one_dimension": "請至少選擇一個維度,或停用分組功能",
|
||||
"please_select_at_least_one_measure": "請至少選擇一個指標",
|
||||
"please_select_dashboard": "請選擇一個儀表板",
|
||||
"predefined_measures": "預設指標",
|
||||
"preset": "預設",
|
||||
"query_executed_successfully": "查詢執行成功",
|
||||
"reset_to_ai_suggestion": "重設為 AI 建議",
|
||||
"save_chart": "儲存圖表",
|
||||
"save_chart_dialog_title": "儲存圖表",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "選擇維度...",
|
||||
"select_field": "選擇欄位",
|
||||
"select_measures": "選擇指標...",
|
||||
"select_preset": "選擇預設",
|
||||
"showing_first_n_of": "顯示前 {{n}} 筆,共 {{count}} 筆資料",
|
||||
"start_date": "開始日期",
|
||||
"time_dimension": "時間維度",
|
||||
"time_dimension_title": "新增基於時間的分組",
|
||||
"time_dimension_toggle_description": "監控隨時間變化的趨勢。"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "新增 {count} 個圖表",
|
||||
"charts_add_failed": "無法將圖表新增至儀表板",
|
||||
"charts_add_partial_failure": "無法新增 {count} 個圖表",
|
||||
"charts_added_to_dashboard": "圖表已新增至儀表板",
|
||||
"charts_load_failed": "無法載入圖表",
|
||||
"create_dashboard": "建立儀表板",
|
||||
"create_dashboard_description": "請輸入新儀表板的名稱。",
|
||||
"create_failed": "建立儀表板失敗",
|
||||
"create_success": "儀表板建立成功!",
|
||||
"dashboard": "儀表板",
|
||||
"dashboard_delete_confirmation": "確定要刪除此儀表板嗎?此操作無法復原。",
|
||||
"dashboard_name": "儀表板名稱",
|
||||
"dashboard_name_placeholder": "我的儀表板",
|
||||
"dashboard_name_required": "儀表板名稱為必填",
|
||||
"dashboard_save_failed": "儲存儀表板失敗",
|
||||
"dashboard_saved": "儀表板已成功儲存",
|
||||
"delete_confirmation": "你確定要刪除此儀表板嗎?此操作無法復原。",
|
||||
"delete_failed": "刪除儀表板失敗",
|
||||
"delete_success": "儀表板刪除成功",
|
||||
"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": "請輸入儀表板名稱"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "新增 API 金鑰",
|
||||
"api_key": "API 金鑰",
|
||||
@@ -2308,6 +2505,7 @@
|
||||
"archive_not_allowed": "您沒有權限封存此目錄。",
|
||||
"are_you_sure_you_want_to_archive": "確定要封存此目錄嗎?工作區將無法再存取它。",
|
||||
"assign_workspaces_description": "控制哪些工作區可以存取此意見回饋記錄目錄。",
|
||||
"connectors_description": "將意見回饋記錄傳送至此目錄的連接器。",
|
||||
"create_feedback_directory": "建立意見回饋目錄",
|
||||
"description": "管理意見回饋記錄目錄及其工作區配置。",
|
||||
"directory_archived_successfully": "目錄已成功封存",
|
||||
@@ -2319,12 +2517,13 @@
|
||||
"directory_unarchived_successfully": "目錄已成功取消封存",
|
||||
"directory_updated_successfully": "目錄已成功更新",
|
||||
"empty_state": "找不到任何意見回饋記錄目錄。建立一個開始使用吧。",
|
||||
"enter_directory_name": "輸入目錄名稱",
|
||||
"error_directory_has_connectors": "無法封存已連結連接器的目錄。請先移除所有連接器。",
|
||||
"error_directory_name_duplicate": "已存在同名的意見回饋記錄目錄。",
|
||||
"error_directory_name_required": "目錄名稱為必填項目。",
|
||||
"error_directory_workspaces_invalid_org": "部分指定的工作區不屬於此組織。",
|
||||
"nav_label": "意見回饋目錄",
|
||||
"no_access": "您沒有權限管理意見回饋記錄目錄。",
|
||||
"no_connectors": "此目錄尚未連結任何連接器。",
|
||||
"select_workspaces_placeholder": "選擇工作區...",
|
||||
"show_archived": "顯示已封存",
|
||||
"title": "意見回饋記錄目錄",
|
||||
@@ -3368,6 +3567,119 @@
|
||||
"team_name": "團隊名稱",
|
||||
"team_settings_description": "查看哪些團隊可以存取此工作區。"
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "新增回饋來源",
|
||||
"add_source": "新增來源",
|
||||
"allowed_values": "允許的值:{values}",
|
||||
"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": "連接器複製成功",
|
||||
"connector_status_updated_successfully": "連接器狀態更新成功",
|
||||
"connector_updated_successfully": "連接器更新成功",
|
||||
"connectors": "連接器",
|
||||
"create_mapping": "建立對應關係",
|
||||
"created_by": "建立者",
|
||||
"csv_at_least_one_row": "CSV 必須至少包含一筆資料列。",
|
||||
"csv_columns": "CSV 欄位",
|
||||
"csv_empty_column_headers": "CSV 包含空白的欄位標題。所有欄位都必須有名稱。",
|
||||
"csv_file_too_large": "CSV 檔案過大,最大限制為 2MB。",
|
||||
"csv_files_only": "僅限 CSV 檔案",
|
||||
"csv_import": "CSV 匯入",
|
||||
"csv_import_complete": "CSV 匯入完成:{successes} 筆成功,{failures} 筆失敗,{skipped} 筆略過",
|
||||
"csv_import_duplicate_warning": "匯入已經匯入過的資料,可能會產生重複紀錄。",
|
||||
"csv_inconsistent_columns": "第 {row} 列的欄位數不一致。所有列必須有相同的標題。",
|
||||
"csv_max_records": "最多允許 {max} 筆紀錄。",
|
||||
"default_connector_name_csv": "CSV 匯入",
|
||||
"default_connector_name_formbricks": "Formbricks 問卷連線",
|
||||
"deselect_all": "取消全選",
|
||||
"drop_a_field_here": "請將欄位拖曳到這裡",
|
||||
"drop_field_or": "拖曳欄位或",
|
||||
"edit_csv_mapping": "編輯 CSV 對應",
|
||||
"edit_source_connection": "編輯來源連線",
|
||||
"enter_name_for_source": "請輸入此來源的名稱",
|
||||
"enter_value": "請輸入值……",
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "載入回饋紀錄失敗",
|
||||
"feedback_date": "目前日期",
|
||||
"feedback_record_directory": "意見回饋記錄目錄",
|
||||
"feedback_record_fields": "回饋紀錄欄位",
|
||||
"feedback_records": "回饋紀錄",
|
||||
"feedback_records_refreshed": "回饋紀錄已更新",
|
||||
"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_rows": "匯入 {count} 筆資料",
|
||||
"importing_data": "正在匯入資料…",
|
||||
"importing_historical_data": "正在匯入歷史資料…",
|
||||
"invalid_enum_values": "對應到 {field} 欄位的值無效",
|
||||
"invalid_values_found": "發現:{values}(列:{rows}){extra}",
|
||||
"load_sample_csv": "載入範例 CSV",
|
||||
"n_supported_questions": "{count} 個支援的問題",
|
||||
"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": "正在更新回饋紀錄…",
|
||||
"required": "必填",
|
||||
"save_changes": "儲存變更",
|
||||
"select_a_survey_to_see_questions": "請選擇問卷以查看其問題",
|
||||
"select_a_value": "請選擇一個值...",
|
||||
"select_all": "全選",
|
||||
"select_feedback_record_directory": "選擇目錄",
|
||||
"select_questions": "選擇問題",
|
||||
"select_source_type_description": "請選擇你想要連接的回饋來源類型。",
|
||||
"select_source_type_prompt": "請選擇你想要連接的回饋來源類型:",
|
||||
"select_survey": "選擇問卷",
|
||||
"select_survey_and_questions": "選擇問卷與問題",
|
||||
"select_survey_questions_description": "請選擇哪些問卷問題要建立 FeedbackRecords。",
|
||||
"set_value": "設定值",
|
||||
"setup_connection": "設定連線",
|
||||
"showing_count_loaded": "顯示 {count} 筆記錄",
|
||||
"showing_rows": "顯示 {count} 筆資料中的 3 筆",
|
||||
"source": "來源",
|
||||
"source_connect_csv_description": "從 CSV 檔案匯入回饋",
|
||||
"source_connect_formbricks_description": "連接來自你 Formbricks 問卷的回饋",
|
||||
"source_fields": "來源欄位",
|
||||
"source_name": "來源名稱",
|
||||
"source_type": "來源類型",
|
||||
"source_type_cannot_be_changed": "來源類型無法變更",
|
||||
"sources": "來源",
|
||||
"status_active": "進行中",
|
||||
"status_completed": "已完成",
|
||||
"status_draft": "草稿",
|
||||
"status_error": "錯誤",
|
||||
"status_paused": "已暫停",
|
||||
"survey_has_no_questions": "此問卷沒有任何題目",
|
||||
"survey_import_line": "{surveyName}:{responseCount} 份回應 × {questionCount} 題 = {total} 筆意見紀錄",
|
||||
"total_feedback_records": "總計:{surveyCount} 份問卷中已選擇 {checked} / {total} 筆意見紀錄",
|
||||
"unify_feedback": "整合回饋",
|
||||
"update_mapping_description": "更新此來源的對應設定。",
|
||||
"updated_at": "更新時間",
|
||||
"upload_csv_data_description": "上傳 CSV 檔案以匯入回饋資料。",
|
||||
"upload_csv_file": "上傳 CSV 檔案",
|
||||
"user_identifier": "使用者",
|
||||
"value": "值"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
"ces_description": "利用每個接觸點來瞭解客戶互動的便利性。",
|
||||
|
||||
@@ -12,7 +12,9 @@ type HasFindMany =
|
||||
| Prisma.TeamFindManyArgs
|
||||
| Prisma.WorkspaceTeamFindManyArgs
|
||||
| Prisma.UserFindManyArgs
|
||||
| Prisma.ContactAttributeKeyFindManyArgs;
|
||||
| Prisma.ContactAttributeKeyFindManyArgs
|
||||
| Prisma.ChartFindManyArgs
|
||||
| Prisma.DashboardFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from "@/modules/ui/components/button";
|
||||
export const BackToLoginButton = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<Button variant="secondary" className="w-full justify-center">
|
||||
<Button variant="default" className="w-full justify-center">
|
||||
<Link href="/auth/login" className="h-full w-full">
|
||||
{t("auth.signup.log_in")}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockLoad = vi.fn();
|
||||
const mockTablePivot = vi.fn();
|
||||
|
||||
vi.mock("@cubejs-client/core", () => ({
|
||||
default: vi.fn(() => ({
|
||||
load: mockLoad,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("executeQuery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
const resultSet = { tablePivot: mockTablePivot };
|
||||
mockLoad.mockResolvedValue(resultSet);
|
||||
mockTablePivot.mockReturnValue([{ id: "1", count: 42 }]);
|
||||
});
|
||||
|
||||
test("loads query and returns tablePivot result", async () => {
|
||||
const { executeQuery } = await import("./cube-client");
|
||||
const query = { measures: ["FeedbackRecords.count"] };
|
||||
const result = await executeQuery(query);
|
||||
|
||||
expect(mockLoad).toHaveBeenCalledWith(query);
|
||||
expect(mockTablePivot).toHaveBeenCalled();
|
||||
expect(result).toEqual([{ id: "1", count: 42 }]);
|
||||
});
|
||||
|
||||
test("preserves API URL when it already contains /cubejs-api/v1", async () => {
|
||||
const fullUrl = "https://cube.example.com/cubejs-api/v1";
|
||||
vi.stubEnv("CUBEJS_API_URL", fullUrl);
|
||||
const { executeQuery } = await import("./cube-client");
|
||||
|
||||
await executeQuery({ measures: ["FeedbackRecords.count"] });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const cubejs = ((await vi.importMock("@cubejs-client/core")) as any).default;
|
||||
expect(cubejs).toHaveBeenCalledWith(expect.any(String), { apiUrl: fullUrl });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import cubejs, { type CubeApi, type Query } from "@cubejs-client/core";
|
||||
|
||||
const getApiUrl = (): string => {
|
||||
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
|
||||
if (baseUrl.includes("/cubejs-api/v1")) {
|
||||
return baseUrl;
|
||||
}
|
||||
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
|
||||
};
|
||||
|
||||
let cubeClient: CubeApi | null = null;
|
||||
|
||||
function getCubeClient(): CubeApi {
|
||||
if (!cubeClient) {
|
||||
// TODO: This will fail silently if the token is not set. We need to fix this before going to production.
|
||||
const token = process.env.CUBEJS_API_TOKEN ?? "";
|
||||
cubeClient = cubejs(token, { apiUrl: getApiUrl() });
|
||||
}
|
||||
return cubeClient;
|
||||
}
|
||||
|
||||
export async function executeQuery(query: Query) {
|
||||
const client = getCubeClient();
|
||||
const resultSet = await client.load(query);
|
||||
return resultSet.tablePivot();
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
"use server";
|
||||
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { Output, generateText } from "ai";
|
||||
import { z } from "zod";
|
||||
import { type TChartQuery, ZChartQuery } from "@formbricks/types/analysis";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
|
||||
import { injectTenantFilter, validateQueryMembers } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import {
|
||||
createChart,
|
||||
deleteChart,
|
||||
duplicateChart,
|
||||
getChart,
|
||||
getCharts,
|
||||
updateChart,
|
||||
} from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { checkWorkspaceAccess } from "@/modules/ee/analysis/lib/access";
|
||||
import { generateSchemaContext } from "@/modules/ee/analysis/lib/ai-schema-context";
|
||||
import { ZChartCreateInput, ZChartType, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
|
||||
/** Client-facing chart input (workspaceId and createdBy are resolved server-side) */
|
||||
const ZChartCreateInputClient = ZChartCreateInput.omit({ workspaceId: true, createdBy: true });
|
||||
|
||||
const ZCreateChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
chartInput: ZChartCreateInputClient,
|
||||
});
|
||||
|
||||
export const createChartAction = authenticatedActionClient.inputSchema(ZCreateChartAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateChartAction>;
|
||||
}) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const chart = await createChart({
|
||||
...parsedInput.chartInput,
|
||||
workspaceId,
|
||||
createdBy: ctx.user.id,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.chartId = chart.id;
|
||||
ctx.auditLoggingCtx.newObject = chart;
|
||||
return chart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
chartId: ZId,
|
||||
chartUpdateInput: ZChartUpdateInput,
|
||||
});
|
||||
|
||||
export const updateChartAction = authenticatedActionClient.inputSchema(ZUpdateChartAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateChartAction>;
|
||||
}) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const { chart, updatedChart } = await updateChart(
|
||||
parsedInput.chartId,
|
||||
workspaceId,
|
||||
parsedInput.chartUpdateInput
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
|
||||
ctx.auditLoggingCtx.oldObject = chart;
|
||||
ctx.auditLoggingCtx.newObject = updatedChart;
|
||||
return updatedChart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDuplicateChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const duplicateChartAction = authenticatedActionClient.inputSchema(ZDuplicateChartAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDuplicateChartAction>;
|
||||
}) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const duplicatedChart = await duplicateChart(parsedInput.chartId, workspaceId, ctx.user.id);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.chartId = duplicatedChart.id;
|
||||
ctx.auditLoggingCtx.newObject = duplicatedChart;
|
||||
return duplicatedChart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const deleteChartAction = authenticatedActionClient.inputSchema(ZDeleteChartAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDeleteChartAction>;
|
||||
}) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const chart = await deleteChart(parsedInput.chartId, workspaceId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
|
||||
ctx.auditLoggingCtx.oldObject = chart;
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const getChartAction = authenticatedActionClient
|
||||
.inputSchema(ZGetChartAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetChartAction>;
|
||||
}) => {
|
||||
const { workspaceId } = await checkWorkspaceAccess(ctx.user.id, parsedInput.workspaceId, "read");
|
||||
|
||||
return getChart(parsedInput.chartId, workspaceId);
|
||||
}
|
||||
);
|
||||
|
||||
const ZGetChartsAction = z.object({
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const getChartsAction = authenticatedActionClient
|
||||
.inputSchema(ZGetChartsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetChartsAction>;
|
||||
}) => {
|
||||
const { workspaceId } = await checkWorkspaceAccess(ctx.user.id, parsedInput.workspaceId, "read");
|
||||
const charts = await getCharts(workspaceId);
|
||||
return charts;
|
||||
}
|
||||
);
|
||||
|
||||
// ── Charts UI specific actions (query execution & AI generation) ─────────────
|
||||
|
||||
const ZExecuteQueryAction = z.object({
|
||||
workspaceId: ZId,
|
||||
query: ZChartQuery,
|
||||
feedbackRecordDirectoryId: ZId,
|
||||
});
|
||||
|
||||
export const executeQueryAction = authenticatedActionClient
|
||||
.inputSchema(ZExecuteQueryAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZExecuteQueryAction>;
|
||||
}) => {
|
||||
await checkWorkspaceAccess(ctx.user.id, parsedInput.workspaceId, "read");
|
||||
|
||||
validateQueryMembers(parsedInput.query);
|
||||
|
||||
const scopedQuery = injectTenantFilter(parsedInput.query, parsedInput.feedbackRecordDirectoryId);
|
||||
|
||||
try {
|
||||
return await executeQuery(scopedQuery as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error("Failed to execute query");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const CUBE_NAME = "FeedbackRecords";
|
||||
|
||||
const ZGenerateAIQueryResponse = z.object({
|
||||
measures: z.array(z.string()),
|
||||
dimensions: z.array(z.string()).nullable(),
|
||||
timeDimensions: z
|
||||
.array(
|
||||
z.object({
|
||||
dimension: z.string(),
|
||||
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).nullable(),
|
||||
dateRange: z.string().nullable(),
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
chartType: ZChartType,
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
member: z.string(),
|
||||
operator: z.enum([
|
||||
"equals",
|
||||
"notEquals",
|
||||
"contains",
|
||||
"notContains",
|
||||
"set",
|
||||
"notSet",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
]),
|
||||
values: z.array(z.string()).nullable(),
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const ZGenerateAIChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
prompt: z.string().min(1).max(2000),
|
||||
feedbackRecordDirectoryId: ZId,
|
||||
});
|
||||
|
||||
export const generateAIChartAction = authenticatedActionClient
|
||||
.inputSchema(ZGenerateAIChartAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGenerateAIChartAction>;
|
||||
}) => {
|
||||
await checkWorkspaceAccess(ctx.user.id, parsedInput.workspaceId, "read");
|
||||
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new Error("OPENAI_API_KEY is not configured");
|
||||
}
|
||||
|
||||
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
const schemaContext = generateSchemaContext();
|
||||
|
||||
const { output } = await generateText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
output: Output.object({ schema: ZGenerateAIQueryResponse }),
|
||||
system: schemaContext,
|
||||
prompt: `User request: "${parsedInput.prompt}"`,
|
||||
});
|
||||
|
||||
const measures = output.measures.length > 0 ? output.measures : [`${CUBE_NAME}.count`];
|
||||
|
||||
const { chartType, ...cubeQuery } = { ...output, measures };
|
||||
|
||||
// Strip nulls/empty arrays so Cube.js receives only present fields
|
||||
const cleanQuery: Record<string, unknown> = {
|
||||
measures: cubeQuery.measures,
|
||||
...(cubeQuery.dimensions?.length && { dimensions: cubeQuery.dimensions }),
|
||||
...(cubeQuery.filters?.length && {
|
||||
filters: cubeQuery.filters.map(({ member, operator, values }) => ({
|
||||
member,
|
||||
operator,
|
||||
...(values != null && { values }),
|
||||
})),
|
||||
}),
|
||||
...(cubeQuery.timeDimensions?.length && {
|
||||
timeDimensions: cubeQuery.timeDimensions.map(({ dimension, granularity, dateRange }) => ({
|
||||
dimension,
|
||||
...(granularity != null && { granularity }),
|
||||
...(dateRange != null && { dateRange }),
|
||||
})),
|
||||
}),
|
||||
};
|
||||
|
||||
validateQueryMembers(cleanQuery as TChartQuery);
|
||||
|
||||
const scopedQuery = injectTenantFilter(
|
||||
cleanQuery as TChartQuery,
|
||||
parsedInput.feedbackRecordDirectoryId
|
||||
);
|
||||
|
||||
const data = await executeQuery(scopedQuery as Record<string, unknown>);
|
||||
|
||||
return {
|
||||
query: cleanQuery,
|
||||
chartType,
|
||||
data: Array.isArray(data) ? data : [],
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface AddToDashboardDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
dashboards: Array<{ id: string; name: string }>;
|
||||
selectedDashboardId: string | undefined;
|
||||
onDashboardSelect: (id: string) => void;
|
||||
onConfirm: () => void;
|
||||
isSaving: boolean;
|
||||
showChartNameField?: boolean;
|
||||
}
|
||||
|
||||
export function AddToDashboardDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
chartName,
|
||||
onChartNameChange,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
onDashboardSelect,
|
||||
onConfirm,
|
||||
isSaving,
|
||||
showChartNameField = true,
|
||||
}: Readonly<AddToDashboardDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && onOpenChange(open)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.analysis.charts.add_chart_to_dashboard")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("workspace.analysis.charts.add_chart_to_dashboard_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
{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}>
|
||||
<SelectTrigger
|
||||
id="dashboard-select"
|
||||
className="mt-2 w-full"
|
||||
disabled={dashboards.length === 0}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
dashboards.length === 0
|
||||
? t("workspace.analysis.charts.no_dashboards_available")
|
||||
: t("workspace.analysis.charts.dashboard_select_placeholder")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="max-h-[200px]">
|
||||
{dashboards.map((dashboard) => (
|
||||
<SelectItem key={dashboard.id} value={dashboard.id}>
|
||||
{dashboard.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{dashboards.length === 0 && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{t("workspace.analysis.charts.no_dashboards_create_first")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
loading={isSaving}
|
||||
disabled={!selectedDashboardId || (showChartNameField && !chartName.trim())}>
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { AdvancedChartPreview } from "@/modules/ee/analysis/charts/components/advanced-chart-preview";
|
||||
import { ChartTypeSelector } from "@/modules/ee/analysis/charts/components/chart-type-selector";
|
||||
import { DimensionsPanel } from "@/modules/ee/analysis/charts/components/dimensions-panel";
|
||||
import { FiltersPanel } from "@/modules/ee/analysis/charts/components/filters-panel";
|
||||
import { MeasuresPanel } from "@/modules/ee/analysis/charts/components/measures-panel";
|
||||
import { TimeDimensionPanel } from "@/modules/ee/analysis/charts/components/time-dimension-panel";
|
||||
import { useChartQuery } from "@/modules/ee/analysis/charts/hooks/use-chart-query";
|
||||
import {
|
||||
type ChartBuilderState,
|
||||
type FilterRow,
|
||||
type TimeDimensionConfig,
|
||||
buildCubeQuery,
|
||||
parseQueryToState,
|
||||
} from "@/modules/ee/analysis/lib/query-builder";
|
||||
import { FEEDBACK_FIELDS } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { AnalyticsResponse, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface AdvancedChartBuilderProps {
|
||||
workspaceId: string;
|
||||
chartType: TChartType;
|
||||
initialQuery?: TChartQuery;
|
||||
hidePreview?: boolean;
|
||||
onChartGenerated?: (data: AnalyticsResponse) => void;
|
||||
feedbackRecordDirectoryId: string | null;
|
||||
runQueryCtaLabel?: string;
|
||||
}
|
||||
|
||||
const ACTION = {
|
||||
SET_MEASURES: "SET_MEASURES",
|
||||
SET_DIMENSIONS: "SET_DIMENSIONS",
|
||||
SET_FILTERS: "SET_FILTERS",
|
||||
SET_FILTER_LOGIC: "SET_FILTER_LOGIC",
|
||||
SET_TIME_DIMENSION: "SET_TIME_DIMENSION",
|
||||
INIT_FROM_QUERY: "INIT_FROM_QUERY",
|
||||
} as const;
|
||||
|
||||
type Action =
|
||||
| { type: typeof ACTION.SET_MEASURES; payload: string[] }
|
||||
| { type: typeof ACTION.SET_DIMENSIONS; payload: string[] }
|
||||
| { type: typeof ACTION.SET_FILTERS; payload: FilterRow[] }
|
||||
| { type: typeof ACTION.SET_FILTER_LOGIC; payload: "and" | "or" }
|
||||
| { type: typeof ACTION.SET_TIME_DIMENSION; payload: TimeDimensionConfig | null }
|
||||
| { type: typeof ACTION.INIT_FROM_QUERY; payload: Partial<ChartBuilderState> };
|
||||
|
||||
const initialState: ChartBuilderState = {
|
||||
selectedMeasures: [],
|
||||
selectedDimensions: [],
|
||||
filters: [],
|
||||
filterLogic: "and",
|
||||
timeDimension: null,
|
||||
};
|
||||
|
||||
const chartBuilderReducer = (state: ChartBuilderState, action: Action): ChartBuilderState => {
|
||||
switch (action.type) {
|
||||
case ACTION.SET_MEASURES:
|
||||
return { ...state, selectedMeasures: action.payload };
|
||||
case ACTION.SET_DIMENSIONS:
|
||||
return { ...state, selectedDimensions: action.payload };
|
||||
case ACTION.SET_FILTERS:
|
||||
return { ...state, filters: action.payload };
|
||||
case ACTION.SET_FILTER_LOGIC:
|
||||
return { ...state, filterLogic: action.payload };
|
||||
case ACTION.SET_TIME_DIMENSION:
|
||||
return { ...state, timeDimension: action.payload };
|
||||
case ACTION.INIT_FROM_QUERY:
|
||||
return { ...state, ...action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export function AdvancedChartBuilder({
|
||||
workspaceId,
|
||||
chartType,
|
||||
initialQuery,
|
||||
hidePreview = false,
|
||||
onChartGenerated,
|
||||
feedbackRecordDirectoryId,
|
||||
runQueryCtaLabel,
|
||||
}: Readonly<AdvancedChartBuilderProps>) {
|
||||
const { t } = useTranslation();
|
||||
const parsedInitial = initialQuery ? parseQueryToState(initialQuery) : null;
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
chartBuilderReducer,
|
||||
initialQuery ? { ...initialState, ...parsedInitial } : initialState
|
||||
);
|
||||
|
||||
const { chartData, query, isLoading, error, runQuery } = useChartQuery(
|
||||
workspaceId,
|
||||
feedbackRecordDirectoryId,
|
||||
initialQuery
|
||||
);
|
||||
|
||||
const currentQuery = useMemo(() => buildCubeQuery(state), [state]);
|
||||
const hasConfigChanged = useMemo(() => {
|
||||
if (!query) return true;
|
||||
return JSON.stringify(currentQuery) !== JSON.stringify(query);
|
||||
}, [currentQuery, query]);
|
||||
|
||||
const appliedInitialQueryRef = useRef<TChartQuery | null>(null);
|
||||
useEffect(() => {
|
||||
if (!initialQuery) return;
|
||||
if (appliedInitialQueryRef.current === initialQuery) return;
|
||||
appliedInitialQueryRef.current = initialQuery;
|
||||
const parsed = parseQueryToState(initialQuery);
|
||||
dispatch({ type: ACTION.INIT_FROM_QUERY, payload: parsed });
|
||||
setDimensionsOpen((parsed.selectedDimensions?.length ?? 0) > 0);
|
||||
}, [initialQuery]);
|
||||
|
||||
const handleRunQuery = async () => {
|
||||
if (state.selectedMeasures.length === 0) {
|
||||
toast.error(t("workspace.analysis.charts.please_select_at_least_one_measure"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (dimensionsOpen && state.selectedDimensions.length === 0) {
|
||||
toast.error(t("workspace.analysis.charts.please_select_at_least_one_dimension"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (filtersOpen && state.filters.length > 0) {
|
||||
const hasEmptyFilterValue = state.filters.some(
|
||||
(f) => f.operator !== "set" && f.operator !== "notSet" && (f.values === null || f.values.length === 0)
|
||||
);
|
||||
if (hasEmptyFilterValue) {
|
||||
toast.error(t("workspace.analysis.charts.please_enter_filter_values"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runQuery(buildCubeQuery(state));
|
||||
if (result) {
|
||||
onChartGenerated?.({ ...result, chartType });
|
||||
}
|
||||
};
|
||||
|
||||
const [dimensionsOpen, setDimensionsOpen] = useState(
|
||||
() => (parsedInitial?.selectedDimensions?.length ?? 0) > 0
|
||||
);
|
||||
const timeDimensionOpen = state.timeDimension != null;
|
||||
const filtersOpen = state.filters.length > 0;
|
||||
|
||||
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={() => {}} />}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col gap-3 overflow-hidden rounded-lg border bg-slate-50 p-4">
|
||||
<MeasuresPanel
|
||||
selectedMeasures={state.selectedMeasures}
|
||||
onMeasuresChange={(measures) => dispatch({ type: ACTION.SET_MEASURES, payload: measures })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={dimensionsOpen}
|
||||
onToggle={(checked) => {
|
||||
setDimensionsOpen(checked);
|
||||
if (!checked) dispatch({ type: ACTION.SET_DIMENSIONS, payload: [] });
|
||||
}}
|
||||
htmlId="chart-dimensions-toggle"
|
||||
title={t("workspace.analysis.charts.group_data")}
|
||||
description={t("workspace.analysis.charts.dimensions_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<DimensionsPanel
|
||||
hideTitle
|
||||
selectedDimensions={state.selectedDimensions}
|
||||
onDimensionsChange={(dimensions) =>
|
||||
dispatch({ type: ACTION.SET_DIMENSIONS, payload: dimensions })
|
||||
}
|
||||
/>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={timeDimensionOpen}
|
||||
onToggle={() => {
|
||||
if (timeDimensionOpen) dispatch({ type: ACTION.SET_TIME_DIMENSION, payload: null });
|
||||
else if (!state.timeDimension) {
|
||||
dispatch({
|
||||
type: ACTION.SET_TIME_DIMENSION,
|
||||
payload: {
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
dateRange: "last 30 days",
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
htmlId="chart-time-dimension-toggle"
|
||||
title={t("workspace.analysis.charts.time_dimension_title")}
|
||||
description={t("workspace.analysis.charts.time_dimension_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<TimeDimensionPanel
|
||||
hideTitle
|
||||
timeDimension={state.timeDimension}
|
||||
onTimeDimensionChange={(config) => dispatch({ type: ACTION.SET_TIME_DIMENSION, payload: config })}
|
||||
/>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={filtersOpen}
|
||||
onToggle={() => {
|
||||
if (filtersOpen) {
|
||||
dispatch({ type: ACTION.SET_FILTERS, payload: [] });
|
||||
} else if (state.filters.length === 0) {
|
||||
const firstField = FEEDBACK_FIELDS.dimensions[0] ?? FEEDBACK_FIELDS.measures[0];
|
||||
dispatch({
|
||||
type: ACTION.SET_FILTERS,
|
||||
payload: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
field: firstField?.id ?? "",
|
||||
operator: "equals" as const,
|
||||
values: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
htmlId="chart-filters-toggle"
|
||||
title={t("workspace.analysis.charts.filter_data")}
|
||||
description={t("workspace.analysis.charts.filters_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<FiltersPanel
|
||||
hideTitle
|
||||
filters={state.filters}
|
||||
filterLogic={state.filterLogic}
|
||||
onFiltersChange={(filters) => dispatch({ type: ACTION.SET_FILTERS, payload: filters })}
|
||||
onFilterLogicChange={(logic) => dispatch({ type: ACTION.SET_FILTER_LOGIC, payload: logic })}
|
||||
/>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleRunQuery} disabled={isLoading || !hasConfigChanged}>
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
(runQueryCtaLabel ?? t("workspace.analysis.charts.create_chart"))
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hidePreview && (
|
||||
<AdvancedChartPreview
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
chartData={chartData}
|
||||
chartType={chartType}
|
||||
query={query}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { DatabaseIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
|
||||
import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface AdvancedChartPreviewProps {
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
chartData: TChartDataRow[] | null;
|
||||
chartType: TChartType;
|
||||
query: TChartQuery | null;
|
||||
}
|
||||
|
||||
export function AdvancedChartPreview({
|
||||
error,
|
||||
isLoading,
|
||||
chartData,
|
||||
chartType,
|
||||
query,
|
||||
}: Readonly<AdvancedChartPreviewProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [showData, setShowData] = useState(false);
|
||||
const hasData = chartData && chartData.length > 0 && !isLoading && chartType && query;
|
||||
const isEmpty = !chartData && !isLoading && !error;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-md font-semibold text-gray-900">{t("workspace.analysis.charts.chart_preview")}</h3>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">{error}</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasData && (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<ChartRenderer chartType={chartType} data={chartData} query={query} />
|
||||
</div>
|
||||
|
||||
<Collapsible.Root open={showData} onOpenChange={setShowData}>
|
||||
<Collapsible.CollapsibleTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<DatabaseIcon className="mr-2 h-4 w-4" />
|
||||
{showData ? t("common.hide") : t("common.view")} {t("workspace.analysis.charts.data_label")}
|
||||
</Button>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-2">
|
||||
<DataViewer data={chartData} />
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.advanced_chart_builder_config_prompt")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { ActivityIcon, WandSparklesIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { generateAIChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import type { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface AIQuerySectionProps {
|
||||
workspaceId: string;
|
||||
onChartGenerated: (data: AnalyticsResponse) => void;
|
||||
feedbackRecordDirectoryId: string;
|
||||
}
|
||||
|
||||
export function AIQuerySection({
|
||||
workspaceId,
|
||||
onChartGenerated,
|
||||
feedbackRecordDirectoryId,
|
||||
}: Readonly<AIQuerySectionProps>) {
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!userQuery.trim()) return;
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const result = await generateAIChartAction({
|
||||
workspaceId,
|
||||
prompt: userQuery.trim(),
|
||||
feedbackRecordDirectoryId,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
onChartGenerated(result.data);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-brand-dark/10">
|
||||
<ActivityIcon className="h-5 w-5 text-brand-dark" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-gray-900">
|
||||
{t("workspace.analysis.charts.ai_query_section_title")}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">{t("workspace.analysis.charts.ai_query_section_description")}</p>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-3" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t("workspace.analysis.charts.ai_query_placeholder")}
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
maxLength={2000}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={!userQuery.trim() || isGenerating}
|
||||
loading={isGenerating}>
|
||||
<WandSparklesIcon className="h-4 w-4" />
|
||||
{t("workspace.analysis.charts.create_chart_with_ai")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { type ElementType, type ReactNode, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
CHART_BRAND_DARK,
|
||||
formatCellValue,
|
||||
formatXAxisTick,
|
||||
} from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
|
||||
import type { ChartConfig } from "@/modules/ui/components/chart";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/modules/ui/components/chart";
|
||||
|
||||
const ChartTooltipRow = ({
|
||||
value,
|
||||
dataKey,
|
||||
color,
|
||||
}: Readonly<{ value: unknown; dataKey: string; color?: string }>) => {
|
||||
const { t } = useTranslation();
|
||||
const indicatorColor = color ?? CHART_BRAND_DARK;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border border-current"
|
||||
style={{
|
||||
backgroundColor: indicatorColor,
|
||||
borderColor: indicatorColor,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between leading-none">
|
||||
<span className="text-muted-foreground">{formatCubeColumnHeader(dataKey, t)}</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">{formatCellValue(value)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** Creates a tooltip formatter bound to dataKey for Cartesian charts. Defined at module level to avoid Sonar "component in parent" warnings. */
|
||||
const createTooltipFormatter = (dataKey: string) => {
|
||||
const Formatter = (value: unknown) => <ChartTooltipRow value={value} dataKey={dataKey} />;
|
||||
Formatter.displayName = "ChartTooltipFormatter";
|
||||
return Formatter;
|
||||
};
|
||||
|
||||
/** Tooltip content for single-measure Cartesian charts. */
|
||||
const SingleMeasureTooltip = ({ dataKey }: Readonly<{ dataKey: string }>) => {
|
||||
const formatter = useMemo(() => createTooltipFormatter(dataKey), [dataKey]);
|
||||
return <ChartTooltipContent labelFormatter={formatXAxisTick} formatter={formatter} />;
|
||||
};
|
||||
|
||||
/** Tooltip formatter for multi-measure charts; uses each payload item's dataKey and color. */
|
||||
const multiMeasureTooltipFormatter = (value: unknown, name: string | number, item: unknown) => {
|
||||
const itemObj = item as { dataKey?: string; color?: string; payload?: { fill?: string } } | undefined;
|
||||
const key = itemObj?.dataKey ?? String(name);
|
||||
const color = itemObj?.color ?? itemObj?.payload?.fill;
|
||||
return <ChartTooltipRow value={value} dataKey={key} color={color} />;
|
||||
};
|
||||
|
||||
export interface CartesianChartProps {
|
||||
data: TChartDataRow[];
|
||||
xAxisKey: string;
|
||||
dataKeys: string[];
|
||||
chartConfig: ChartConfig;
|
||||
chart: ElementType;
|
||||
children: ReactNode;
|
||||
showLegend?: boolean;
|
||||
chartProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Shared layout for bar, line, and area charts. Supports single or multiple measures. */
|
||||
export function CartesianChart({
|
||||
data,
|
||||
xAxisKey,
|
||||
dataKeys,
|
||||
chartConfig,
|
||||
chart: Chart,
|
||||
children,
|
||||
showLegend = false,
|
||||
chartProps = {},
|
||||
}: Readonly<CartesianChartProps>) {
|
||||
const isMultiMeasure = dataKeys.length > 1;
|
||||
const tooltipContent = isMultiMeasure ? (
|
||||
<ChartTooltipContent labelFormatter={formatXAxisTick} formatter={multiMeasureTooltipFormatter} />
|
||||
) : (
|
||||
<SingleMeasureTooltip dataKey={dataKeys[0]} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-64 w-full">
|
||||
<ChartContainer config={chartConfig} className="h-full w-full">
|
||||
<Chart data={data} {...chartProps}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey={xAxisKey}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={formatXAxisTick}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={tooltipContent} />
|
||||
{showLegend && <ChartLegend content={<ChartLegendContent />} verticalAlign="top" height={36} />}
|
||||
{children}
|
||||
</Chart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, SaveIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DialogFooter } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartDialogFooterProps {
|
||||
onSaveClick: () => 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>
|
||||
{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" />
|
||||
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface ChartDialogLoadingViewProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChartDialogLoadingView({ open, onClose }: Readonly<ChartDialogLoadingViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent width="wide">
|
||||
<DialogTitle className="sr-only">{t("common.loading")}</DialogTitle>
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon, MoreVertical, PlusIcon, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface ChartDropdownMenuProps {
|
||||
workspaceId: string;
|
||||
chart: TChartWithCreator;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<ChartDropdownMenuProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
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);
|
||||
try {
|
||||
const result = await deleteChartAction({ workspaceId, chartId: chart.id });
|
||||
if (result?.data) {
|
||||
toast.success(t("workspace.analysis.charts.chart_deleted_successfully"));
|
||||
setIsDeleteDialogOpen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
const msg = getFormattedErrorMessage(result) || t("workspace.analysis.charts.chart_deletion_error");
|
||||
toast.error(msg);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateChart = async () => {
|
||||
setIsDuplicating(true);
|
||||
try {
|
||||
const result = await duplicateChartAction({ workspaceId, chartId: chart.id });
|
||||
if (result?.data) {
|
||||
toast.success(t("workspace.analysis.charts.chart_duplicated_successfully"));
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(
|
||||
getFormattedErrorMessage(result) || t("workspace.analysis.charts.chart_duplication_error")
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("workspace.analysis.charts.chart_duplication_error"));
|
||||
} finally {
|
||||
setIsDuplicating(false);
|
||||
}
|
||||
};
|
||||
|
||||
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}>
|
||||
<DropdownMenuTrigger className="z-10" asChild>
|
||||
<Button variant="outline" className="px-2" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="sr-only">{t("workspace.analysis.charts.open_options")}</span>
|
||||
<MoreVertical className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="inline-block w-auto min-w-max" align="end">
|
||||
<DropdownMenuGroup>
|
||||
{onEdit && (
|
||||
<DropdownMenuItem
|
||||
icon={<SquarePenIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
setIsDropDownOpen(false);
|
||||
onEdit();
|
||||
}}>
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<CopyIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
setIsDropDownOpen(false);
|
||||
handleDuplicateChart();
|
||||
}}
|
||||
disabled={isDuplicating}>
|
||||
{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={() => {
|
||||
setIsDropDownOpen(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
disabled={isDeleting}>
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat={t("common.chart")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
onDelete={handleDeleteChart}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
interface ChartErrorBoundaryProps {
|
||||
fallbackMessage: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ChartErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class ChartErrorBoundary extends Component<ChartErrorBoundaryProps, ChartErrorBoundaryState> {
|
||||
constructor(props: ChartErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): ChartErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error("ChartRenderer error:", error, info.componentStack);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 items-center justify-center text-sm">
|
||||
{this.props.fallbackMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, DatabaseIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChartErrorBoundary } from "@/modules/ee/analysis/charts/components/chart-error-boundary";
|
||||
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
|
||||
import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer";
|
||||
import { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
|
||||
interface ChartPreviewProps {
|
||||
chartData: AnalyticsResponse | null;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function ChartPreview({ chartData, isLoading = false, error }: Readonly<ChartPreviewProps>) {
|
||||
const [activeTab, setActiveTab] = useState<"chart" | "data">("chart");
|
||||
const { t } = useTranslation();
|
||||
|
||||
const data = chartData?.data ?? [];
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (value === "chart" || value === "data") {
|
||||
setActiveTab(value);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || chartData?.error) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-red-600">
|
||||
{error || chartData?.error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chartData) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.no_data_available")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<TabsList>
|
||||
<TabsTrigger value="chart" icon={<BarChart className="h-4 w-4" />}>
|
||||
{t("workspace.analysis.charts.chart")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="data" icon={<DatabaseIcon className="h-4 w-4" />}>
|
||||
{t("workspace.analysis.charts.chart_data_tab")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="chart" className="mt-0">
|
||||
<ChartErrorBoundary fallbackMessage={t("workspace.analysis.charts.chart_render_error")}>
|
||||
<ChartRenderer chartType={chartData.chartType} data={data} query={chartData.query} />
|
||||
</ChartErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="data" className="mt-0">
|
||||
<DataViewer data={data} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 font-semibold text-gray-900">{t("workspace.analysis.charts.chart_preview")}</h3>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Area, AreaChart, Bar, BarChart, Cell, Line, LineChart, Pie, PieChart } from "recharts";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesian-chart";
|
||||
import {
|
||||
CHART_BRAND_DARK,
|
||||
CHART_MEASURE_COLORS,
|
||||
formatXAxisTick,
|
||||
preparePieData,
|
||||
} from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import type { ChartConfig } from "@/modules/ui/components/chart";
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
|
||||
|
||||
const formatPieLabel = ({ name, percent }: { name: string; percent?: number }): string => {
|
||||
if (percent == null) return "";
|
||||
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
|
||||
};
|
||||
|
||||
interface ChartRendererProps {
|
||||
chartType: TChartType;
|
||||
data: TChartDataRow[];
|
||||
query: TChartQuery;
|
||||
}
|
||||
|
||||
export function ChartRenderer({ chartType, data, query }: Readonly<ChartRendererProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 items-center justify-center">
|
||||
{t("workspace.analysis.charts.no_data_available")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rowKeys = Object.keys(data[0] ?? {});
|
||||
const timeDim = query.timeDimensions?.[0];
|
||||
const timeDimKey = timeDim?.granularity
|
||||
? `${timeDim.dimension}.${timeDim.granularity}`
|
||||
: timeDim?.dimension;
|
||||
|
||||
const xAxisKey = query.dimensions?.[0] ?? timeDimKey ?? rowKeys[0] ?? "key";
|
||||
|
||||
const measureIds = query.measures?.filter((m) => rowKeys.includes(m)) ?? [];
|
||||
const dataKeys = measureIds.length > 0 ? measureIds : rowKeys.filter((k) => k !== xAxisKey);
|
||||
|
||||
if (dataKeys.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 items-center justify-center">
|
||||
{t("workspace.analysis.charts.no_data_available")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartConfig: ChartConfig = Object.fromEntries(
|
||||
dataKeys.map((key, i) => [
|
||||
key,
|
||||
{
|
||||
label: formatCubeColumnHeader(key, t),
|
||||
color: CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length],
|
||||
},
|
||||
])
|
||||
);
|
||||
const dataKey = dataKeys[0];
|
||||
const isMultiMeasure = dataKeys.length > 1;
|
||||
|
||||
switch (chartType) {
|
||||
case "bar":
|
||||
return (
|
||||
<CartesianChart
|
||||
chart={BarChart}
|
||||
data={data}
|
||||
xAxisKey={xAxisKey}
|
||||
dataKeys={dataKeys}
|
||||
chartConfig={chartConfig}
|
||||
showLegend
|
||||
chartProps={isMultiMeasure ? { barCategoryGap: "20%" } : {}}>
|
||||
{dataKeys.map((key, i) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
|
||||
radius={4}
|
||||
/>
|
||||
))}
|
||||
</CartesianChart>
|
||||
);
|
||||
case "line":
|
||||
return (
|
||||
<CartesianChart
|
||||
chart={LineChart}
|
||||
data={data}
|
||||
xAxisKey={xAxisKey}
|
||||
dataKeys={dataKeys}
|
||||
chartConfig={chartConfig}
|
||||
showLegend={isMultiMeasure}>
|
||||
{dataKeys.map((key, i) => {
|
||||
const color = chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length];
|
||||
return (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={color}
|
||||
strokeWidth={3}
|
||||
dot={{ fill: color, r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CartesianChart>
|
||||
);
|
||||
case "area":
|
||||
return (
|
||||
<CartesianChart
|
||||
chart={AreaChart}
|
||||
data={data}
|
||||
xAxisKey={xAxisKey}
|
||||
dataKeys={dataKeys}
|
||||
chartConfig={chartConfig}
|
||||
showLegend={isMultiMeasure}>
|
||||
{dataKeys.map((key, i) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
|
||||
fill={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
|
||||
fillOpacity={0.4}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</CartesianChart>
|
||||
);
|
||||
case "pie": {
|
||||
const pieResult = preparePieData(data, dataKey);
|
||||
if (!pieResult) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 items-center justify-center">
|
||||
{t("workspace.analysis.charts.no_valid_data_to_display")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { processedData, colors } = pieResult;
|
||||
|
||||
return (
|
||||
<div className="h-64 w-full min-w-0">
|
||||
<ChartContainer config={chartConfig} className="h-full w-full min-w-0">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={processedData}
|
||||
dataKey={dataKey}
|
||||
nameKey={xAxisKey}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
label={formatPieLabel}>
|
||||
{processedData.map((row, index) => {
|
||||
const rowKey = row[xAxisKey] ?? `row-${index}`;
|
||||
const uniqueKey = `${xAxisKey}-${String(rowKey)}-${index}`;
|
||||
return <Cell key={uniqueKey} fill={colors[index] || CHART_BRAND_DARK} />;
|
||||
})}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, name) => [String(value), formatCubeColumnHeader(String(name), t)]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "big_number": {
|
||||
const total =
|
||||
data.length === 1
|
||||
? Number(data[0]?.[dataKey]) || 0
|
||||
: data.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0);
|
||||
const formatted = total.toLocaleString();
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-foreground text-4xl font-bold">{formatted}</div>
|
||||
<div className="text-muted-foreground mt-2 text-sm">{formatCubeColumnHeader(dataKey, t)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 items-center justify-center">
|
||||
{t("workspace.analysis.charts.chart_type_not_supported", { chartType })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDate, timeSinceDate } from "@/lib/time";
|
||||
import { ChartDropdownMenu } from "@/modules/ee/analysis/charts/components/chart-dropdown-menu";
|
||||
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
|
||||
import { CHART_TYPE_ICONS } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartRowProps {
|
||||
chart: TChartWithCreator;
|
||||
workspaceId: string;
|
||||
isReadOnly: boolean;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function ChartRow({ chart, workspaceId, isReadOnly, directories }: Readonly<ChartRowProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const IconComponent = CHART_TYPE_ICONS[chart.type] ?? BarChart3Icon;
|
||||
|
||||
const handleChartClick = () => {
|
||||
if (!isReadOnly) {
|
||||
setIsEditDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleChartClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Cannot use native <button>; row contains dropdown trigger (nested interactive invalid) */}
|
||||
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
role={isReadOnly ? undefined : "button"}
|
||||
tabIndex={isReadOnly ? undefined : 0}
|
||||
onClick={isReadOnly ? undefined : handleChartClick}
|
||||
onKeyDown={isReadOnly ? undefined : handleRowKeyDown}
|
||||
aria-label={isReadOnly ? undefined : t("workspace.analysis.charts.open_chart", { name: chart.name })}
|
||||
className={`grid h-12 w-full grid-cols-7 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100 ${isReadOnly ? "" : "cursor-pointer"}`}>
|
||||
<div className="col-span-6 grid grid-cols-6 content-center">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{chart.creator?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{formatDate(new Date(chart.createdAt))}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{timeSinceDate(new Date(chart.updatedAt))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div // NOSONAR - stopPropagation wrapper to prevent row click when interacting with dropdown
|
||||
className="col-span-1 my-auto flex items-center justify-end pr-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}>
|
||||
{!isReadOnly && (
|
||||
<ChartDropdownMenu
|
||||
workspaceId={workspaceId}
|
||||
chart={chart}
|
||||
onEdit={() => setIsEditDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<CreateChartDialog
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
workspaceId={workspaceId}
|
||||
chartId={chart.id}
|
||||
initialChart={chart}
|
||||
onSuccess={() => setIsEditDialogOpen(false)}
|
||||
directories={directories}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartTypeSelectorProps {
|
||||
selectedChartType: TChartType;
|
||||
onChartTypeSelect: (chartType: TChartType) => void;
|
||||
}
|
||||
|
||||
export function ChartTypeSelector({
|
||||
selectedChartType,
|
||||
onChartTypeSelect,
|
||||
}: Readonly<ChartTypeSelectorProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartTypes = getChartTypes(t);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-md font-semibold text-gray-900">
|
||||
{t("workspace.analysis.charts.chart_builder_choose_chart_type")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{chartTypes.map((chart) => {
|
||||
const isSelected = selectedChartType === chart.id;
|
||||
return (
|
||||
<button
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onChartTypeSelect(chart.id)}
|
||||
className={`rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 ${
|
||||
isSelected
|
||||
? "border-brand-dark bg-brand-dark/5 ring-1 ring-brand-dark"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { use } from "react";
|
||||
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";
|
||||
|
||||
interface ChartsListContentProps {
|
||||
chartsPromise: Promise<TChartWithCreator[]>;
|
||||
workspaceId: string;
|
||||
isReadOnly: boolean;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const ChartsListContent = ({
|
||||
chartsPromise,
|
||||
workspaceId,
|
||||
isReadOnly,
|
||||
directories,
|
||||
}: Readonly<ChartsListContentProps>) => {
|
||||
const charts = use(chartsPromise);
|
||||
|
||||
return (
|
||||
<ChartsList charts={charts} workspaceId={workspaceId} isReadOnly={isReadOnly} directories={directories} />
|
||||
);
|
||||
};
|
||||
|
||||
interface ChartsListPageProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPageProps>) {
|
||||
const t = await getTranslate();
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
|
||||
directories.map((directory) => directory.id)
|
||||
);
|
||||
const chartsPromise = hasFeedbackRecords ? getChartsWithCreator(workspaceId) : null;
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.dashboards")}
|
||||
workspaceId={workspaceId}
|
||||
cta={
|
||||
isReadOnly ? undefined : (
|
||||
<CreateChartButton
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
buttonProps={{ disabled: !hasFeedbackRecords }}
|
||||
/>
|
||||
)
|
||||
}>
|
||||
{hasFeedbackRecords && chartsPromise ? (
|
||||
<ChartsListContent
|
||||
chartsPromise={chartsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
/>
|
||||
) : (
|
||||
<NoFeedbackRecordsState workspaceId={workspaceId} />
|
||||
)}
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const SKELETON_ROWS = 3;
|
||||
|
||||
const SkeletonRow = () => {
|
||||
return (
|
||||
<div className="grid h-12 w-full animate-pulse grid-cols-7 content-center p-2">
|
||||
<div className="col-span-3 flex items-center gap-4 pl-6">
|
||||
<div className="h-5 w-5 rounded bg-gray-200" />
|
||||
<div className="h-4 w-36 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-20 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-20 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChartsListSkeletonProps {
|
||||
columnHeaders: [string, string, string, string];
|
||||
}
|
||||
|
||||
export const ChartsListSkeleton = ({ columnHeaders }: Readonly<ChartsListSkeletonProps>) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{columnHeaders[0]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[1]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[2]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[3]}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
|
||||
<SkeletonRow key={`skeleton-row-${String(i)}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ChartRow } from "@/modules/ee/analysis/charts/components/chart-row";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartsListProps {
|
||||
charts: TChartWithCreator[];
|
||||
workspaceId: string;
|
||||
isReadOnly: boolean;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export const ChartsList = async ({
|
||||
charts,
|
||||
workspaceId,
|
||||
isReadOnly,
|
||||
directories,
|
||||
}: Readonly<ChartsListProps>) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{t("common.title")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{charts.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
{t("workspace.analysis.charts.no_charts_found")}
|
||||
</p>
|
||||
) : (
|
||||
charts.map((chart) => (
|
||||
<ChartRow
|
||||
key={chart.id}
|
||||
chart={chart}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user