mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 19:35:53 -05:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ceabaf0aff | |||
| 52dc64ffd2 | |||
| bec3fa2dbd | |||
| 18a3c4f0f7 | |||
| 6c61afec2f | |||
| ce4d9350e2 | |||
| 3e6f81268d | |||
| 8137de3c80 | |||
| bf0ad45697 | |||
| b1a4277ca8 | |||
| 1876c13f52 | |||
| 0623bb9ff5 | |||
| d37cddaa7e | |||
| 24f632f9ce | |||
| b041e3da86 | |||
| 8d91a3db62 | |||
| c05e3f192d | |||
| 5b61e00560 |
@@ -0,0 +1 @@
|
||||
export { WorkspaceSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
|
||||
@@ -23,9 +23,13 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||
import { getOrganizationsByUserId } from "./lib/organization";
|
||||
import { getWorkspacesByUserId } from "./lib/workspace";
|
||||
|
||||
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
|
||||
feedbackRecordDirectoryId: ZId.optional(),
|
||||
});
|
||||
|
||||
const ZCreateWorkspaceAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZWorkspaceUpdateInput,
|
||||
data: ZCreateWorkspaceInput,
|
||||
});
|
||||
|
||||
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
|
||||
@@ -40,7 +44,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZWorkspaceUpdateInput,
|
||||
schema: ZCreateWorkspaceInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
Loader2,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
MessageSquareTextIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
SettingsIcon,
|
||||
Shapes,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
@@ -146,58 +146,77 @@ export const MainNavigation = ({
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const mainNavigation = useMemo(
|
||||
const mainNavigationSections = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: t("common.surveys"),
|
||||
href: `/workspaces/${workspace.id}/surveys`,
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
href: `/workspaces/${workspace.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
icon: UserIcon,
|
||||
isActive:
|
||||
pathname?.includes("/contacts") ||
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
name: t("common.analysis"),
|
||||
href: `/workspaces/${workspace.id}/dashboards`,
|
||||
icon: BarChart3Icon,
|
||||
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
id: "ask",
|
||||
name: "Ask",
|
||||
items: [
|
||||
{
|
||||
name: t("common.surveys"),
|
||||
href: `/workspaces/${workspace.id}/surveys`,
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
href: `/workspaces/${workspace.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
icon: UserIcon,
|
||||
isActive:
|
||||
pathname?.includes("/contacts") ||
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "unify-feedback",
|
||||
name: t("workspace.unify.unify_feedback"),
|
||||
href: `/workspaces/${workspace.id}/unify/sources`,
|
||||
icon: Shapes,
|
||||
isActive: pathname?.includes("/unify"),
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/workspaces/${workspace.id}/general`,
|
||||
icon: Cog,
|
||||
isActive:
|
||||
pathname?.includes("/general") ||
|
||||
pathname?.includes("/look") ||
|
||||
pathname?.includes("/app-connection") ||
|
||||
pathname?.includes("/integrations") ||
|
||||
pathname?.includes("/teams") ||
|
||||
pathname?.includes("/languages") ||
|
||||
pathname?.includes("/tags"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
items: [
|
||||
{
|
||||
name: t("workspace.unify.feedback_records"),
|
||||
href: `/workspaces/${workspace.id}/unify/feedback-records`,
|
||||
icon: MessageSquareTextIcon,
|
||||
isActive: pathname?.includes("/unify/feedback-records"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
name: t("common.dashboards"),
|
||||
href: `/workspaces/${workspace.id}/dashboards`,
|
||||
icon: BarChart3Icon,
|
||||
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t, workspace.id, pathname, isMembershipPending, isBilling]
|
||||
);
|
||||
|
||||
const configurationNavigationItem = useMemo(
|
||||
() => ({
|
||||
name: t("common.configuration"),
|
||||
href: `/workspaces/${workspace.id}/general`,
|
||||
icon: Cog,
|
||||
isActive:
|
||||
pathname?.includes("/general") ||
|
||||
pathname?.includes("/look") ||
|
||||
pathname?.includes("/app-connection") ||
|
||||
pathname?.includes("/feedback-sources") ||
|
||||
pathname?.includes("/integrations") ||
|
||||
pathname?.includes("/teams") ||
|
||||
pathname?.includes("/languages") ||
|
||||
pathname?.includes("/tags"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
}),
|
||||
[t, workspace.id, pathname, isMembershipPending, isBilling]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
{
|
||||
label: t("common.account"),
|
||||
@@ -256,6 +275,11 @@ export const MainNavigation = ({
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `/workspaces/${workspace.id}/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "feedback-sources",
|
||||
label: t("workspace.unify.feedback_sources"),
|
||||
href: `/workspaces/${workspace.id}/feedback-sources`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
@@ -552,23 +576,52 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
|
||||
{/* Main Nav Switch */}
|
||||
<ul>
|
||||
{mainNavigation.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{mainNavigationSections.map((section) => (
|
||||
<li key={section.id}>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<p className="px-4 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
{section.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul>
|
||||
{section.items.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
|
||||
<ul>
|
||||
<NavigationLink
|
||||
href={configurationNavigationItem.href}
|
||||
isActive={configurationNavigationItem.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={configurationNavigationItem.disabled}
|
||||
disabledMessage={
|
||||
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
|
||||
}
|
||||
linkText={configurationNavigationItem.name}>
|
||||
<configurationNavigationItem.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -118,6 +118,11 @@ export const WorkspaceBreadcrumb = ({
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `${workspaceBasePath}/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "feedback-sources",
|
||||
label: t("workspace.unify.feedback_sources"),
|
||||
href: `${workspaceBasePath}/feedback-sources`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
|
||||
@@ -21,6 +21,7 @@ export const SettingsCard = ({
|
||||
beta,
|
||||
className,
|
||||
buttonInfo,
|
||||
cta,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -30,6 +31,7 @@ export const SettingsCard = ({
|
||||
beta?: boolean;
|
||||
className?: string;
|
||||
buttonInfo?: ButtonInfo;
|
||||
cta?: React.ReactNode;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -52,11 +54,12 @@ export const SettingsCard = ({
|
||||
{description}
|
||||
</Small>
|
||||
</div>
|
||||
{buttonInfo && (
|
||||
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
|
||||
{buttonInfo?.text}
|
||||
</Button>
|
||||
)}
|
||||
{cta ??
|
||||
(buttonInfo && (
|
||||
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
|
||||
{buttonInfo?.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
|
||||
</div>
|
||||
|
||||
+12
-2
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface UnifyConfigNavigationProps {
|
||||
@@ -17,15 +18,24 @@ export const UnifyConfigNavigation = ({
|
||||
const { t } = useTranslation();
|
||||
const baseHref = `/workspaces/${workspaceId}/unify`;
|
||||
|
||||
const activeId = activeIdProp ?? "sources";
|
||||
const activeId = activeIdProp ?? "feedback-records";
|
||||
|
||||
const navigation = [
|
||||
{ id: "sources", label: t("workspace.unify.sources"), href: `${baseHref}/sources` },
|
||||
{
|
||||
id: "feedback-records",
|
||||
label: t("workspace.unify.feedback_records"),
|
||||
href: `${baseHref}/feedback-records`,
|
||||
},
|
||||
{
|
||||
id: "topics-subtopics",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{t("workspace.unify.topics_and_subtopics")}
|
||||
<Badge text={t("common.soon")} type="gray" size="tiny" />
|
||||
</span>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
|
||||
@@ -0,0 +1,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+11
-11
@@ -9,33 +9,33 @@ import { FeedbackRecordsTable } from "./feedback-records-table";
|
||||
|
||||
interface FeedbackRecordsPageClientProps {
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
initialFrdId: string | null;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
initialNextCursor?: string;
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export function FeedbackRecordsPageClient({
|
||||
workspaceId,
|
||||
directories,
|
||||
initialFrdId,
|
||||
initialRecords,
|
||||
initialNextCursor,
|
||||
}: FeedbackRecordsPageClientProps) {
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsPageClientProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("workspace.unify.unify_feedback")}>
|
||||
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
|
||||
<UnifyConfigNavigation workspaceId={workspaceId} activeId="feedback-records" />
|
||||
</PageHeader>
|
||||
|
||||
<FeedbackRecordsTable
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
initialFrdId={initialFrdId}
|
||||
initialRecords={initialRecords}
|
||||
initialNextCursor={initialNextCursor}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
+240
-151
@@ -3,13 +3,16 @@
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
HashIcon,
|
||||
MessageSquareTextIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ToggleLeftIcon,
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
|
||||
@@ -18,15 +21,16 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { CsvImportModal } from "../sources/components/csv-import-modal";
|
||||
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
|
||||
|
||||
const RECORDS_PER_PAGE = 50;
|
||||
|
||||
@@ -50,6 +54,18 @@ const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string):
|
||||
return "—";
|
||||
};
|
||||
|
||||
const formatSourceType = (sourceType: string, t: TFunction): string => {
|
||||
switch (sourceType) {
|
||||
case "formbricks":
|
||||
case "formbricks_survey":
|
||||
return t("workspace.unify.formbricks_surveys");
|
||||
case "csv":
|
||||
return t("workspace.unify.csv_import");
|
||||
default:
|
||||
return sourceType;
|
||||
}
|
||||
};
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "…";
|
||||
@@ -57,96 +73,88 @@ function truncate(str: string, maxLen: number): string {
|
||||
|
||||
interface FeedbackRecordsTableProps {
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
initialFrdId: string | null;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
initialNextCursor?: string;
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export const FeedbackRecordsTable = ({
|
||||
workspaceId,
|
||||
directories,
|
||||
initialFrdId,
|
||||
initialRecords,
|
||||
initialNextCursor,
|
||||
}: FeedbackRecordsTableProps) => {
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsTableProps>) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const [selectedFrdId, setSelectedFrdId] = useState<string | null>(initialFrdId);
|
||||
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(initialNextCursor);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit");
|
||||
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const fetchRecords = useCallback(
|
||||
async (frdId: string, cursor: string | undefined, append: boolean): Promise<string | null> => {
|
||||
const setLoading = append ? setIsLoadingMore : setIsRefreshing;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await listFeedbackRecordsAction({
|
||||
workspaceId,
|
||||
frdId,
|
||||
limit: RECORDS_PER_PAGE,
|
||||
cursor,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
const message =
|
||||
getFormattedErrorMessage(result) ?? t("workspace.unify.failed_to_load_feedback_records");
|
||||
setError(message);
|
||||
setLoading(false);
|
||||
return message;
|
||||
}
|
||||
|
||||
const response = result.data;
|
||||
setRecords((prev) => (append ? [...prev, ...response.data] : response.data));
|
||||
setNextCursor(response.next_cursor);
|
||||
setLoading(false);
|
||||
return null;
|
||||
},
|
||||
[workspaceId, t]
|
||||
const directories = useMemo(
|
||||
() =>
|
||||
Object.entries(frdMap)
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[frdMap]
|
||||
);
|
||||
const feedbackDirectoryName = useMemo(() => {
|
||||
const directoryNames = Array.from(
|
||||
new Set(
|
||||
records
|
||||
.map((record) => frdMap[record.tenant_id])
|
||||
.filter((directoryName): directoryName is string => Boolean(directoryName))
|
||||
)
|
||||
);
|
||||
|
||||
const handleFrdChange = (frdId: string) => {
|
||||
setSelectedFrdId(frdId);
|
||||
setRecords([]);
|
||||
setNextCursor(undefined);
|
||||
fetchRecords(frdId, undefined, false);
|
||||
};
|
||||
if (directoryNames.length > 0) {
|
||||
return directoryNames.join(", ");
|
||||
}
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!selectedFrdId) return;
|
||||
fetchRecords(selectedFrdId, nextCursor, true);
|
||||
};
|
||||
return directories[0]?.name ?? "—";
|
||||
}, [directories, frdMap, records]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!selectedFrdId || isRefreshing) return;
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
|
||||
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
|
||||
const errorMessage = await fetchRecords(selectedFrdId, undefined, false);
|
||||
if (errorMessage) {
|
||||
toast.error(errorMessage, { id: toastId });
|
||||
const directoryIds = Object.keys(frdMap);
|
||||
const results = await Promise.all(
|
||||
directoryIds.map((frdId) =>
|
||||
listFeedbackRecordsAction({
|
||||
workspaceId,
|
||||
frdId,
|
||||
limit: RECORDS_PER_PAGE,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const successfulRecords = results.flatMap((result) => result?.data?.data ?? []);
|
||||
|
||||
if (directoryIds.length > 0 && successfulRecords.length === 0) {
|
||||
const firstErrorResult = results.find((result) => !result?.data);
|
||||
const errorMessage = firstErrorResult ? getFormattedErrorMessage(firstErrorResult) : undefined;
|
||||
toast.error(errorMessage ?? t("workspace.unify.failed_to_load_feedback_records"), {
|
||||
id: toastId,
|
||||
});
|
||||
setIsRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedRecords = successfulRecords
|
||||
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
|
||||
.slice(0, RECORDS_PER_PAGE);
|
||||
setRecords(mergedRecords);
|
||||
setIsRefreshing(false);
|
||||
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
|
||||
};
|
||||
|
||||
const hasMore = !!nextCursor;
|
||||
const isEmpty = records.length === 0 && !isRefreshing;
|
||||
const currentFrdName = directories.find((d) => d.id === selectedFrdId)?.name ?? "—";
|
||||
|
||||
if (directories.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 p-8 text-center">
|
||||
<MessageSquareTextIcon className="mx-auto h-8 w-8 text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{t("workspace.unify.no_feedback_record_directory_available")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
@@ -161,114 +169,195 @@ export const FeedbackRecordsTable = ({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>{t("workspace.unify.feedback_record_directory")}</Label>
|
||||
{directories.length === 1 ? (
|
||||
<p className="text-sm font-medium text-slate-900">{currentFrdName}</p>
|
||||
) : (
|
||||
<Select value={selectedFrdId ?? ""} onValueChange={handleFrdChange}>
|
||||
<SelectTrigger className="min-w-[220px]">
|
||||
<SelectValue placeholder={t("workspace.unify.select_feedback_record_directory")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{directories.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
const isEmpty = records.length === 0 && !isRefreshing;
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{!isEmpty && (
|
||||
const openEditDrawer = (recordId: string) => {
|
||||
setDrawerMode("edit");
|
||||
setDrawerRecordId(recordId);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
setDrawerMode("create");
|
||||
setDrawerRecordId(undefined);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const hasCsvSources = csvSources.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{isEmpty ? (
|
||||
<span />
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.showing_count_loaded", { count: records.length })}
|
||||
{t("workspace.unify.showing_count_loaded", {
|
||||
count: records.length,
|
||||
directoryName: feedbackDirectoryName,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t("workspace.unify.refresh_feedback_records")}>
|
||||
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{canWrite &&
|
||||
(hasCsvSources ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="secondary">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={openCreateDrawer}>
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{csvSources.map((source) => (
|
||||
<DropdownMenuItem
|
||||
key={source.id}
|
||||
onClick={() => {
|
||||
setCsvImportSource(source);
|
||||
}}>
|
||||
{t("workspace.unify.import_via_source_name", { sourceName: source.name })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={openCreateDrawer}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
</Button>
|
||||
))}
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
|
||||
{t("workspace.unify.manage_feedback_sources")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t("workspace.unify.refresh_feedback_records")}>
|
||||
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
</td>
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{records.map((record) => (
|
||||
<FeedbackRecordRow key={record.id} record={record} locale={locale} t={t} />
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
</thead>
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{records.map((record) => (
|
||||
<FeedbackRecordRow
|
||||
key={record.id}
|
||||
record={record}
|
||||
workspaceId={workspaceId}
|
||||
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
|
||||
t={t}
|
||||
onClick={() => openEditDrawer(record.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="secondary" size="sm" onClick={handleLoadMore} loading={isLoadingMore}>
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
<FeedbackRecordFormDrawer
|
||||
mode={drawerMode}
|
||||
open={isDrawerOpen}
|
||||
onOpenChange={setIsDrawerOpen}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
canWrite={canWrite}
|
||||
recordId={drawerMode === "edit" ? drawerRecordId : undefined}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
|
||||
{csvImportSource && (
|
||||
<CsvImportModal
|
||||
open={csvImportSource !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCsvImportSource(null);
|
||||
}
|
||||
}}
|
||||
connectorId={csvImportSource.id}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FeedbackRecordRow = ({
|
||||
record,
|
||||
workspaceId,
|
||||
locale,
|
||||
t,
|
||||
onClick,
|
||||
}: {
|
||||
record: FeedbackRecordData;
|
||||
workspaceId: string;
|
||||
locale: string;
|
||||
t: TFunction;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const value = formatValue(record, t, locale);
|
||||
const isLongValue = value.length > 60;
|
||||
const isFormbricksSurveySource =
|
||||
(record.source_type === "formbricks" || record.source_type === "formbricks_survey") && !!record.source_id;
|
||||
const surveySummaryHref = `/workspaces/${workspaceId}/surveys/${record.source_id}/summary`;
|
||||
|
||||
return (
|
||||
<tr className="text-sm text-slate-700 transition-colors hover:bg-slate-50">
|
||||
<tr
|
||||
className="cursor-pointer text-sm text-slate-700 transition-colors hover:bg-slate-50"
|
||||
onClick={onClick}>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
|
||||
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<Badge text={record.source_type} type="gray" size="tiny" />
|
||||
<Badge text={formatSourceType(record.source_type, t)} type="gray" size="tiny" />
|
||||
</td>
|
||||
<td className="max-w-[150px] truncate px-4 py-3" title={record.source_name ?? undefined}>
|
||||
{record.source_name ?? "—"}
|
||||
{isFormbricksSurveySource ? (
|
||||
<Link
|
||||
href={surveySummaryHref}
|
||||
className="text-slate-700 underline underline-offset-2 hover:text-slate-900"
|
||||
onClick={(event) => event.stopPropagation()}>
|
||||
{record.source_name ?? "—"}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{record.source_name ?? "—"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate px-4 py-3" title={record.field_label ?? undefined}>
|
||||
{record.field_label ?? record.field_id}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { FeedbackRecordListResponse } from "@/modules/hub";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { FeedbackRecordsPageClient } from "./feedback-records-page-client";
|
||||
|
||||
const INITIAL_PAGE_SIZE = 10;
|
||||
const INITIAL_PAGE_SIZE = 50;
|
||||
|
||||
export default async function UnifyFeedbackRecordsPage(props: {
|
||||
readonly params: Promise<{ workspaceId: string }>;
|
||||
}) {
|
||||
export default async function UnifyFeedbackRecordsPage(
|
||||
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
|
||||
) {
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
@@ -22,31 +22,40 @@ export default async function UnifyFeedbackRecordsPage(props: {
|
||||
}
|
||||
|
||||
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
|
||||
const canWrite = isOwner || isManager || hasReadWriteAccess || hasManageAccess;
|
||||
if (!hasAccess) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId);
|
||||
const [frds, connectors] = await Promise.all([
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
]);
|
||||
|
||||
// Preload first FRD's records server-side for fast initial render
|
||||
const initialFrdId = frds[0]?.id;
|
||||
let initialRecords: FeedbackRecordListResponse | null = null;
|
||||
const results = await Promise.all(
|
||||
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
|
||||
);
|
||||
|
||||
if (initialFrdId) {
|
||||
const result = await listFeedbackRecords({ tenant_id: initialFrdId, limit: INITIAL_PAGE_SIZE });
|
||||
// Don't crash if Hub is down — show empty state
|
||||
if (!result.error) {
|
||||
initialRecords = result.data;
|
||||
}
|
||||
}
|
||||
// Don't crash if Hub is unreachable — show empty state
|
||||
const successfulResults = results.filter((r) => !r.error);
|
||||
|
||||
const merged = successfulResults
|
||||
.flatMap((r) => r.data?.data ?? [])
|
||||
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
|
||||
.slice(0, INITIAL_PAGE_SIZE);
|
||||
|
||||
const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name]));
|
||||
const csvSources = connectors
|
||||
.filter((connector) => connector.type === "csv")
|
||||
.map((connector) => ({ id: connector.id, name: connector.name }));
|
||||
|
||||
return (
|
||||
<FeedbackRecordsPageClient
|
||||
workspaceId={params.workspaceId}
|
||||
directories={frds}
|
||||
initialFrdId={initialFrdId ?? null}
|
||||
initialRecords={initialRecords?.data ?? []}
|
||||
initialNextCursor={initialRecords?.next_cursor}
|
||||
initialRecords={merged}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UnifyPage(props: { params: Promise<{ workspaceId: string }> }) {
|
||||
const params = await props.params;
|
||||
redirect(`/workspaces/${params.workspaceId}/unify/sources`);
|
||||
redirect(`/workspaces/${params.workspaceId}/unify/feedback-records`);
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
|
||||
import { TConnectorType } from "@formbricks/types/connector";
|
||||
|
||||
export const getConnectorIcon = (type: TConnectorType, className: string) => {
|
||||
switch (type) {
|
||||
case "formbricks_survey":
|
||||
return <FormIcon className={className} />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className={className} />;
|
||||
default:
|
||||
return <FormIcon className={className} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const getConnectorTypeLabelKey = (type: TConnectorType): string => {
|
||||
switch (type) {
|
||||
case "formbricks_survey":
|
||||
return "workspace.unify.formbricks_surveys";
|
||||
case "csv":
|
||||
return "workspace.unify.csv_import";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { FEEDBACK_RECORD_FIELDS, TFieldMapping } from "../types";
|
||||
|
||||
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
|
||||
|
||||
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
|
||||
const requiredFieldIds = new Set(
|
||||
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
|
||||
);
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!requiredFieldIds.has(mapping.targetFieldId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mapping.sourceFieldId || mapping.staticValue) {
|
||||
requiredFieldIds.delete(mapping.targetFieldId);
|
||||
}
|
||||
}
|
||||
|
||||
return requiredFieldIds.size === 0;
|
||||
};
|
||||
+24
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
FileSpreadsheetIcon,
|
||||
MoreVertical,
|
||||
PauseIcon,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
SquarePenIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
@@ -39,12 +41,15 @@ export function ConnectorRowDropdown({
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: ConnectorRowDropdownProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const isActive = connector.status === "active";
|
||||
const linkedSurveyId =
|
||||
connector.type === "formbricks_survey" ? connector.formbricksMappings[0]?.surveyId : undefined;
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
@@ -89,6 +94,25 @@ export function ConnectorRowDropdown({
|
||||
</>
|
||||
)}
|
||||
|
||||
{linkedSurveyId && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
router.push(`/workspaces/${connector.workspaceId}/surveys/${linkedSurveyId}/summary`);
|
||||
}}>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
{`${t("common.view")} ${t("common.survey")}`}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+44
-18
@@ -1,56 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorType } from "@formbricks/types/connector";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { getConnectorOptions } from "../utils";
|
||||
import { TConnectorOptionId, getConnectorOptions } from "../utils";
|
||||
|
||||
interface ConnectorTypeSelectorProps {
|
||||
selectedType: TConnectorType | null;
|
||||
onSelectType: (type: TConnectorType) => void;
|
||||
selectedType: TConnectorOptionId | null;
|
||||
onSelectType: (type: TConnectorOptionId) => void;
|
||||
}
|
||||
|
||||
export function ConnectorTypeSelector({ selectedType, onSelectType }: ConnectorTypeSelectorProps) {
|
||||
const getOptionClassName = (
|
||||
selectedType: TConnectorOptionId | null,
|
||||
optionId: TConnectorOptionId,
|
||||
disabled: boolean
|
||||
): string => {
|
||||
if (selectedType === optionId) {
|
||||
return "border-brand-dark bg-slate-50";
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60";
|
||||
}
|
||||
|
||||
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
|
||||
};
|
||||
|
||||
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
|
||||
const { t } = useTranslation();
|
||||
const connectorOptions = getConnectorOptions(t);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-600">{t("workspace.unify.select_source_type_prompt")}</p>
|
||||
<div className="space-y-2">
|
||||
{connectorOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => onSelectType(option.id as TConnectorType)}
|
||||
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
|
||||
selectedType === option.id
|
||||
? "border-brand-dark bg-slate-50"
|
||||
: option.disabled
|
||||
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
|
||||
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
|
||||
}`}>
|
||||
onClick={() => onSelectType(option.id)}
|
||||
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
|
||||
selectedType,
|
||||
option.id,
|
||||
option.disabled
|
||||
)}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{option.name}</span>
|
||||
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
|
||||
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`ml-4 h-5 w-5 rounded-full border-2 ${
|
||||
className={`ml-3 h-4 w-4 rounded-full border-2 ${
|
||||
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
|
||||
}`}>
|
||||
{selectedType === option.id && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-2 w-2 rounded-full bg-white" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Alert variant="outbound" size="small">
|
||||
<AlertTitle>{t("workspace.unify.missing_feedback_source_title")}</AlertTitle>
|
||||
<AlertButton asChild>
|
||||
<Link
|
||||
href="https://app.formbricks.com/s/cmob8tub9s2ndu5010ei4it0g"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-slate-900 hover:underline">
|
||||
{t("workspace.unify.request_feedback_source")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+47
-20
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorType, TConnectorWithMappings, THubTargetField } from "@formbricks/types/connector";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import {
|
||||
createConnectorWithMappingsAction,
|
||||
deleteConnectorAction,
|
||||
@@ -12,9 +14,10 @@ import {
|
||||
updateConnectorWithMappingsAction,
|
||||
} from "@/lib/connector/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||
import { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
|
||||
import { TFieldMapping, TUnifySurvey } from "../types";
|
||||
import { ConnectorsTable } from "./connectors-table";
|
||||
import { CreateConnectorModal } from "./create-connector-modal";
|
||||
@@ -33,12 +36,21 @@ export function ConnectorsSection({
|
||||
initialConnectors,
|
||||
initialSurveys,
|
||||
directories,
|
||||
}: ConnectorsSectionProps) {
|
||||
}: Readonly<ConnectorsSectionProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingConnector, setEditingConnector] = useState<TConnectorWithMappings | null>(null);
|
||||
const [csvImportConnector, setCsvImportConnector] = useState<TConnectorWithMappings | null>(null);
|
||||
const directoryNames = directories.map((directory) => directory.name).join(", ");
|
||||
const feedbackDirectoryAccessText =
|
||||
directories.length === 1
|
||||
? t("workspace.unify.feedback_sources_directory_access_single", {
|
||||
directoryNames,
|
||||
})
|
||||
: t("workspace.unify.feedback_sources_directory_access_multiple", {
|
||||
directoryNames,
|
||||
});
|
||||
|
||||
const handleCreateConnector = async (data: {
|
||||
name: string;
|
||||
@@ -55,9 +67,9 @@ export function ConnectorsSection({
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
},
|
||||
formbricksMappings:
|
||||
data.type === "formbricks" && data.surveyMappings?.length ? data.surveyMappings : undefined,
|
||||
data.type === "formbricks_survey" && data.surveyMappings?.length ? data.surveyMappings : undefined,
|
||||
fieldMappings:
|
||||
data.type !== "formbricks" && data.fieldMappings?.length
|
||||
data.type !== "formbricks_survey" && data.fieldMappings?.length
|
||||
? data.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId || "",
|
||||
targetFieldId: m.targetFieldId as THubTargetField,
|
||||
@@ -154,22 +166,18 @@ export function ConnectorsSection({
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
pageTitle={t("workspace.unify.unify_feedback")}
|
||||
cta={
|
||||
<CreateConnectorModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
onCreateConnector={handleCreateConnector}
|
||||
surveys={initialSurveys}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
/>
|
||||
}>
|
||||
<UnifyConfigNavigation workspaceId={workspaceId} activeId="sources" />
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<WorkspaceConfigNavigation activeId="feedback-sources" />
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<SettingsCard
|
||||
title={t("workspace.unify.feedback_sources")}
|
||||
description={t("workspace.unify.feedback_sources_settings_description")}
|
||||
buttonInfo={{
|
||||
text: t("workspace.unify.add_source"),
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
variant: "default",
|
||||
}}>
|
||||
<ConnectorsTable
|
||||
connectors={initialConnectors}
|
||||
onConnectorClick={setEditingConnector}
|
||||
@@ -179,7 +187,27 @@ export function ConnectorsSection({
|
||||
onDelete={handleDeleteConnector}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
{directories.length > 0 && (
|
||||
<Alert size="small" className="mt-4">
|
||||
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<CreateConnectorModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
onCreateConnector={handleCreateConnector}
|
||||
surveys={initialSurveys}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
showTrigger={false}
|
||||
/>
|
||||
|
||||
<EditConnectorModal
|
||||
connector={editingConnector}
|
||||
@@ -187,7 +215,6 @@ export function ConnectorsSection({
|
||||
onOpenChange={(open) => !open && setEditingConnector(null)}
|
||||
onUpdateConnector={handleUpdateConnector}
|
||||
surveys={initialSurveys}
|
||||
directories={directories}
|
||||
onOpenCsvImport={() => {
|
||||
if (editingConnector) {
|
||||
setCsvImportConnector(editingConnector);
|
||||
|
||||
+23
-35
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorStatus, TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
|
||||
import { ConnectorRowDropdown } from "./connector-row-dropdown";
|
||||
|
||||
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
|
||||
@@ -39,17 +39,6 @@ interface ConnectorsTableDataRowProps {
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getConnectorIcon(type: TConnectorType) {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return <FormIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
|
||||
default:
|
||||
return <FormIcon className="h-4 w-4 text-slate-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
|
||||
active: "success",
|
||||
paused: "warning",
|
||||
@@ -63,13 +52,24 @@ export function ConnectorsTableDataRow({
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: ConnectorsTableDataRowProps) {
|
||||
}: Readonly<ConnectorsTableDataRowProps>) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const handleRowClick = () => {
|
||||
if (connector.type === "csv" && onCsvImport) {
|
||||
onCsvImport();
|
||||
return;
|
||||
}
|
||||
|
||||
const getStatusLabel = (s: TConnectorStatus) => {
|
||||
onEdit();
|
||||
};
|
||||
|
||||
const getStatusLabel = (s: TConnectorStatus, connectorType: TConnectorType) => {
|
||||
switch (s) {
|
||||
case "active":
|
||||
return t("workspace.unify.status_active");
|
||||
if (connectorType === "csv") {
|
||||
return t("workspace.unify.status_ready");
|
||||
}
|
||||
return t("workspace.unify.status_live_sync");
|
||||
case "paused":
|
||||
return t("workspace.unify.status_paused");
|
||||
case "error":
|
||||
@@ -77,44 +77,32 @@ export function ConnectorsTableDataRow({
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectorTypeLabel = (connectorType: TConnectorType) => {
|
||||
switch (connectorType) {
|
||||
case "formbricks":
|
||||
return t("workspace.unify.formbricks_surveys");
|
||||
case "csv":
|
||||
return t("workspace.unify.csv_import");
|
||||
default:
|
||||
return connectorType;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
|
||||
onClick={onEdit}
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onEdit();
|
||||
handleRowClick();
|
||||
}
|
||||
}}>
|
||||
<div className="col-span-1 flex items-center gap-2 pl-4" title={getConnectorTypeLabel(connector.type)}>
|
||||
{getConnectorIcon(connector.type)}
|
||||
<div
|
||||
className="col-span-1 flex items-center gap-2 pl-4"
|
||||
title={t(getConnectorTypeLabelKey(connector.type))}>
|
||||
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
|
||||
</div>
|
||||
<div className="col-span-3 flex items-center">
|
||||
<div className="col-span-5 flex items-center">
|
||||
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
|
||||
</div>
|
||||
<div className="col-span-1 hidden items-center justify-center sm:flex">
|
||||
<Badge
|
||||
text={getStatusLabel(connector.status)}
|
||||
text={getStatusLabel(connector.status, connector.type)}
|
||||
type={STATUS_BADGE_TYPE[connector.status]}
|
||||
size="tiny"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||
{getRelativeTime(connector.createdAt, i18n.language)}
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||
{getRelativeTime(connector.updatedAt, i18n.language)}
|
||||
</div>
|
||||
|
||||
+2
-3
@@ -23,16 +23,15 @@ export function ConnectorsTable({
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
}: ConnectorsTableProps) {
|
||||
}: Readonly<ConnectorsTableProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">{t("common.type")}</div>
|
||||
<div className="col-span-3">{t("common.name")}</div>
|
||||
<div className="col-span-5">{t("common.name")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.created")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
|
||||
<div className="col-span-1" />
|
||||
|
||||
+305
-317
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2Icon, PlusIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TConnectorType, UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
|
||||
import {
|
||||
getResponseCountAction,
|
||||
@@ -21,8 +25,15 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,21 +41,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { TCreateConnectorStep, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
|
||||
import {
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
TCreateConnectorStep,
|
||||
TFieldMapping,
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
} from "../types";
|
||||
import { TEnumValidationError, parseCSVColumnsToFields, validateEnumMappings } from "../utils";
|
||||
TConnectorOptionId,
|
||||
TEnumValidationError,
|
||||
parseCSVColumnsToFields,
|
||||
validateEnumMappings,
|
||||
} from "../utils";
|
||||
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
|
||||
import { ConnectorTypeSelector } from "./connector-type-selector";
|
||||
import { CsvConnectorUI } from "./csv-connector-ui";
|
||||
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
import { FormbricksQuestionList } from "./formbricks-question-list";
|
||||
|
||||
interface CreateConnectorModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
showTrigger?: boolean;
|
||||
onCreateConnector: (data: {
|
||||
name: string;
|
||||
type: TConnectorType;
|
||||
@@ -59,139 +72,105 @@ interface CreateConnectorModalProps {
|
||||
|
||||
const getDialogTitle = (
|
||||
step: TCreateConnectorStep,
|
||||
type: TConnectorType | null,
|
||||
type: TConnectorOptionId | null,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (step === "selectType") return t("workspace.unify.add_feedback_source");
|
||||
if (type === "formbricks") return t("workspace.unify.select_survey_and_questions");
|
||||
if (type === "formbricks_survey") return t("workspace.unify.select_survey_and_questions");
|
||||
if (type === "csv") return t("workspace.unify.import_csv_data");
|
||||
return t("workspace.unify.configure_mapping");
|
||||
};
|
||||
|
||||
const getDialogDescription = (
|
||||
step: TCreateConnectorStep,
|
||||
type: TConnectorType | null,
|
||||
type: TConnectorOptionId | null,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (step === "selectType") return t("workspace.unify.select_source_type_description");
|
||||
if (type === "formbricks") return t("workspace.unify.select_survey_questions_description");
|
||||
if (type === "formbricks_survey") return t("workspace.unify.select_survey_questions_description");
|
||||
if (type === "csv") return t("workspace.unify.upload_csv_data_description");
|
||||
return t("workspace.unify.configure_mapping");
|
||||
};
|
||||
|
||||
const getNextStepButtonLabel = (type: TConnectorType | null, t: (key: string) => string): string => {
|
||||
if (type === "formbricks") return t("workspace.unify.select_questions");
|
||||
const getNextStepButtonLabel = (type: TConnectorOptionId | null, t: (key: string) => string): string => {
|
||||
if (type === "formbricks_survey") return t("workspace.unify.select_questions");
|
||||
if (type === "csv") return t("workspace.unify.configure_import");
|
||||
if (type === "api_ingestion") return t("workspace.unify.api_ingestion_manage_api_keys");
|
||||
if (type === "feedback_record_mcp") return t("common.learn_more");
|
||||
return t("workspace.unify.create_mapping");
|
||||
};
|
||||
|
||||
const getCreateDisabled = (
|
||||
type: TConnectorType | null,
|
||||
isFormbricksValid: boolean,
|
||||
isCsvValid: boolean,
|
||||
allRequiredMapped: boolean
|
||||
): boolean => {
|
||||
if (type === "formbricks") return !isFormbricksValid;
|
||||
if (type === "csv") return !isCsvValid || !allRequiredMapped;
|
||||
return !allRequiredMapped;
|
||||
};
|
||||
const ZFormbricksConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
|
||||
interface AggregateImportSectionProps {
|
||||
surveyEntries: {
|
||||
surveyId: string;
|
||||
surveyName: string;
|
||||
responseCount: number;
|
||||
elementCount: number;
|
||||
importHistorical: boolean;
|
||||
}[];
|
||||
onImportHistoricalChange: (surveyId: string, checked: boolean) => void;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
|
||||
|
||||
const AggregateImportSection = ({
|
||||
surveyEntries,
|
||||
onImportHistoricalChange,
|
||||
t,
|
||||
}: AggregateImportSectionProps) => {
|
||||
const totalRecords = surveyEntries.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
|
||||
const checkedCount = surveyEntries.filter((e) => e.importHistorical).length;
|
||||
|
||||
const checkedTotal = surveyEntries
|
||||
.filter((e) => e.importHistorical)
|
||||
.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="space-y-2">
|
||||
{surveyEntries.map((entry) => (
|
||||
<label key={entry.surveyId} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={entry.importHistorical}
|
||||
onChange={(e) => onImportHistoricalChange(entry.surveyId, e.target.checked)}
|
||||
className="h-4 w-4 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-xs text-amber-800">
|
||||
{t("workspace.unify.survey_import_line", {
|
||||
surveyName: entry.surveyName,
|
||||
responseCount: entry.responseCount,
|
||||
questionCount: entry.elementCount,
|
||||
total: entry.responseCount * entry.elementCount,
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{surveyEntries.length > 1 && (
|
||||
<p className="mt-3 border-t border-amber-200 pt-2 text-xs font-medium text-amber-900">
|
||||
{t("workspace.unify.total_feedback_records", {
|
||||
checked: checkedTotal,
|
||||
total: totalRecords,
|
||||
surveyCount: checkedCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const getSelectableQuestionIds = (survey: TUnifySurvey): string[] =>
|
||||
survey.elements
|
||||
.filter((element) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(element.type))
|
||||
.map((element) => element.id);
|
||||
|
||||
export const CreateConnectorModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
showTrigger = true,
|
||||
onCreateConnector,
|
||||
surveys,
|
||||
workspaceId,
|
||||
directories,
|
||||
}: CreateConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const defaultConnectorName = useMemo<Record<TConnectorType, string>>(
|
||||
() => ({
|
||||
formbricks_survey: t("workspace.unify.default_connector_name_formbricks"),
|
||||
csv: t("workspace.unify.default_connector_name_csv"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formbricksForm = useForm<TFormbricksConnectorForm>({
|
||||
resolver: zodResolver(ZFormbricksConnectorForm),
|
||||
defaultValues: {
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const defaultConnectorName: Record<TConnectorType, string> = {
|
||||
formbricks: t("workspace.unify.default_connector_name_formbricks"),
|
||||
csv: t("workspace.unify.default_connector_name_csv"),
|
||||
};
|
||||
const [currentStep, setCurrentStep] = useState<TCreateConnectorStep>("selectType");
|
||||
const [selectedType, setSelectedType] = useState<TConnectorType | null>(null);
|
||||
const [connectorName, setConnectorName] = useState("");
|
||||
const [selectedType, setSelectedType] = useState<TConnectorOptionId | null>(null);
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
|
||||
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
|
||||
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
|
||||
|
||||
const [csvParsedData, setCsvParsedData] = useState<Record<string, string>[]>([]);
|
||||
|
||||
const [enumValidationErrors, setEnumValidationErrors] = useState<TEnumValidationError[]>([]);
|
||||
|
||||
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
|
||||
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
const [responseCountBySurvey, setResponseCountBySurvey] = useState<Record<string, number | null>>({});
|
||||
const [importHistoricalBySurvey, setImportHistoricalBySurvey] = useState<Record<string, boolean>>({});
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(
|
||||
directories.length === 1 ? directories[0].id : null
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories[0]?.id ?? null);
|
||||
|
||||
const formbricksValues = formbricksForm.watch();
|
||||
const selectedSurveyId = formbricksValues.surveyId;
|
||||
const selectedQuestionIds = formbricksValues.selectedQuestionIds ?? [];
|
||||
|
||||
const selectedSurvey = useMemo(
|
||||
() => surveys.find((survey) => survey.id === selectedSurveyId) ?? null,
|
||||
[surveys, selectedSurveyId]
|
||||
);
|
||||
|
||||
const selectedSurveyResponseCount =
|
||||
selectedSurveyId && responseCountBySurvey[selectedSurveyId] !== undefined
|
||||
? responseCountBySurvey[selectedSurveyId]
|
||||
: null;
|
||||
|
||||
const fetchResponseCount = useCallback(
|
||||
async (surveyId: string) => {
|
||||
if (responseCountBySurvey[surveyId] !== undefined) return;
|
||||
@@ -204,30 +183,50 @@ export const CreateConnectorModal = ({
|
||||
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: null }));
|
||||
}
|
||||
},
|
||||
[workspaceId, responseCountBySurvey]
|
||||
[responseCountBySurvey, workspaceId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSurveyId && selectedType === "formbricks") {
|
||||
if (selectedSurveyId && currentStep === "mapping" && selectedType === "formbricks_survey") {
|
||||
fetchResponseCount(selectedSurveyId);
|
||||
}
|
||||
}, [selectedSurveyId, selectedType, fetchResponseCount]);
|
||||
}, [currentStep, fetchResponseCount, selectedSurveyId, selectedType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep !== "mapping" || selectedType !== "formbricks_survey" || !selectedSurveyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const survey = surveys.find((item) => item.id === selectedSurveyId);
|
||||
const supportedElementIds = survey ? getSelectableQuestionIds(survey) : [];
|
||||
|
||||
formbricksForm.setValue("selectedQuestionIds", supportedElementIds, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
formbricksForm.setValue("importHistorical", true, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}, [currentStep, formbricksForm, selectedSurveyId, selectedType, surveys]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCurrentStep("selectType");
|
||||
setSelectedType(null);
|
||||
setConnectorName("");
|
||||
formbricksForm.reset({
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setCsvParsedData([]);
|
||||
setEnumValidationErrors([]);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
setResponseCountBySurvey({});
|
||||
setImportHistoricalBySurvey({});
|
||||
setCsvConnectorName("");
|
||||
setIsImporting(false);
|
||||
setIsCreating(false);
|
||||
setSelectedDirectoryId(directories.length === 1 ? directories[0].id : null);
|
||||
setSelectedDirectoryId(directories[0]?.id ?? null);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
@@ -239,50 +238,31 @@ export const CreateConnectorModal = ({
|
||||
const handleNextStep = () => {
|
||||
if (currentStep !== "selectType" || !selectedType) return;
|
||||
|
||||
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
|
||||
setConnectorName(
|
||||
selectedType === "formbricks" && selectedSurvey
|
||||
? `${selectedSurvey.name} ${t("workspace.unify.connection")}`
|
||||
: defaultConnectorName[selectedType]
|
||||
);
|
||||
setCurrentStep("mapping");
|
||||
};
|
||||
|
||||
const handleSurveySelect = (surveyId: string | null) => {
|
||||
setSelectedSurveyId(surveyId);
|
||||
};
|
||||
|
||||
const handleElementToggle = (elementId: string) => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => {
|
||||
const current = prev[selectedSurveyId] ?? [];
|
||||
return {
|
||||
...prev,
|
||||
[selectedSurveyId]: current.includes(elementId)
|
||||
? current.filter((id) => id !== elementId)
|
||||
: [...current, elementId],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAllElements = (surveyId: string) => {
|
||||
const survey = surveys.find((s) => s.id === surveyId);
|
||||
if (survey) {
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[surveyId]: survey.elements
|
||||
.filter((e) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(e.type))
|
||||
.map((e) => e.id),
|
||||
}));
|
||||
if (selectedType === "api_ingestion") {
|
||||
handleOpenChange(false);
|
||||
router.push(`/workspaces/${workspaceId}/settings/api-keys`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselectAllElements = () => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[selectedSurveyId]: [],
|
||||
}));
|
||||
if (selectedType === "feedback_record_mcp") {
|
||||
window.open("https://formbricks.com/docs", "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedType === "formbricks_survey") {
|
||||
formbricksForm.reset({
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedType === "csv") {
|
||||
setCsvConnectorName(defaultConnectorName.csv);
|
||||
}
|
||||
|
||||
setCurrentStep("mapping");
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -290,52 +270,31 @@ export const CreateConnectorModal = ({
|
||||
setCurrentStep("selectType");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setEnumValidationErrors([]);
|
||||
}
|
||||
};
|
||||
|
||||
const getSurveyMappings = () =>
|
||||
Object.entries(elementIdsBySurvey)
|
||||
.filter(([, ids]) => ids.length > 0)
|
||||
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
|
||||
|
||||
const handleHistoricalImports = async (connectorId: string) => {
|
||||
const surveysToImport = Object.entries(importHistoricalBySurvey)
|
||||
.filter(([surveyId, checked]) => checked && (elementIdsBySurvey[surveyId]?.length ?? 0) > 0)
|
||||
.map(([surveyId]) => surveyId);
|
||||
|
||||
if (surveysToImport.length === 0) return;
|
||||
|
||||
const handleHistoricalImport = async (connectorId: string, surveyId: string) => {
|
||||
const responseCount = responseCountBySurvey[surveyId] ?? 0;
|
||||
if (responseCount <= 0) return;
|
||||
setIsImporting(true);
|
||||
let totalSuccesses = 0;
|
||||
let totalFailures = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const surveyId of surveysToImport) {
|
||||
const importResult = await importHistoricalResponsesAction({
|
||||
connectorId,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
});
|
||||
|
||||
if (importResult?.data) {
|
||||
totalSuccesses += importResult.data.successes;
|
||||
totalFailures += importResult.data.failures;
|
||||
totalSkipped += importResult.data.skipped;
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(importResult));
|
||||
}
|
||||
}
|
||||
|
||||
const importResult = await importHistoricalResponsesAction({
|
||||
connectorId,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
});
|
||||
setIsImporting(false);
|
||||
|
||||
if (totalSuccesses > 0 || totalFailures > 0) {
|
||||
if (importResult?.data) {
|
||||
toast.success(
|
||||
t("workspace.unify.historical_import_complete", {
|
||||
successes: totalSuccesses,
|
||||
failures: totalFailures,
|
||||
skipped: totalSkipped,
|
||||
successes: importResult.data.successes,
|
||||
failures: importResult.data.failures,
|
||||
skipped: importResult.data.skipped,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(importResult));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -361,10 +320,41 @@ export const CreateConnectorModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!selectedType || !connectorName.trim() || !selectedDirectoryId) return;
|
||||
const handleFormbricksQuestionToggle = (questionId: string) => {
|
||||
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
|
||||
const isSelected = currentSelection.includes(questionId);
|
||||
const nextSelection = isSelected
|
||||
? currentSelection.filter((id) => id !== questionId)
|
||||
: [...currentSelection, questionId];
|
||||
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedType === "csv" && csvParsedData.length > 0) {
|
||||
const handleCreateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
|
||||
if (!selectedDirectoryId) return;
|
||||
setIsCreating(true);
|
||||
|
||||
const connectorId = await onCreateConnector({
|
||||
name: values.sourceName.trim(),
|
||||
type: "formbricks_survey",
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
|
||||
});
|
||||
|
||||
if (connectorId && values.importHistorical) {
|
||||
await handleHistoricalImport(connectorId, values.surveyId);
|
||||
}
|
||||
|
||||
setIsCreating(false);
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCreateCsvConnector = async () => {
|
||||
if (!selectedDirectoryId || !isConnectorNameValid(csvConnectorName)) return;
|
||||
if (csvParsedData.length > 0) {
|
||||
const errors = validateEnumMappings(mappings, csvParsedData);
|
||||
if (errors.length > 0) {
|
||||
setEnumValidationErrors(errors);
|
||||
@@ -375,21 +365,14 @@ export const CreateConnectorModal = ({
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
const surveyMappings = getSurveyMappings();
|
||||
|
||||
const connectorId = await onCreateConnector({
|
||||
name: connectorName.trim(),
|
||||
type: selectedType,
|
||||
name: csvConnectorName.trim(),
|
||||
type: "csv",
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
surveyMappings: selectedType === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
|
||||
fieldMappings: selectedType !== "formbricks" && mappings.length > 0 ? mappings : undefined,
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
|
||||
if (connectorId && selectedType === "formbricks") {
|
||||
await handleHistoricalImports(connectorId);
|
||||
}
|
||||
|
||||
if (connectorId && selectedType === "csv" && csvParsedData.length > 0) {
|
||||
if (connectorId && csvParsedData.length > 0) {
|
||||
await handleCsvImport(connectorId);
|
||||
}
|
||||
|
||||
@@ -398,14 +381,8 @@ export const CreateConnectorModal = ({
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const allRequiredMapped = requiredFields.every((field) =>
|
||||
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
|
||||
);
|
||||
|
||||
const hasAnyElementSelections = Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
|
||||
const isFormbricksValid = selectedType === "formbricks" && hasAnyElementSelections;
|
||||
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
|
||||
const areCsvRequiredFieldsMapped = areAllRequiredFieldsMapped(mappings);
|
||||
|
||||
const handleLoadSourceFields = () => {
|
||||
if (selectedType === "csv") {
|
||||
@@ -416,10 +393,12 @@ export const CreateConnectorModal = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||
{t("workspace.unify.add_source")}
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
{showTrigger && (
|
||||
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||
{t("workspace.unify.add_source")}
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
@@ -444,86 +423,118 @@ export const CreateConnectorModal = ({
|
||||
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "formbricks" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
|
||||
<Input
|
||||
id="connectorName"
|
||||
value={connectorName}
|
||||
onChange={(e) => setConnectorName(e.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FrdPicker
|
||||
directories={directories}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onChange={setSelectedDirectoryId}
|
||||
workspaceId={workspaceId}
|
||||
t={t}
|
||||
/>
|
||||
{directories.length === 0 && (
|
||||
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<FormbricksSurveySelector
|
||||
surveys={surveys}
|
||||
selectedSurveyId={selectedSurveyId}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSurveySelect={handleSurveySelect}
|
||||
onElementToggle={handleElementToggle}
|
||||
onSelectAllElements={handleSelectAllElements}
|
||||
onDeselectAllElements={handleDeselectAllElements}
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("workspace.unify.select_survey")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((survey) => (
|
||||
<SelectItem key={survey.id} value={survey.id}>
|
||||
{survey.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const entries = Object.entries(elementIdsBySurvey)
|
||||
.filter(([, ids]) => ids.length > 0)
|
||||
.map(([surveyId, ids]) => ({
|
||||
surveyId,
|
||||
surveyName: surveys.find((s) => s.id === surveyId)?.name ?? surveyId,
|
||||
responseCount: responseCountBySurvey[surveyId] ?? 0,
|
||||
elementCount: ids.length,
|
||||
importHistorical: importHistoricalBySurvey[surveyId] ?? false,
|
||||
}))
|
||||
.filter((e) => e.responseCount > 0);
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<FormbricksQuestionList
|
||||
survey={selectedSurvey}
|
||||
selectedQuestionIds={selectedQuestionIds}
|
||||
onQuestionToggle={handleFormbricksQuestionToggle}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<AggregateImportSection
|
||||
surveyEntries={entries}
|
||||
onImportHistoricalChange={(surveyId, checked) => {
|
||||
setImportHistoricalBySurvey((prev) => ({ ...prev, [surveyId]: checked }));
|
||||
}}
|
||||
t={t}
|
||||
{selectedSurveyResponseCount !== null && selectedSurveyResponseCount > 0 && (
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="importHistorical"
|
||||
render={({ field }) => (
|
||||
<FormItem className="rounded-md border border-slate-200 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>{t("workspace.unify.import_historical_responses")}</FormLabel>
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.import_historical_responses_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "csv" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
|
||||
<label htmlFor="connectorName" className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.source_name")}
|
||||
</label>
|
||||
<Input
|
||||
id="connectorName"
|
||||
value={connectorName}
|
||||
onChange={(e) => setConnectorName(e.target.value)}
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FrdPicker
|
||||
directories={directories}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onChange={setSelectedDirectoryId}
|
||||
workspaceId={workspaceId}
|
||||
t={t}
|
||||
/>
|
||||
{directories.length === 0 && (
|
||||
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
|
||||
)}
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<CsvConnectorUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
@@ -582,13 +593,20 @@ export const CreateConnectorModal = ({
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
onClick={
|
||||
selectedType === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleCreateFormbricksConnector)()
|
||||
: handleCreateCsvConnector
|
||||
}
|
||||
disabled={
|
||||
isCreating ||
|
||||
isImporting ||
|
||||
!connectorName.trim() ||
|
||||
!selectedDirectoryId ||
|
||||
getCreateDisabled(selectedType, !!isFormbricksValid, isCsvValid, allRequiredMapped)
|
||||
(selectedType === "formbricks_survey"
|
||||
? !isConnectorNameValid(formbricksValues.sourceName ?? "") ||
|
||||
!formbricksValues.surveyId ||
|
||||
!formbricksValues.selectedQuestionIds?.length
|
||||
: !isConnectorNameValid(csvConnectorName) || !isCsvValid || !areCsvRequiredFieldsMapped)
|
||||
}>
|
||||
{isCreating && <Loader2Icon className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("workspace.unify.setup_connection")}
|
||||
@@ -601,52 +619,22 @@ export const CreateConnectorModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface FrdPickerProps {
|
||||
directories: { id: string; name: string }[];
|
||||
selectedDirectoryId: string | null;
|
||||
onChange: (id: string) => void;
|
||||
interface NoFeedbackRecordDirectoryAlertProps {
|
||||
workspaceId: string;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const FrdPicker = ({ directories, selectedDirectoryId, onChange, workspaceId, t }: FrdPickerProps) => {
|
||||
if (directories.length === 0) {
|
||||
return (
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
|
||||
<a
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.go_to_feedback_record_directories")}
|
||||
</a>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (directories.length === 1) {
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
{t("workspace.unify.records_will_go_to")}{" "}
|
||||
<span className="font-medium text-slate-900">{directories[0].name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const NoFeedbackRecordDirectoryAlert = ({ workspaceId, t }: NoFeedbackRecordDirectoryAlertProps) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedbackRecordDirectory">{t("workspace.unify.feedback_record_directory")}</Label>
|
||||
<Select value={selectedDirectoryId ?? ""} onValueChange={onChange}>
|
||||
<SelectTrigger id="feedbackRecordDirectory">
|
||||
<SelectValue placeholder={t("workspace.unify.select_feedback_record_directory")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{directories.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
|
||||
<a
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.go_to_feedback_record_directories")}
|
||||
</a>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
+236
-166
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { z } from "zod";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,17 +15,27 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
SAMPLE_CSV_COLUMNS,
|
||||
TFieldMapping,
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
} from "../types";
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
|
||||
import { parseCSVColumnsToFields } from "../utils";
|
||||
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
|
||||
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
|
||||
import { FormbricksQuestionList } from "./formbricks-question-list";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
|
||||
interface EditConnectorModalProps {
|
||||
@@ -38,42 +50,17 @@ interface EditConnectorModalProps {
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<void>;
|
||||
surveys: TUnifySurvey[];
|
||||
directories: { id: string; name: string }[];
|
||||
onOpenCsvImport?: () => void;
|
||||
}
|
||||
|
||||
const getConnectorIcon = (type: TConnectorType) => {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className="h-5 w-5 text-slate-500" />;
|
||||
default:
|
||||
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
|
||||
}
|
||||
};
|
||||
const ZFormbricksEditConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
|
||||
const getConnectorTypeLabelKey = (type: TConnectorType): string => {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "workspace.unify.formbricks_surveys";
|
||||
case "csv":
|
||||
return "workspace.unify.csv_import";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const groupMappingsBySurvey = (
|
||||
mappings: { surveyId: string; elementId: string }[]
|
||||
): Record<string, string[]> => {
|
||||
const grouped: Record<string, string[]> = {};
|
||||
for (const m of mappings) {
|
||||
if (!grouped[m.surveyId]) grouped[m.surveyId] = [];
|
||||
grouped[m.surveyId].push(m.elementId);
|
||||
}
|
||||
return grouped;
|
||||
};
|
||||
type TFormbricksEditConnectorForm = z.infer<typeof ZFormbricksEditConnectorForm>;
|
||||
|
||||
export const EditConnectorModal = ({
|
||||
connector,
|
||||
@@ -81,35 +68,52 @@ export const EditConnectorModal = ({
|
||||
onOpenChange,
|
||||
onUpdateConnector,
|
||||
surveys,
|
||||
directories,
|
||||
onOpenCsvImport,
|
||||
}: EditConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [connectorName, setConnectorName] = useState("");
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
|
||||
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
|
||||
const formbricksForm = useForm<TFormbricksEditConnectorForm>({
|
||||
resolver: zodResolver(ZFormbricksEditConnectorForm),
|
||||
defaultValues: {
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
|
||||
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const allRequiredMapped = requiredFields.every((field) =>
|
||||
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
|
||||
const formbricksValues = formbricksForm.watch();
|
||||
const selectedSurveyId = formbricksValues.surveyId;
|
||||
const selectedQuestionIds = formbricksValues.selectedQuestionIds ?? [];
|
||||
const selectedSurvey = useMemo(
|
||||
() => surveys.find((survey) => survey.id === selectedSurveyId) ?? null,
|
||||
[surveys, selectedSurveyId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (connector) {
|
||||
setConnectorName(connector.name);
|
||||
if (connector.type === "formbricks_survey") {
|
||||
const mappedSurveyId = connector.formbricksMappings[0]?.surveyId ?? "";
|
||||
const mappedQuestionIds = connector.formbricksMappings
|
||||
.filter((mapping) => mapping.surveyId === mappedSurveyId)
|
||||
.map((mapping) => mapping.elementId);
|
||||
|
||||
if (connector.type === "formbricks") {
|
||||
const fbMappings = connector.formbricksMappings;
|
||||
setSelectedSurveyId(fbMappings.length > 0 ? fbMappings[0].surveyId : null);
|
||||
setElementIdsBySurvey(groupMappingsBySurvey(fbMappings));
|
||||
formbricksForm.reset({
|
||||
sourceName: connector.name,
|
||||
surveyId: mappedSurveyId,
|
||||
selectedQuestionIds: mappedQuestionIds,
|
||||
importHistorical: true,
|
||||
});
|
||||
setCsvConnectorName("");
|
||||
setSourceFields([]);
|
||||
setMappings([]);
|
||||
} else if (connector.type === "csv") {
|
||||
setCsvConnectorName(connector.name);
|
||||
const columnsFromMappings = [
|
||||
...new Set(connector.fieldMappings.map((m) => m.sourceFieldId).filter(Boolean)),
|
||||
];
|
||||
@@ -125,23 +129,37 @@ export const EditConnectorModal = ({
|
||||
staticValue: m.staticValue ?? undefined,
|
||||
}))
|
||||
);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
} else {
|
||||
setCsvConnectorName("");
|
||||
setSourceFields([]);
|
||||
setMappings([]);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [connector]);
|
||||
}, [connector, formbricksForm]);
|
||||
|
||||
const resetForm = () => {
|
||||
setConnectorName("");
|
||||
setCsvConnectorName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
@@ -151,76 +169,64 @@ export const EditConnectorModal = ({
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const handleSurveySelect = (surveyId: string | null) => {
|
||||
setSelectedSurveyId(surveyId);
|
||||
};
|
||||
|
||||
const handleElementToggle = (elementId: string) => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => {
|
||||
const current = prev[selectedSurveyId] ?? [];
|
||||
return {
|
||||
...prev,
|
||||
[selectedSurveyId]: current.includes(elementId)
|
||||
? current.filter((id) => id !== elementId)
|
||||
: [...current, elementId],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAllElements = (surveyId: string) => {
|
||||
const survey = surveys.find((s) => s.id === surveyId);
|
||||
if (survey) {
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[surveyId]: survey.elements.map((e) => e.id),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselectAllElements = () => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[selectedSurveyId]: [],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!connector || !connectorName.trim()) return;
|
||||
|
||||
const surveyMappings = Object.entries(elementIdsBySurvey)
|
||||
.filter(([, ids]) => ids.length > 0)
|
||||
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
|
||||
|
||||
const handleUpdateFormbricksConnector = async (values: TFormbricksEditConnectorForm) => {
|
||||
if (connector?.type !== "formbricks_survey") return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: connectorName.trim(),
|
||||
surveyMappings:
|
||||
connector.type === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
|
||||
fieldMappings: connector.type !== "formbricks" && mappings.length > 0 ? mappings : undefined,
|
||||
name: values.sourceName.trim(),
|
||||
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
|
||||
fieldMappings: undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const assignedDirectoryName =
|
||||
directories.find((d) => d.id === connector?.feedbackRecordDirectoryId)?.name ??
|
||||
connector?.feedbackRecordDirectoryId ??
|
||||
"—";
|
||||
const handleUpdateCsvConnector = async () => {
|
||||
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: csvConnectorName.trim(),
|
||||
surveyMappings: undefined,
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const saveChangesDisbaled = useMemo(() => {
|
||||
const handleFormbricksQuestionToggle = (questionId: string) => {
|
||||
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
|
||||
const isSelected = currentSelection.includes(questionId);
|
||||
const nextSelection = isSelected
|
||||
? currentSelection.filter((id) => id !== questionId)
|
||||
: [...currentSelection, questionId];
|
||||
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
const saveChangesDisabled = useMemo(() => {
|
||||
if (!connector) return true;
|
||||
if (!connectorName.trim()) return true;
|
||||
if (isUpdating) return true;
|
||||
|
||||
if (connector.type === "formbricks") {
|
||||
return !Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
|
||||
if (connector.type === "formbricks_survey") {
|
||||
return (
|
||||
!isConnectorNameValid(formbricksValues.sourceName ?? "") ||
|
||||
!formbricksValues.surveyId ||
|
||||
!formbricksValues.selectedQuestionIds?.length
|
||||
);
|
||||
}
|
||||
|
||||
if (connector.type === "csv") {
|
||||
return !allRequiredMapped;
|
||||
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredFieldsMapped(mappings);
|
||||
}
|
||||
}, [allRequiredMapped, connector, connectorName, elementIdsBySurvey]);
|
||||
|
||||
return true;
|
||||
}, [connector, csvConnectorName, formbricksValues, isUpdating, mappings]);
|
||||
|
||||
if (!connector) return null;
|
||||
|
||||
@@ -233,53 +239,111 @@ export const EditConnectorModal = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
{getConnectorIcon(connector.type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{t(getConnectorTypeLabelKey(connector.type))}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{t("workspace.unify.source_type_cannot_be_changed")}</p>
|
||||
</div>
|
||||
</div>
|
||||
{connector.type === "formbricks_survey" ? (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="editConnectorName">{t("workspace.unify.source_name")}</Label>
|
||||
<Input
|
||||
id="editConnectorName"
|
||||
value={connectorName}
|
||||
onChange={(e) => setConnectorName(e.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("workspace.unify.select_survey")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectedSurvey && (
|
||||
<SelectItem key={selectedSurvey.id} value={selectedSurvey.id}>
|
||||
{selectedSurvey.name}
|
||||
</SelectItem>
|
||||
)}
|
||||
{!selectedSurvey && field.value && (
|
||||
<SelectItem value={field.value}>{field.value}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
{t("workspace.unify.records_will_go_to")}{" "}
|
||||
<span className="font-medium text-slate-900">{assignedDirectoryName}</span>
|
||||
<p className="mt-1 text-xs text-slate-400">{t("workspace.unify.frd_cannot_be_changed")}</p>
|
||||
</div>
|
||||
|
||||
{connector.type === "formbricks" ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<FormbricksSurveySelector
|
||||
surveys={surveys}
|
||||
selectedSurveyId={selectedSurveyId}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSurveySelect={handleSurveySelect}
|
||||
onElementToggle={handleElementToggle}
|
||||
onSelectAllElements={handleSelectAllElements}
|
||||
onDeselectAllElements={handleDeselectAllElements}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<FormbricksQuestionList
|
||||
survey={selectedSurvey}
|
||||
selectedQuestionIds={selectedQuestionIds}
|
||||
onQuestionToggle={handleFormbricksQuestionToggle}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
) : (
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
connectorType={connector.type}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
{getConnectorIcon(connector.type, "h-5 w-5 text-slate-500")}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{t(getConnectorTypeLabelKey(connector.type))}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("workspace.unify.source_type_cannot_be_changed")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="editConnectorName" className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.source_name")}
|
||||
</label>
|
||||
<Input
|
||||
id="editConnectorName"
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
connectorType={connector.type}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -294,7 +358,13 @@ export const EditConnectorModal = ({
|
||||
{t("workspace.unify.import_feedback")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleUpdate} disabled={saveChangesDisbaled}>
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
|
||||
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { TUnifySurvey } from "../types";
|
||||
|
||||
interface FormbricksQuestionListProps {
|
||||
survey: TUnifySurvey | null;
|
||||
selectedQuestionIds: string[];
|
||||
onQuestionToggle: (questionId: string) => void;
|
||||
}
|
||||
|
||||
const isUnsupportedElementType = (type: string): boolean =>
|
||||
(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(type);
|
||||
|
||||
export const FormbricksQuestionList = ({
|
||||
survey,
|
||||
selectedQuestionIds,
|
||||
onQuestionToggle,
|
||||
}: Readonly<FormbricksQuestionListProps>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!survey) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-slate-300 p-3">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.select_a_survey_to_see_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (survey.elements.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-slate-300 p-3">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.survey_has_no_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto rounded-md border border-slate-200 p-3">
|
||||
{survey.elements.map((element) => {
|
||||
const unsupported = isUnsupportedElementType(element.type);
|
||||
const isChecked = selectedQuestionIds.includes(element.id);
|
||||
const elementTypeLabel = getTSurveyElementTypeEnumName(element.type, t) ?? element.type;
|
||||
const inputId = `connector-question-${element.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className={`flex items-start gap-3 rounded-md border border-slate-100 p-2 ${
|
||||
unsupported ? "opacity-60" : ""
|
||||
}`}>
|
||||
<Checkbox
|
||||
id={inputId}
|
||||
checked={!unsupported && isChecked}
|
||||
disabled={unsupported}
|
||||
onCheckedChange={() => {
|
||||
if (!unsupported) {
|
||||
onQuestionToggle(element.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={inputId} className={unsupported ? "cursor-not-allowed" : "cursor-pointer"}>
|
||||
{element.headline}
|
||||
</Label>
|
||||
<p className="text-xs text-slate-500">{elementTypeLabel}</p>
|
||||
{unsupported && (
|
||||
<p className="text-xs text-slate-500">{t("workspace.unify.question_type_not_supported")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-233
@@ -1,233 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, ChevronRightIcon, FileTextIcon, MessageSquareTextIcon, StarIcon } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { TUnifySurvey } from "../types";
|
||||
|
||||
interface FormbricksSurveySelectorProps {
|
||||
surveys: TUnifySurvey[];
|
||||
selectedSurveyId: string | null;
|
||||
selectedElementIds: string[];
|
||||
onSurveySelect: (surveyId: string | null) => void;
|
||||
onElementToggle: (elementId: string) => void;
|
||||
onSelectAllElements: (surveyId: string) => void;
|
||||
onDeselectAllElements: () => void;
|
||||
}
|
||||
|
||||
const getElementIcon = (type: TSurveyElementTypeEnum) => {
|
||||
switch (type) {
|
||||
case "openText":
|
||||
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "rating":
|
||||
case "nps":
|
||||
return <StarIcon className="h-4 w-4 text-amber-500" />;
|
||||
default:
|
||||
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const isUnsupportedType = (type: TSurveyElementTypeEnum): boolean => {
|
||||
return UNSUPPORTED_CONNECTOR_ELEMENT_TYPES.includes(type);
|
||||
};
|
||||
|
||||
export const FormbricksSurveySelector = ({
|
||||
surveys,
|
||||
selectedSurveyId,
|
||||
selectedElementIds,
|
||||
onSurveySelect,
|
||||
onElementToggle,
|
||||
onSelectAllElements,
|
||||
onDeselectAllElements,
|
||||
}: FormbricksSurveySelectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
|
||||
const supportedElements = selectedSurvey?.elements.filter((e) => !isUnsupportedType(e.type)) ?? [];
|
||||
const allSupportedSelected =
|
||||
supportedElements.length > 0 && supportedElements.every((e) => selectedElementIds.includes(e.id));
|
||||
|
||||
const handleSurveyClick = (survey: TUnifySurvey) => {
|
||||
if (selectedSurveyId !== survey.id) {
|
||||
onSurveySelect(survey.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAllSupported = (surveyId: string) => {
|
||||
onSelectAllElements(surveyId);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: TUnifySurvey["status"]) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <Badge text={t("workspace.unify.status_active")} type="success" size="tiny" />;
|
||||
case "paused":
|
||||
return <Badge text={t("workspace.unify.status_paused")} type="warning" size="tiny" />;
|
||||
case "draft":
|
||||
return <Badge text={t("workspace.unify.status_draft")} type="gray" size="tiny" />;
|
||||
case "completed":
|
||||
return <Badge text={t("workspace.unify.status_completed")} type="gray" size="tiny" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getSupportedElementCount = (survey: TUnifySurvey) =>
|
||||
survey.elements.filter((e) => !isUnsupportedType(e.type)).length;
|
||||
|
||||
const getElementButtonClassName = (unsupported: boolean, isSelected: boolean): string => {
|
||||
if (unsupported) return "cursor-not-allowed border-slate-100 bg-slate-50 opacity-50";
|
||||
if (isSelected) return "border-green-300 bg-green-50";
|
||||
return "border-slate-200 bg-white hover:border-slate-300";
|
||||
};
|
||||
|
||||
const getCheckboxClassName = (unsupported: boolean, isSelected: boolean): string => {
|
||||
if (unsupported) return "border border-slate-200 bg-slate-100";
|
||||
if (isSelected) return "bg-green-500 text-white";
|
||||
return "border border-slate-300 bg-white";
|
||||
};
|
||||
|
||||
const renderElementPanel = () => {
|
||||
if (!selectedSurvey) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.select_a_survey_to_see_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedSurvey.elements.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.survey_has_no_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 overflow-y-auto pr-1">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
{selectedSurvey.elements.map((element) => {
|
||||
const isSelected = selectedElementIds.includes(element.id);
|
||||
const unsupported = isUnsupportedType(element.type);
|
||||
|
||||
const button = (
|
||||
<button
|
||||
key={element.id}
|
||||
type="button"
|
||||
disabled={unsupported}
|
||||
onClick={() => onElementToggle(element.id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${getElementButtonClassName(unsupported, isSelected)}`}>
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded ${getCheckboxClassName(unsupported, isSelected)}`}>
|
||||
{isSelected && !unsupported && <CheckIcon className="h-3 w-3" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">{getElementIcon(element.type)}</div>
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm ${unsupported ? "text-slate-400" : "text-slate-900"}`}>
|
||||
{element.headline}
|
||||
</p>
|
||||
<span className={`text-xs ${unsupported ? "text-slate-300" : "text-slate-500"}`}>
|
||||
{getTSurveyElementTypeEnumName(element.type, t) ?? element.type}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (unsupported) {
|
||||
return (
|
||||
<Tooltip key={element.id}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>{t("workspace.unify.question_type_not_supported")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
})}
|
||||
</TooltipProvider>
|
||||
|
||||
{selectedElementIds.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
<Trans
|
||||
i18nKey={
|
||||
selectedElementIds.length === 1
|
||||
? "workspace.unify.question_selected"
|
||||
: "workspace.unify.questions_selected"
|
||||
}
|
||||
values={{ count: selectedElementIds.length }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-[50vh] grid-cols-2 gap-6">
|
||||
{/* Left: Survey List */}
|
||||
<div className="flex flex-col gap-3 overflow-hidden">
|
||||
<h4 className="shrink-0 text-sm font-medium text-slate-700">{t("workspace.unify.select_survey")}</h4>
|
||||
<div className="space-y-2 overflow-y-auto pr-1">
|
||||
{surveys.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_surveys_found")}</p>
|
||||
</div>
|
||||
) : (
|
||||
surveys.map((survey) => {
|
||||
const isSelected = selectedSurveyId === survey.id;
|
||||
|
||||
return (
|
||||
<div key={survey.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSurveyClick(survey)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border bg-white p-3 text-left transition-colors ${
|
||||
isSelected ? "border-brand-dark bg-slate-50" : "border-slate-200 hover:border-slate-300"
|
||||
}`}>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div>{getStatusBadge(survey.status)}</div>
|
||||
<span className="block truncate text-sm font-medium text-slate-900">{survey.name}</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("workspace.unify.n_supported_questions", {
|
||||
count: getSupportedElementCount(survey),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <ChevronRightIcon className="h-5 w-5 shrink-0 text-brand-dark" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Element Selection */}
|
||||
<div className="flex flex-col gap-3 overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700">{t("workspace.unify.select_questions")}</h4>
|
||||
{selectedSurvey && supportedElements.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
allSupportedSelected ? onDeselectAllElements() : handleSelectAllSupported(selectedSurvey.id)
|
||||
}
|
||||
className="text-xs text-slate-500 hover:text-slate-700">
|
||||
{allSupportedSelected ? t("workspace.unify.deselect_all") : t("workspace.unify.select_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderElementPanel()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { ConnectorsSection } from "./components/connectors-page-client";
|
||||
import { transformToUnifySurvey } from "./lib";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UnifySourcesPage(props: { params: Promise<{ workspaceId: string }> }) {
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
|
||||
await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
|
||||
if (!hasAccess) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const [connectors, surveys, directories] = await Promise.all([
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
getSurveys(params.workspaceId),
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
]);
|
||||
|
||||
const unifySurveys = surveys.map(transformToUnifySurvey);
|
||||
|
||||
return (
|
||||
<ConnectorsSection
|
||||
workspaceId={params.workspaceId}
|
||||
initialConnectors={connectors}
|
||||
initialSurveys={unifySurveys}
|
||||
directories={directories}
|
||||
/>
|
||||
);
|
||||
redirect(`/workspaces/${params.workspaceId}/feedback-sources`);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } from ".
|
||||
const mockT = (key: string) => key;
|
||||
|
||||
describe("getConnectorOptions", () => {
|
||||
test("returns formbricks and csv options", () => {
|
||||
test("returns formbricks, csv, api ingestion, and mcp options", () => {
|
||||
const options = getConnectorOptions(mockT as never);
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options[0].id).toBe("formbricks");
|
||||
expect(options).toHaveLength(4);
|
||||
expect(options[0].id).toBe("formbricks_survey");
|
||||
expect(options[1].id).toBe("csv");
|
||||
expect(options[2].id).toBe("api_ingestion");
|
||||
expect(options[3].id).toBe("feedback_record_mcp");
|
||||
});
|
||||
|
||||
test("both options are enabled by default", () => {
|
||||
@@ -23,6 +25,10 @@ describe("getConnectorOptions", () => {
|
||||
expect(options[0].description).toBe("workspace.unify.source_connect_formbricks_description");
|
||||
expect(options[1].name).toBe("workspace.unify.csv_import");
|
||||
expect(options[1].description).toBe("workspace.unify.source_connect_csv_description");
|
||||
expect(options[2].name).toBe("workspace.unify.api_ingestion");
|
||||
expect(options[2].description).toBe("workspace.unify.api_ingestion_settings_description");
|
||||
expect(options[3].name).toBe("workspace.unify.feedback_record_mcp");
|
||||
expect(options[3].description).toBe("workspace.unify.source_connect_feedback_record_mcp_description");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { THubFieldType } from "@formbricks/types/connector";
|
||||
import { TConnectorType, THubFieldType } from "@formbricks/types/connector";
|
||||
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
|
||||
|
||||
export type TConnectorOptionId = TConnectorType | "api_ingestion" | "feedback_record_mcp";
|
||||
|
||||
export interface TConnectorOption {
|
||||
id: string;
|
||||
id: TConnectorOptionId;
|
||||
name: string;
|
||||
description: string;
|
||||
disabled: boolean;
|
||||
@@ -12,7 +14,7 @@ export interface TConnectorOption {
|
||||
|
||||
export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
|
||||
{
|
||||
id: "formbricks",
|
||||
id: "formbricks_survey",
|
||||
name: t("workspace.unify.formbricks_surveys"),
|
||||
description: t("workspace.unify.source_connect_formbricks_description"),
|
||||
disabled: false,
|
||||
@@ -23,6 +25,18 @@ export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
|
||||
description: t("workspace.unify.source_connect_csv_description"),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "api_ingestion",
|
||||
name: t("workspace.unify.api_ingestion"),
|
||||
description: t("workspace.unify.api_ingestion_settings_description"),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "feedback_record_mcp",
|
||||
name: t("workspace.unify.feedback_record_mcp"),
|
||||
description: t("workspace.unify.source_connect_feedback_record_mcp_description"),
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const parseCSVColumnsToFields = (columns: string): TSourceField[] => {
|
||||
|
||||
+61
-18
@@ -98,6 +98,7 @@ checksums:
|
||||
common/activity: 1948763de8e531483a798b68195e297e
|
||||
common/add: 87c4a663507f2bcbbf79934af8164e13
|
||||
common/add_action: 66fefc4dd6a7b939c2224272cf0d2669
|
||||
common/add_chart: 0c8539d3ccc83fce87bb1e0dc3e30005
|
||||
common/add_charts: c377a42e165e8ab67bfbb8ad72026dd8
|
||||
common/add_existing_chart_description: b1292a1d6df2e03ad7b399689312c37f
|
||||
common/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
|
||||
@@ -304,6 +305,7 @@ checksums:
|
||||
common/not_authenticated: fed6c62208524ea6782b5f9c07a95a4f
|
||||
common/not_authorized: 4be80383fe1a6f52c61138f1aa8d01d4
|
||||
common/not_connected: 91ebf07fff6b2ead94d85bd17212e0ba
|
||||
common/not_set: 380482630d60ee2d1531b31246caa467
|
||||
common/note: e0337f202c911423275f834edeffc54b
|
||||
common/notifications: c52df856139b50dbb1cae7bfb1cf73bb
|
||||
common/number: 2789f8391f63e7200a5521078aab017d
|
||||
@@ -363,6 +365,7 @@ checksums:
|
||||
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
|
||||
common/request_trial_license: 560df1240ef621f7c60d3f7d65422ccd
|
||||
common/reset_to_default: 68ee98b46677392f44b505b268053b26
|
||||
common/resize: 20887e5af5294f08bc72cdedeee6e7a8
|
||||
common/response: c7a9d88269d8ff117abcbc0d97f88b2c
|
||||
common/response_id: 73375099cc976dc7203b8e27f5f709e0
|
||||
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
|
||||
@@ -404,6 +407,7 @@ checksums:
|
||||
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
|
||||
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
|
||||
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
|
||||
common/soon: b12e79beb0aef9414a445a1b95dd4322
|
||||
common/sort_by: 8adf3dbc5668379558957662f0c43563
|
||||
common/start_free_trial: e346e4ed7d138dcc873db187922369da
|
||||
common/status: 4e1fcce15854d824919b4a582c697c90
|
||||
@@ -1716,8 +1720,10 @@ checksums:
|
||||
workspace/analysis/charts/please_select_dashboard: 8f062db96f815ed8268584dd8d292fa6
|
||||
workspace/analysis/charts/predefined_measures: 7651141f62c991954edcff70899b2a8b
|
||||
workspace/analysis/charts/preset: a17bb0bf56f3326c9567be3ea896ee19
|
||||
workspace/analysis/charts/preview_chart: 8db30f87ba44165401f340a1ee7f549b
|
||||
workspace/analysis/charts/query_executed_successfully: 9d6f9dad526fcfe0161757c2d2fe2c69
|
||||
workspace/analysis/charts/reset_to_ai_suggestion: 51ced8dd7c0eea8b7fc4e08b35cfbf30
|
||||
workspace/analysis/charts/save_and_add_to_dashboard: a76ed91c62dae10c5f8a9d48cbacd566
|
||||
workspace/analysis/charts/save_chart: 2e4505f7bf3d1c35b0b37b1e9d3dc566
|
||||
workspace/analysis/charts/save_chart_dialog_title: 2e4505f7bf3d1c35b0b37b1e9d3dc566
|
||||
workspace/analysis/charts/select_data_source: 983394bc0182b65ec68f713a46b97302
|
||||
@@ -1731,6 +1737,7 @@ checksums:
|
||||
workspace/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84
|
||||
workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d
|
||||
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
|
||||
workspace/analysis/charts/update_chart: 7d9223335d9f0c5938ec30356d7034a9
|
||||
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
|
||||
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
|
||||
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
|
||||
@@ -1739,6 +1746,7 @@ checksums:
|
||||
workspace/analysis/dashboards/create_dashboard: bedb308708fe9c576e161a2fa16d3439
|
||||
workspace/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
|
||||
workspace/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
|
||||
workspace/analysis/dashboards/create_new_chart: e03c0fdf4b861454c09707d66fb9bf4c
|
||||
workspace/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
|
||||
workspace/analysis/dashboards/dashboard: c9380ea68c8c76ea451bd9613329a07c
|
||||
workspace/analysis/dashboards/dashboard_delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
|
||||
@@ -1753,11 +1761,13 @@ checksums:
|
||||
workspace/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
|
||||
workspace/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
|
||||
workspace/analysis/dashboards/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
|
||||
workspace/analysis/dashboards/no_charts_available_description: 796ed01bcb53f770e5f627002839dcb4
|
||||
workspace/analysis/dashboards/no_charts_to_add_message: ad4cec703aa7d59c407bbb021dce4273
|
||||
workspace/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
|
||||
workspace/analysis/dashboards/no_data_message: 464d50cf30281a5b6af2726846eb14b4
|
||||
workspace/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
|
||||
workspace/analysis/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8
|
||||
workspace/analysis/no_feedback_records_message: 67d6ebb9c040304789017d795ca474fc
|
||||
workspace/analysis/no_feedback_records_with_sources_message: 4b72636a55afb4dcf977161ad5a15467
|
||||
workspace/analysis/setup_feedback_source: 7cc5855a2b0c762fe2ae13b4921f3e92
|
||||
workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da
|
||||
workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985
|
||||
workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c
|
||||
@@ -2441,10 +2451,14 @@ checksums:
|
||||
workspace/settings/feedback_record_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
|
||||
workspace/settings/feedback_record_directories/no_access: cc3385cd01a11e3949003a2cc6fb5b31
|
||||
workspace/settings/feedback_record_directories/no_connectors: b1becb4fe4e2ba7c5d277db149f092ff
|
||||
workspace/settings/feedback_record_directories/pause_connectors_confirmation_description: a3c2c56daed9f2a9e6a853cb8b924bad
|
||||
workspace/settings/feedback_record_directories/pause_connectors_confirmation_title: 09041363c55fb2686f8115df6fa2afc1
|
||||
workspace/settings/feedback_record_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
|
||||
workspace/settings/feedback_record_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
|
||||
workspace/settings/feedback_record_directories/title: e3d425c27f80162f29ce094e31a3fd8f
|
||||
workspace/settings/feedback_record_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
|
||||
workspace/settings/feedback_record_directories/unarchive_workspace_conflict: 82f4b8ebaf41589cfb96e6398dafcc76
|
||||
workspace/settings/feedback_record_directories/workspace_access: 32407b39cf878fb579559c1ed3660892
|
||||
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
|
||||
workspace/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
|
||||
workspace/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
|
||||
@@ -3453,16 +3467,21 @@ checksums:
|
||||
workspace/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
|
||||
workspace/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
|
||||
workspace/teams/team_settings_description: 52f91883b9ceb6de83efbf8efd4f11c0
|
||||
workspace/unify/add_feedback_record: 19cf2b1fef0ca1400f2400e7ee681ea0
|
||||
workspace/unify/add_feedback_record_description: 94bca46246ba7353049b33742554b4c0
|
||||
workspace/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
|
||||
workspace/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
|
||||
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
|
||||
workspace/unify/api_ingestion: a14642d27bbb6843f9f4903b6555dfbb
|
||||
workspace/unify/api_ingestion_manage_api_keys: 116786a004fb7b16ead8a5b7a6a2debe
|
||||
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
|
||||
workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d
|
||||
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
|
||||
workspace/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
|
||||
workspace/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
|
||||
workspace/unify/collected_at: b41902ddb4586ba4a4611d726b5014aa
|
||||
workspace/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd
|
||||
workspace/unify/configure_mapping: c794411c50bc511f8fc332def0e4e2f9
|
||||
workspace/unify/connection: 421e709602c92ffbe04a266f6a092089
|
||||
workspace/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa
|
||||
workspace/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780
|
||||
workspace/unify/connector_duplicated_successfully: eb21ce42cdbef5fa38244206bf65fe4e
|
||||
@@ -3481,9 +3500,12 @@ checksums:
|
||||
workspace/unify/csv_import_duplicate_warning: 56625e4613b93690e95661e5faaa4b27
|
||||
workspace/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
|
||||
workspace/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
|
||||
workspace/unify/custom_source_type: d931a8a74d3a5becd568e398107979da
|
||||
workspace/unify/custom_source_type_placeholder: f139e3e5d70dbf426d7c6b5ab2b198cc
|
||||
workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
|
||||
workspace/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922
|
||||
workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941
|
||||
workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e
|
||||
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
|
||||
workspace/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
|
||||
workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9
|
||||
@@ -3493,47 +3515,64 @@ checksums:
|
||||
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
|
||||
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
|
||||
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
|
||||
workspace/unify/feedback_record_created_successfully: 0ff30472085f1313a5ad53837c83e7c1
|
||||
workspace/unify/feedback_record_details: 823f3353db049a9d263ef31405054cda
|
||||
workspace/unify/feedback_record_details_description: 0b6f908154161241ce6bdeb4a2acaecd
|
||||
workspace/unify/feedback_record_directory: 89a08a540d1c6eb9f0b1a4b8f56e8aca
|
||||
workspace/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
|
||||
workspace/unify/feedback_record_mcp: cdddbef2944489820fd7f376a49c2803
|
||||
workspace/unify/feedback_record_updated_successfully: cb40ef4b924e21fa627ebe6809d1d826
|
||||
workspace/unify/feedback_record_value_required: b54d4d86f82071a93dc979e8eb359cf0
|
||||
workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388
|
||||
workspace/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
|
||||
workspace/unify/feedback_sources: e58ec9be19db8789e7096a756d24f2b2
|
||||
workspace/unify/feedback_sources_directory_access_multiple: 11d613bc1e9825aa6faa3db17ae678eb
|
||||
workspace/unify/feedback_sources_directory_access_single: c9da6b30d410a0ca6302a00a5747dc19
|
||||
workspace/unify/feedback_sources_settings_description: 45f162f2f81cd195c23cb3ec490bb3df
|
||||
workspace/unify/field_group_id: 17024bb46ff1e088afb6a279dc85aad4
|
||||
workspace/unify/field_group_label: 3df09c3b6fd22310359cf955ecff5c8e
|
||||
workspace/unify/field_id: 7791b5d581b7a525dcadf11ec73c6ab7
|
||||
workspace/unify/field_label: 6384505ca0e40010c666b712511132a6
|
||||
workspace/unify/field_type: 2581066dc304c853a4a817c20996fa08
|
||||
workspace/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
|
||||
workspace/unify/frd_cannot_be_changed: 265c12529f540d8309811f4e0090272f
|
||||
workspace/unify/go_to_feedback_record_directories: 16b66b62f85e7be311778f39315d118a
|
||||
workspace/unify/historical_import_complete: f46f98bf4db63bf2993bfb234dc95f62
|
||||
workspace/unify/import_csv_data: f05e1d1ed88d528256efe5702df46646
|
||||
workspace/unify/import_feedback: f05e1d1ed88d528256efe5702df46646
|
||||
workspace/unify/import_historical_responses: d7941f65344b6bfba56a40cc53a063b4
|
||||
workspace/unify/import_historical_responses_description: c860f7c6dbe8b74383ecf9cae9c219a0
|
||||
workspace/unify/import_rows: d2963498a7d2766264c4d67db677e8ff
|
||||
workspace/unify/import_via_source_name: eae32ae2fc87f925ca016fe8283bcbfd
|
||||
workspace/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984
|
||||
workspace/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20
|
||||
workspace/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6
|
||||
workspace/unify/invalid_values_found: 5011dc9c0294a222033f9910ea919b8a
|
||||
workspace/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
|
||||
workspace/unify/n_supported_questions: d75413d386441b5eb137a1ea191e4bd9
|
||||
workspace/unify/manage_directories: 460e00e1cbf1f51de57a2548546e33d7
|
||||
workspace/unify/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8
|
||||
workspace/unify/metadata: 695d4f7da261ba76e3be4de495491028
|
||||
workspace/unify/metadata_key: c478d228673f59fa556208ece60452f6
|
||||
workspace/unify/metadata_read_only_entries: 1934fee46c0a117f4926b61cc3d2d602
|
||||
workspace/unify/metadata_value: 8d69be1f5a20d9473a33c35670dff216
|
||||
workspace/unify/missing_feedback_source_title: 9ab1b8d54b4da72dd00ce03fe3b698b5
|
||||
workspace/unify/no_feedback_record_directory_available: b8126ef5d6276d9655a9b27ffcaca824
|
||||
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
|
||||
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
|
||||
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
|
||||
workspace/unify/no_surveys_found: 649a2f29b4c34525778d9177605fb326
|
||||
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
workspace/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
|
||||
workspace/unify/question_selected: b9ff13b6212874258da911867932dc7d
|
||||
workspace/unify/question_type_not_supported: 8d9f7554e3b509dfd5307d8d1fef08d7
|
||||
workspace/unify/questions_selected: 1f13d6fecafa2ce5ea9e6d07078a1d38
|
||||
workspace/unify/records_will_go_to: 6a3f5a6580857a931bab389ad354831c
|
||||
workspace/unify/refresh_feedback_records: c111751e02a7dee57390ed7fb79cfcc6
|
||||
workspace/unify/refreshing_feedback_records: 2a03b44510ebe19eea6473639e9a7222
|
||||
workspace/unify/request_feedback_source: 51045caa2c81dee971d23a1841d19a7e
|
||||
workspace/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||
workspace/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
|
||||
workspace/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
|
||||
workspace/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
|
||||
workspace/unify/select_all: eedc7cdb02de467c15dc418a066a77f2
|
||||
workspace/unify/select_feedback_record_directory: 88afbf2c2a322249908ee5d00ec5f65d
|
||||
workspace/unify/select_feedback_record_source_type: 10997fcbea2f93e756888cf7a7476fdf
|
||||
workspace/unify/select_questions: 13c79b8c284423eb6140534bf2137e56
|
||||
workspace/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc
|
||||
workspace/unify/select_source_type_prompt: c3fce7d908ee62b9e1b7fab1b17606d7
|
||||
workspace/unify/select_survey: bac52e59c7847417bef6fe7b7096b475
|
||||
workspace/unify/select_survey_and_questions: 53914988a2f48caecea23f3b3b868b9f
|
||||
workspace/unify/select_survey_questions_description: 3386ed56085eabebefa3cc453269fc5b
|
||||
@@ -3543,20 +3582,20 @@ checksums:
|
||||
workspace/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131
|
||||
workspace/unify/source: 45309626f464f4bda161ee783a4c8c80
|
||||
workspace/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
|
||||
workspace/unify/source_connect_feedback_record_mcp_description: a3f56e2a6e403f4021e83f1b1a466d95
|
||||
workspace/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
|
||||
workspace/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
|
||||
workspace/unify/source_id: 134a9a7d473508c5623ac724a5ba4be9
|
||||
workspace/unify/source_name: 157675beca12efcd8ec512c5256b1a61
|
||||
workspace/unify/source_type: d1ff69af76c687eb189db72030717570
|
||||
workspace/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
|
||||
workspace/unify/sources: ecbbe6e49baa335c5afd7b04b609d006
|
||||
workspace/unify/status_active: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
workspace/unify/status_completed: 0e4bbce9985f25eb673d9a054c8d5334
|
||||
workspace/unify/status_draft: e8a92958ad300aacfe46c2bf6644927e
|
||||
workspace/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
workspace/unify/status_live_sync: 7e794257419414f57d34845ef38d0939
|
||||
workspace/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
|
||||
workspace/unify/status_ready: 437c0eea608e15ad5cdab94bde2f4b48
|
||||
workspace/unify/submission_id: 02edf76883b47079dbe20f3f36b7c1a7
|
||||
workspace/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d
|
||||
workspace/unify/survey_import_line: 63fa0ea1d7daa3ba333436fbc65f8b19
|
||||
workspace/unify/total_feedback_records: 8962087650b62e4a12b81e7d09317ffa
|
||||
workspace/unify/topics_and_subtopics: 1148eca01a1993fadca932efcdea7641
|
||||
workspace/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
|
||||
workspace/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
|
||||
workspace/unify/updated_at: 8fdb85248e591254973403755dcc3724
|
||||
@@ -3564,6 +3603,10 @@ checksums:
|
||||
workspace/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
|
||||
workspace/unify/user_identifier: 61073457a5c3901084b557d065f876be
|
||||
workspace/unify/value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||
workspace/unify/value_boolean: bbdcd3f46954b6304b9069e94e1371ab
|
||||
workspace/unify/value_date: c8d705d1975affc01c002324725fec3f
|
||||
workspace/unify/value_number: 1f14da79d14bd7b1c2324141f4470675
|
||||
workspace/unify/value_text: e097a597cc507c716401ad18255de578
|
||||
workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9
|
||||
workspace/xm-templates/ces_description: c8d9794dd17d5ab85a979f1b3e1bc935
|
||||
workspace/xm-templates/csat: fdfc1dc6214cce661dcdc32a71d80337
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ZConnectorUpdateInput,
|
||||
getHubFieldTypeFromElementType,
|
||||
} from "@formbricks/types/connector";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { AuthorizationError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
@@ -86,20 +86,24 @@ const resolveSurveyMappings = async (
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const elementMap = new Map(elements.map((el) => [el.id, el]));
|
||||
|
||||
return elementIds
|
||||
.filter((elementId) => {
|
||||
if (elementMap.has(elementId)) return true;
|
||||
return elementIds.flatMap((elementId) => {
|
||||
const element = elementMap.get(elementId);
|
||||
if (!element) {
|
||||
logger.warn({ surveyId, elementId }, "Skipping unknown elementId when building connector mappings");
|
||||
return false;
|
||||
})
|
||||
.map((elementId) => {
|
||||
const element = elementMap.get(elementId)!;
|
||||
return {
|
||||
surveyId,
|
||||
elementId,
|
||||
hubFieldType: getHubFieldTypeFromElementType(element.type),
|
||||
};
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const hubFieldType = getHubFieldTypeFromElementType(element.type);
|
||||
if (!hubFieldType) {
|
||||
logger.warn(
|
||||
{ surveyId, elementId, elementType: element.type },
|
||||
"Skipping unmappable element type when building connector mappings"
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ surveyId, elementId, hubFieldType }];
|
||||
});
|
||||
};
|
||||
|
||||
const resolveFormbricksMappingsInput = async (
|
||||
@@ -108,7 +112,12 @@ const resolveFormbricksMappingsInput = async (
|
||||
const allMappings = await Promise.all(
|
||||
entries.map(({ surveyId, elementIds }) => resolveSurveyMappings(surveyId, elementIds))
|
||||
);
|
||||
return { type: "formbricks", mappings: allMappings.flat() };
|
||||
const flattenedMappings = allMappings.flat();
|
||||
if (flattenedMappings.length === 0) {
|
||||
throw new InvalidInputError("No supported survey questions selected for connector mapping");
|
||||
}
|
||||
|
||||
return { type: "formbricks_survey", mappings: flattenedMappings };
|
||||
};
|
||||
|
||||
const ZFormbricksSurveyMapping = z.object({
|
||||
@@ -116,15 +125,23 @@ const ZFormbricksSurveyMapping = z.object({
|
||||
elementIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
// Temporary compatibility to support legacy client payloads using `formbricks`.
|
||||
const ZConnectorCreateInputWithLegacyType = ZConnectorCreateInput.extend({
|
||||
type: z.enum(["formbricks_survey", "csv", "formbricks"]),
|
||||
});
|
||||
|
||||
const ZCreateConnectorWithMappingsAction = z
|
||||
.object({
|
||||
workspaceId: ZId,
|
||||
connectorInput: ZConnectorCreateInput,
|
||||
connectorInput: ZConnectorCreateInputWithLegacyType,
|
||||
formbricksMappings: z.array(ZFormbricksSurveyMapping).optional(),
|
||||
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.connectorInput.type === "formbricks") {
|
||||
const normalizedType =
|
||||
data.connectorInput.type === "formbricks" ? "formbricks_survey" : data.connectorInput.type;
|
||||
|
||||
if (normalizedType === "formbricks_survey") {
|
||||
if (!data.formbricksMappings?.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
@@ -132,7 +149,7 @@ const ZCreateConnectorWithMappingsAction = z
|
||||
message: "At least one survey mapping is required for Formbricks connectors",
|
||||
});
|
||||
}
|
||||
} else if (data.connectorInput.type === "csv") {
|
||||
} else if (normalizedType === "csv") {
|
||||
if (!data.fieldMappings?.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
@@ -146,6 +163,14 @@ const ZCreateConnectorWithMappingsAction = z
|
||||
export const createConnectorWithMappingsAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateConnectorWithMappingsAction)
|
||||
.action(async ({ ctx, parsedInput }): Promise<TConnectorWithMappings> => {
|
||||
const connectorInput = ZConnectorCreateInput.parse({
|
||||
...parsedInput.connectorInput,
|
||||
type:
|
||||
parsedInput.connectorInput.type === "formbricks"
|
||||
? "formbricks_survey"
|
||||
: parsedInput.connectorInput.type,
|
||||
});
|
||||
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -165,7 +190,7 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
|
||||
|
||||
// Verify FRD belongs to same org
|
||||
const frd = await prisma.feedbackRecordDirectory.findUnique({
|
||||
where: { id: parsedInput.connectorInput.feedbackRecordDirectoryId },
|
||||
where: { id: connectorInput.feedbackRecordDirectoryId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (frd?.organizationId !== organizationId) {
|
||||
@@ -193,7 +218,7 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
|
||||
|
||||
return createConnectorWithMappings(
|
||||
parsedInput.workspaceId,
|
||||
{ ...parsedInput.connectorInput, createdBy: ctx.user.id },
|
||||
{ ...connectorInput, createdBy: ctx.user.id },
|
||||
mappingsInput
|
||||
);
|
||||
});
|
||||
@@ -298,9 +323,9 @@ export const duplicateConnectorAction = authenticatedActionClient
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
if (source.type === "formbricks" && source.formbricksMappings.length > 0) {
|
||||
if (source.type === "formbricks_survey" && source.formbricksMappings.length > 0) {
|
||||
mappingsInput = {
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
mappings: source.formbricksMappings.map((m) => ({
|
||||
surveyId: m.surveyId,
|
||||
elementId: m.elementId,
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("importCsvData", () => {
|
||||
});
|
||||
|
||||
test("throws InvalidInputError for non-csv connector", async () => {
|
||||
const connector = makeConnector({ type: "formbricks" });
|
||||
const connector = makeConnector({ type: "formbricks_survey" });
|
||||
await expect(importCsvData(connector, [])).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const mockConnector: TConnectorWithMappings = {
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
name: "Test Connector",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
workspaceId: ENV_ID,
|
||||
lastSyncAt: null,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const importHistoricalResponses = async (
|
||||
connector: TConnectorWithMappings,
|
||||
survey: TSurvey
|
||||
): Promise<TImportResult> => {
|
||||
if (connector.type !== "formbricks") {
|
||||
if (connector.type !== "formbricks_survey") {
|
||||
throw new InvalidInputError("Historical import is only supported for Formbricks connectors");
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ function createConnector(
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Connector",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
workspaceId: "env-1",
|
||||
feedbackRecordDirectoryId: "frd-1",
|
||||
@@ -79,7 +79,7 @@ const oneFeedbackRecord = [
|
||||
{
|
||||
field_id: "el-1",
|
||||
field_type: "rating" as const,
|
||||
source_type: "formbricks",
|
||||
source_type: "formbricks_survey",
|
||||
source_id: "survey-1",
|
||||
source_name: "Test Survey",
|
||||
field_label: "Question?",
|
||||
|
||||
@@ -47,7 +47,7 @@ const mockConnector = {
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
name: "Test Connector",
|
||||
type: "formbricks" as const,
|
||||
type: "formbricks_survey" as const,
|
||||
status: "active" as const,
|
||||
workspaceId: ENV_ID,
|
||||
lastSyncAt: null,
|
||||
@@ -144,7 +144,7 @@ describe("getConnectorsBySurveyId", () => {
|
||||
expect(prisma.connector.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
formbricksMappings: { some: { surveyId: SURVEY_ID } },
|
||||
},
|
||||
@@ -303,13 +303,18 @@ describe("createConnectorWithMappings", () => {
|
||||
|
||||
const result = await createConnectorWithMappings(ENV_ID, {
|
||||
name: "New",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
});
|
||||
|
||||
expect(tx.connector.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { name: "New", type: "formbricks", workspaceId: ENV_ID, feedbackRecordDirectoryId: FRD_ID },
|
||||
data: {
|
||||
name: "New",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: ENV_ID,
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(tx.connectorFormbricksMapping.create).not.toHaveBeenCalled();
|
||||
@@ -325,9 +330,9 @@ describe("createConnectorWithMappings", () => {
|
||||
|
||||
await createConnectorWithMappings(
|
||||
ENV_ID,
|
||||
{ name: "FB", type: "formbricks", feedbackRecordDirectoryId: FRD_ID },
|
||||
{ name: "FB", type: "formbricks_survey", feedbackRecordDirectoryId: FRD_ID },
|
||||
{
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
mappings: [
|
||||
{ surveyId: SURVEY_ID, elementId: "el-1", hubFieldType: "text" },
|
||||
{ surveyId: SURVEY_ID, elementId: "el-2", hubFieldType: "nps" },
|
||||
@@ -392,7 +397,7 @@ describe("createConnectorWithMappings", () => {
|
||||
await expect(
|
||||
createConnectorWithMappings(ENV_ID, {
|
||||
name: "Dup",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
})
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
@@ -470,7 +475,7 @@ describe("updateConnectorWithMappings", () => {
|
||||
ENV_ID,
|
||||
{ name: "Updated" },
|
||||
{
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
mappings: [{ surveyId: SURVEY_ID, elementId: "el-new", hubFieldType: "nps" }],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -132,7 +132,7 @@ export const getConnectorsBySurveyId = reactCache(
|
||||
try {
|
||||
const connectors = await prisma.connector.findMany({
|
||||
where: {
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
formbricksMappings: {
|
||||
some: {
|
||||
@@ -213,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
|
||||
// -- Composite functions --
|
||||
|
||||
export type TFormbricksMappingsInput = {
|
||||
type: "formbricks";
|
||||
type: "formbricks_survey";
|
||||
mappings: TConnectorFormbricksMappingCreateInput[];
|
||||
};
|
||||
|
||||
@@ -243,7 +243,7 @@ export const createConnectorWithMappings = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (mappingsInput?.type === "formbricks") {
|
||||
if (mappingsInput?.type === "formbricks_survey") {
|
||||
await Promise.all(
|
||||
mappingsInput.mappings.map((mapping) =>
|
||||
tx.connectorFormbricksMapping.create({
|
||||
@@ -311,7 +311,7 @@ export const updateConnectorWithMappings = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (mappingsInput?.type === "formbricks") {
|
||||
if (mappingsInput?.type === "formbricks_survey") {
|
||||
await tx.connectorFormbricksMapping.deleteMany({
|
||||
where: { connectorId, workspaceId },
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
source_type: "formbricks",
|
||||
source_type: "formbricks_survey",
|
||||
field_id: "el-text",
|
||||
field_type: "text",
|
||||
field_label: "How can we improve?",
|
||||
@@ -185,6 +185,24 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
expect(result[0].collected_at).toBe(NOW.toISOString());
|
||||
});
|
||||
|
||||
test("falls back to updatedAt when createdAt is missing", () => {
|
||||
const updatedAt = new Date("2026-02-25T10:00:00.000Z");
|
||||
const response = { ...mockResponse, createdAt: undefined, updatedAt } as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result[0].collected_at).toBe(updatedAt.toISOString());
|
||||
});
|
||||
|
||||
test("parses string createdAt values for collected_at", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
createdAt: "2026-02-26T10:00:00.000Z",
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
expect(result[0].collected_at).toBe("2026-02-26T10:00:00.000Z");
|
||||
});
|
||||
|
||||
test("includes tenant_id when provided", () => {
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, "tenant-abc");
|
||||
|
||||
@@ -14,6 +14,23 @@ const getHeadlineFromElement = (element?: TSurveyElement): string => {
|
||||
return getTextContent(raw) || "Untitled";
|
||||
};
|
||||
|
||||
const toIsoTimestamp = (value: unknown): string | null => {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getCollectedAt = (response: TResponse): string => {
|
||||
return toIsoTimestamp(response.createdAt) ?? toIsoTimestamp(response.updatedAt) ?? new Date().toISOString();
|
||||
};
|
||||
|
||||
function extractResponseValue(responseData: TResponseData, elementId: string): TResponseDataValue {
|
||||
if (!responseData || typeof responseData !== "object") return undefined;
|
||||
return responseData[elementId];
|
||||
@@ -99,9 +116,8 @@ export function transformResponseToFeedbackRecords(
|
||||
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
|
||||
|
||||
const feedbackRecord = {
|
||||
collected_at:
|
||||
response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt),
|
||||
source_type: "formbricks",
|
||||
collected_at: getCollectedAt(response),
|
||||
source_type: "formbricks_survey",
|
||||
submission_id: response.id,
|
||||
tenant_id: tenantId,
|
||||
field_id: mapping.elementId,
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "Aktivität",
|
||||
"add": "Hinzufügen",
|
||||
"add_action": "Aktion hinzufügen",
|
||||
"add_chart": "Diagramm hinzufügen",
|
||||
"add_charts": "Diagramme hinzufügen",
|
||||
"add_existing_chart_description": "Suche und wähle Diagramme aus, um sie zu diesem Dashboard hinzuzufügen.",
|
||||
"add_filter": "Filter hinzufügen",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
|
||||
"not_authorized": "Nicht autorisiert",
|
||||
"not_connected": "Nicht verbunden",
|
||||
"not_set": "Nicht festgelegt",
|
||||
"note": "Hinweis",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"number": "Nummer",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "Umfrage melden",
|
||||
"request_trial_license": "Testlizenz anfordern",
|
||||
"reset_to_default": "Auf Standard zurücksetzen",
|
||||
"resize": "Größe ändern",
|
||||
"response": "Antwort",
|
||||
"response_id": "Antwort-ID",
|
||||
"responses": "Antworten",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden",
|
||||
"something_went_wrong": "Etwas ist schiefgelaufen",
|
||||
"something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||
"soon": "Bald",
|
||||
"sort_by": "Sortieren nach",
|
||||
"start_free_trial": "Kostenlose Testversion starten",
|
||||
"status": "Status",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "Bitte wähle ein Dashboard aus",
|
||||
"predefined_measures": "Vordefinierte Kennzahlen",
|
||||
"preset": "Vorlage",
|
||||
"preview_chart": "Vorschaudiagramm",
|
||||
"query_executed_successfully": "Abfrage erfolgreich ausgeführt",
|
||||
"reset_to_ai_suggestion": "Auf KI-Vorschlag zurücksetzen",
|
||||
"save_and_add_to_dashboard": "Speichern und zum Dashboard hinzufügen",
|
||||
"save_chart": "Diagramm speichern",
|
||||
"save_chart_dialog_title": "Diagramm speichern",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "Startdatum",
|
||||
"time_dimension": "Zeitdimension",
|
||||
"time_dimension_title": "Zeitbasierte Gruppierung hinzufügen",
|
||||
"time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf."
|
||||
"time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf.",
|
||||
"update_chart": "Diagramm aktualisieren"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} Diagramm(e) hinzufügen",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "Dashboard erstellen",
|
||||
"create_dashboard_description": "Gib einen Namen für dein neues Dashboard ein.",
|
||||
"create_failed": "Dashboard konnte nicht erstellt werden",
|
||||
"create_new_chart": "Neues Diagramm erstellen",
|
||||
"create_success": "Dashboard erfolgreich erstellt!",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_delete_confirmation": "Bist du sicher, dass du dieses Dashboard löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "Dashboard konnte nicht dupliziert werden",
|
||||
"duplicate_success": "Dashboard erfolgreich dupliziert!",
|
||||
"failed_to_load_chart_data": "Diagrammdaten konnten nicht geladen werden",
|
||||
"no_charts_available_description": "Es gibt keine Diagramme, die zu diesem Dashboard hinzugefügt werden können. Entweder existieren noch keine Diagramme oder alle vorhandenen Diagramme wurden bereits hinzugefügt. Gehe zur Diagramm-Seite, um neue Diagramme zu erstellen.",
|
||||
"no_charts_to_add_message": "Keine Diagramme zum Hinzufügen zu diesem Dashboard vorhanden.",
|
||||
"no_dashboards_found": "Keine Dashboards gefunden.",
|
||||
"no_data_message": "Keine Daten. Es gibt derzeit keine Informationen zum Anzeigen. Füge Diagramme hinzu, um dein Dashboard zu erstellen.",
|
||||
"please_enter_name": "Bitte gib einen Dashboard-Namen ein"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "Sie haben keine Feedback-Datensätze, über die Sie berichten können. Richten Sie Feedbackquellen ein, um Daten in das System einzuspeisen.",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "Richten Sie Feedbackquellen ein"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "API-Key hinzufügen",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "Feedback-Verzeichnisse",
|
||||
"no_access": "Du hast keine Berechtigung, Feedback-Datensatz-Verzeichnisse zu verwalten.",
|
||||
"no_connectors": "Noch keine Connectoren mit diesem Verzeichnis verknüpft.",
|
||||
"pause_connectors_confirmation_description": "Wenn du diese Connectoren pausierst, werden keine neuen Datensätze mehr hinzugefügt.",
|
||||
"pause_connectors_confirmation_title": "Verknüpfte Connectoren pausieren?",
|
||||
"select_workspaces_placeholder": "Workspaces auswählen...",
|
||||
"show_archived": "Archivierte anzeigen",
|
||||
"title": "Feedback-Datensatz-Verzeichnisse",
|
||||
"unarchive": "Aus Archiv wiederherstellen"
|
||||
"unarchive": "Aus Archiv wiederherstellen",
|
||||
"unarchive_workspace_conflict": "Dieses Verzeichnis kann nicht wiederhergestellt werden, weil ein oder mehrere zugewiesene Workspaces archiviert sind.",
|
||||
"workspace_access": "Workspace-Zugriff"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Datenanreicherung & -analyse (KI)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "Sieh nach, welche Teams auf diesen Workspace zugreifen können."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Feedback-Datensatz hinzufügen",
|
||||
"add_feedback_record_description": "Erstellen Sie manuell einen Feedback-Datensatz.",
|
||||
"add_feedback_source": "Feedback-Quelle hinzufügen",
|
||||
"add_source": "Quelle hinzufügen",
|
||||
"allowed_values": "Zulässige Werte: {values}",
|
||||
"api_ingestion": "API-Erfassung",
|
||||
"api_ingestion_manage_api_keys": "API-Schlüssel verwalten",
|
||||
"api_ingestion_settings_description": "Sende Feedback-Datensätze über die Management-API.",
|
||||
"auto_generated": "Automatisch generiert",
|
||||
"change_file": "Datei ändern",
|
||||
"click_load_sample_csv": "Klick auf 'Beispiel-CSV laden', um Spalten zu sehen",
|
||||
"click_to_upload": "Zum Hochladen klicken",
|
||||
"collected_at": "Erfasst am",
|
||||
"configure_import": "Import konfigurieren",
|
||||
"configure_mapping": "Mapping konfigurieren",
|
||||
"connection": "Verbindung",
|
||||
"connector_created_successfully": "Connector erfolgreich erstellt",
|
||||
"connector_deleted_successfully": "Connector erfolgreich gelöscht",
|
||||
"connector_duplicated_successfully": "Connector erfolgreich dupliziert",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "Wenn Du die Daten zweimal importierst, entstehen doppelte Einträge.",
|
||||
"csv_inconsistent_columns": "Zeile {row} hat inkonsistente Spalten. Alle Zeilen müssen die gleichen Überschriften haben.",
|
||||
"csv_max_records": "Maximal {max} Einträge erlaubt.",
|
||||
"custom_source_type": "Benutzerdefinierter Quelltyp",
|
||||
"custom_source_type_placeholder": "Geben Sie den benutzerdefinierten Quelltyp ein",
|
||||
"default_connector_name_csv": "CSV-Import",
|
||||
"default_connector_name_formbricks": "Formbricks-Umfrage-Verbindung",
|
||||
"deselect_all": "Alle abwählen",
|
||||
"discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.",
|
||||
"discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?",
|
||||
"drop_a_field_here": "Ziehe ein Feld hierher",
|
||||
"drop_field_or": "Feld ablegen oder",
|
||||
"edit_csv_mapping": "CSV-Zuordnung bearbeiten",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "Aufzählung",
|
||||
"failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden",
|
||||
"feedback_date": "Aktuelles Datum",
|
||||
"feedback_record_created_successfully": "Feedback-Datensatz erfolgreich erstellt",
|
||||
"feedback_record_details": "Details zum Feedback-Datensatz",
|
||||
"feedback_record_details_description": "Überprüfen und aktualisieren Sie die Felder des Feedback-Datensatzes.",
|
||||
"feedback_record_directory": "Feedback-Datensatz-Verzeichnis",
|
||||
"feedback_record_fields": "Feedback-Eintragsfelder",
|
||||
"feedback_record_mcp": "Feedback-Datensatz MCP",
|
||||
"feedback_record_updated_successfully": "Feedback-Datensatz erfolgreich aktualisiert",
|
||||
"feedback_record_value_required": "Für den ausgewählten Feldtyp ist ein Wert erforderlich",
|
||||
"feedback_records": "Feedback-Einträge",
|
||||
"feedback_records_refreshed": "Feedback-Einträge aktualisiert",
|
||||
"feedback_sources": "Feedback-Quellen",
|
||||
"feedback_sources_directory_access_multiple": "Neue Datensätze aus diesen Quellen werden gespeichert in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Neue Datensätze aus dieser Quelle werden gespeichert in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Verbinde und verwalte alle Feedback-Quellen für diesen Workspace.",
|
||||
"field_group_id": "Feldgruppen-ID",
|
||||
"field_group_label": "Feldgruppenbezeichnung",
|
||||
"field_id": "Feld-ID",
|
||||
"field_label": "Feldbezeichnung",
|
||||
"field_type": "Feldtyp",
|
||||
"formbricks_surveys": "Formbricks-Umfragen",
|
||||
"frd_cannot_be_changed": "Das Feedback-Verzeichnis kann nach der Erstellung nicht mehr geändert werden.",
|
||||
"go_to_feedback_record_directories": "Zu den Verzeichnis-Einstellungen",
|
||||
"historical_import_complete": "Import abgeschlossen: {successes} erfolgreich, {failures} fehlgeschlagen, {skipped} übersprungen (keine Daten)",
|
||||
"import_csv_data": "Feedback importieren",
|
||||
"import_feedback": "Feedback importieren",
|
||||
"import_historical_responses": "Bisherige Antworten importieren",
|
||||
"import_historical_responses_description": "Importiere jetzt vorhandene Antworten aus dieser Umfrage.",
|
||||
"import_rows": "{count} Zeilen importieren",
|
||||
"import_via_source_name": "Import über „{sourceName}“",
|
||||
"importing_data": "Daten werden importiert...",
|
||||
"importing_historical_data": "Historische Daten werden importiert...",
|
||||
"invalid_enum_values": "Ungültige Werte in der Spalte, die {field} zugeordnet ist",
|
||||
"invalid_values_found": "Gefunden: {values} (Zeilen: {rows}) {extra}",
|
||||
"load_sample_csv": "Beispiel-CSV laden",
|
||||
"n_supported_questions": "{count} unterstützte Fragen",
|
||||
"manage_directories": "Verzeichnisse verwalten",
|
||||
"manage_feedback_sources": "Feedbackquellen verwalten",
|
||||
"metadata": "Metadaten",
|
||||
"metadata_key": "Metadatenschlüssel",
|
||||
"metadata_read_only_entries": "Schreibgeschützte Metadatenwerte (keine Zeichenfolge)",
|
||||
"metadata_value": "Metadatenwert",
|
||||
"missing_feedback_source_title": "Feedback-Quelle fehlt?",
|
||||
"no_feedback_record_directory_available": "Diesem Workspace ist kein Feedback-Datensatz-Verzeichnis zugewiesen. Erstelle oder weise zuerst eines zu.",
|
||||
"no_feedback_records": "Noch keine Feedback-Einträge vorhanden. Einträge erscheinen hier, sobald deine Konnektoren Daten senden.",
|
||||
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
|
||||
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
|
||||
"no_surveys_found": "Keine Umfragen in dieser Umgebung gefunden",
|
||||
"optional": "Optional",
|
||||
"or_drag_and_drop": "oder per Drag & Drop",
|
||||
"question_selected": "<strong>{count}</strong> Frage ausgewählt. Jede Antwort auf diese Frage wird einen neuen Feedback-Eintrag erstellen.",
|
||||
"question_type_not_supported": "Dieser Fragetyp wird nicht unterstützt",
|
||||
"questions_selected": "<strong>{count}</strong> Fragen ausgewählt. Jede Antwort auf diese Fragen wird einen neuen Feedback-Eintrag erstellen.",
|
||||
"records_will_go_to": "Datensätze gehen an",
|
||||
"refresh_feedback_records": "Feedback-Einträge aktualisieren",
|
||||
"refreshing_feedback_records": "Feedback-Einträge werden aktualisiert...",
|
||||
"request_feedback_source": "Quellen-Integration anfragen",
|
||||
"required": "Erforderlich",
|
||||
"save_changes": "Änderungen speichern",
|
||||
"select_a_survey_to_see_questions": "Wähle eine Umfrage aus, um ihre Fragen zu sehen",
|
||||
"select_a_value": "Wähle einen Wert aus...",
|
||||
"select_all": "Alle auswählen",
|
||||
"select_feedback_record_directory": "Verzeichnis auswählen",
|
||||
"select_feedback_record_source_type": "Wählen Sie den Quelltyp aus",
|
||||
"select_questions": "Fragen auswählen",
|
||||
"select_source_type_description": "Wähle die Art der Feedback-Quelle aus, die Du verbinden möchtest.",
|
||||
"select_source_type_prompt": "Wähle die Art der Feedback-Quelle aus, die Du verbinden möchtest:",
|
||||
"select_survey": "Umfrage auswählen",
|
||||
"select_survey_and_questions": "Umfrage & Fragen auswählen",
|
||||
"select_survey_questions_description": "Wähle aus, welche Umfragefragen FeedbackRecords erstellen sollen.",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "3 von {count} Zeilen werden angezeigt",
|
||||
"source": "Quelle",
|
||||
"source_connect_csv_description": "Feedback aus CSV-Dateien importieren",
|
||||
"source_connect_feedback_record_mcp_description": "Sende Feedback-Datensätze über die MCP-Integration.",
|
||||
"source_connect_formbricks_description": "Feedback aus Deinen Formbricks-Umfragen verbinden",
|
||||
"source_fields": "Quellfelder",
|
||||
"source_id": "Quell-ID",
|
||||
"source_name": "Quellenname",
|
||||
"source_type": "Quellentyp",
|
||||
"source_type_cannot_be_changed": "Quellentyp kann nicht geändert werden",
|
||||
"sources": "Quellen",
|
||||
"status_active": "In Bearbeitung",
|
||||
"status_completed": "Abgeschlossen",
|
||||
"status_draft": "Entwurf",
|
||||
"status_error": "Fehler",
|
||||
"status_live_sync": "Live-Synchronisierung",
|
||||
"status_paused": "Pausiert",
|
||||
"status_ready": "Bereit",
|
||||
"submission_id": "Einreichungs-ID",
|
||||
"survey_has_no_questions": "Diese Umfrage hat keine Fragen",
|
||||
"survey_import_line": "{surveyName}: {responseCount} Antworten × {questionCount} Fragen = {total} Feedback-Datensätze",
|
||||
"total_feedback_records": "Gesamt: {checked} von {total} Feedback-Datensätzen ausgewählt über {surveyCount} Umfragen",
|
||||
"topics_and_subtopics": "Themen & Unterthemen",
|
||||
"unify_feedback": "Feedback vereinheitlichen",
|
||||
"update_mapping_description": "Aktualisiere die Zuordnungskonfiguration für diese Quelle.",
|
||||
"updated_at": "Aktualisiert am",
|
||||
"upload_csv_data_description": "Lade eine CSV-Datei hoch, um Feedback-Daten zu importieren.",
|
||||
"upload_csv_file": "CSV-Datei hochladen",
|
||||
"user_identifier": "Benutzer",
|
||||
"value": "Wert"
|
||||
"value": "Wert",
|
||||
"value_boolean": "Wert (Boolescher Wert)",
|
||||
"value_date": "Wert (Datum)",
|
||||
"value_number": "Wert (Anzahl)",
|
||||
"value_text": "Wert (Text)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "Activity",
|
||||
"add": "Add",
|
||||
"add_action": "Add action",
|
||||
"add_chart": "Add chart",
|
||||
"add_charts": "Add charts",
|
||||
"add_existing_chart_description": "Search and select charts to add to this dashboard.",
|
||||
"add_filter": "Add filter",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "You are not authenticated to perform this action.",
|
||||
"not_authorized": "Not authorized",
|
||||
"not_connected": "Not Connected",
|
||||
"not_set": "Not set",
|
||||
"note": "Note",
|
||||
"notifications": "Notifications",
|
||||
"number": "Number",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "Report Survey",
|
||||
"request_trial_license": "Request trial license",
|
||||
"reset_to_default": "Reset to default",
|
||||
"resize": "Resize",
|
||||
"response": "Response",
|
||||
"response_id": "Response ID",
|
||||
"responses": "Responses",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "Some files failed to upload",
|
||||
"something_went_wrong": "Something went wrong",
|
||||
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
|
||||
"soon": "Soon",
|
||||
"sort_by": "Sort by",
|
||||
"start_free_trial": "Start free trial",
|
||||
"status": "Status",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "Please select a dashboard",
|
||||
"predefined_measures": "Predefined Measures",
|
||||
"preset": "Preset",
|
||||
"preview_chart": "Preview chart",
|
||||
"query_executed_successfully": "Query executed successfully",
|
||||
"reset_to_ai_suggestion": "Reset to AI suggestion",
|
||||
"save_and_add_to_dashboard": "Save & add to dashboard",
|
||||
"save_chart": "Save Chart",
|
||||
"save_chart_dialog_title": "Save Chart",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "Start date",
|
||||
"time_dimension": "Time Dimension",
|
||||
"time_dimension_title": "Add time-based grouping",
|
||||
"time_dimension_toggle_description": "Monitor trends over time."
|
||||
"time_dimension_toggle_description": "Monitor trends over time.",
|
||||
"update_chart": "Update chart"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Add {count} chart(s)",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "Create dashboard",
|
||||
"create_dashboard_description": "Enter a name for your new dashboard.",
|
||||
"create_failed": "Failed to create dashboard",
|
||||
"create_new_chart": "Create new chart",
|
||||
"create_success": "Dashboard created successfully!",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "Failed to duplicate dashboard",
|
||||
"duplicate_success": "Dashboard duplicated successfully!",
|
||||
"failed_to_load_chart_data": "Failed to load chart data",
|
||||
"no_charts_available_description": "There are no charts that can be added to this dashboard. Either no charts exist yet, or all existing charts have already been added. Go to the Charts page to create new charts.",
|
||||
"no_charts_to_add_message": "No charts to add to this dashboard.",
|
||||
"no_dashboards_found": "No dashboards found.",
|
||||
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
|
||||
"please_enter_name": "Please enter a dashboard name"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "You don't have Feedback Records to report on. Setup Feedback Sources to feed data into the system.",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "Setup feedback sources"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Add API Key",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "Feedback Directories",
|
||||
"no_access": "You do not have permission to manage feedback record directories.",
|
||||
"no_connectors": "No connectors linked to this directory yet.",
|
||||
"pause_connectors_confirmation_description": "Pausing these connectors will stop new records from being added.",
|
||||
"pause_connectors_confirmation_title": "Pause linked connectors?",
|
||||
"select_workspaces_placeholder": "Select workspaces...",
|
||||
"show_archived": "Show archived",
|
||||
"title": "Feedback Record Directories",
|
||||
"unarchive": "Unarchive"
|
||||
"unarchive": "Unarchive",
|
||||
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more assigned workspaces are archived.",
|
||||
"workspace_access": "Workspace access"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "See which teams can access this workspace."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Add feedback record",
|
||||
"add_feedback_record_description": "Create a feedback record manually.",
|
||||
"add_feedback_source": "Add Feedback Source",
|
||||
"add_source": "Add source",
|
||||
"allowed_values": "Allowed values: {values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_manage_api_keys": "Manage API keys",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "Auto-generated",
|
||||
"change_file": "Change file",
|
||||
"click_load_sample_csv": "Click 'Load sample CSV' to see columns",
|
||||
"click_to_upload": "Click to upload",
|
||||
"collected_at": "Collected At",
|
||||
"configure_import": "Configure import",
|
||||
"configure_mapping": "Configure Mapping",
|
||||
"connection": "Connection",
|
||||
"connector_created_successfully": "Connector created successfully",
|
||||
"connector_deleted_successfully": "Connector deleted successfully",
|
||||
"connector_duplicated_successfully": "Connector duplicated successfully",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "Importing data twice will create duplicate records.",
|
||||
"csv_inconsistent_columns": "Row {row} has inconsistent columns. All rows must have the same headers.",
|
||||
"csv_max_records": "Maximum {max} records allowed.",
|
||||
"custom_source_type": "Custom source type",
|
||||
"custom_source_type_placeholder": "Enter custom source type",
|
||||
"default_connector_name_csv": "CSV Import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey Connection",
|
||||
"deselect_all": "Deselect all",
|
||||
"discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.",
|
||||
"discard_feedback_record_changes_title": "Discard unsaved changes?",
|
||||
"drop_a_field_here": "Drop a field here",
|
||||
"drop_field_or": "Drop field or",
|
||||
"edit_csv_mapping": "Edit CSV mapping",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Failed to load feedback records",
|
||||
"feedback_date": "Current date",
|
||||
"feedback_record_created_successfully": "Feedback record created successfully",
|
||||
"feedback_record_details": "Feedback record details",
|
||||
"feedback_record_details_description": "Review and update feedback record fields.",
|
||||
"feedback_record_directory": "Feedback Record Directory",
|
||||
"feedback_record_fields": "Feedback Record Fields",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"feedback_record_updated_successfully": "Feedback record updated successfully",
|
||||
"feedback_record_value_required": "A value is required for the selected field type",
|
||||
"feedback_records": "Feedback Records",
|
||||
"feedback_records_refreshed": "Feedback records refreshed",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
|
||||
"field_group_id": "Field Group ID",
|
||||
"field_group_label": "Field Group Label",
|
||||
"field_id": "Field ID",
|
||||
"field_label": "Field Label",
|
||||
"field_type": "Field Type",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "Feedback directory cannot be changed after creation.",
|
||||
"go_to_feedback_record_directories": "Go to directories settings",
|
||||
"historical_import_complete": "Import complete: {successes} succeeded, {failures} failed, {skipped} skipped (no data)",
|
||||
"import_csv_data": "Import feedback",
|
||||
"import_feedback": "Import feedback",
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "Import {count} rows",
|
||||
"import_via_source_name": "Import via \"{sourceName}\"",
|
||||
"importing_data": "Importing data...",
|
||||
"importing_historical_data": "Importing historical data...",
|
||||
"invalid_enum_values": "Invalid values in column mapped to {field}",
|
||||
"invalid_values_found": "Found: {values} (rows: {rows}) {extra}",
|
||||
"load_sample_csv": "Load sample CSV",
|
||||
"n_supported_questions": "{count} supported questions",
|
||||
"manage_directories": "Manage directories",
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"metadata": "Metadata",
|
||||
"metadata_key": "Metadata key",
|
||||
"metadata_read_only_entries": "Read-only metadata values (non-string)",
|
||||
"metadata_value": "Metadata value",
|
||||
"missing_feedback_source_title": "Missing feedback source?",
|
||||
"no_feedback_record_directory_available": "No feedback record directory assigned to this workspace. Create or assign one first.",
|
||||
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
|
||||
"no_source_fields_loaded": "No source fields loaded yet",
|
||||
"no_sources_connected": "No sources connected yet. Add a source to get started.",
|
||||
"no_surveys_found": "No surveys found in this environment",
|
||||
"optional": "Optional",
|
||||
"or_drag_and_drop": "or drag and drop",
|
||||
"question_selected": "<strong>{count}</strong> question selected. Each response to these questions will create a new Feedback Record.",
|
||||
"question_type_not_supported": "This question type is not supported",
|
||||
"questions_selected": "<strong>{count}</strong> questions selected. Each response to these questions will create a new Feedback Record.",
|
||||
"records_will_go_to": "Records will go to",
|
||||
"refresh_feedback_records": "Refresh feedback records",
|
||||
"refreshing_feedback_records": "Refreshing feedback records...",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "Required",
|
||||
"save_changes": "Save changes",
|
||||
"select_a_survey_to_see_questions": "Select a survey to see its questions",
|
||||
"select_a_value": "Select a value...",
|
||||
"select_all": "Select all",
|
||||
"select_feedback_record_directory": "Select a directory",
|
||||
"select_feedback_record_source_type": "Select source type",
|
||||
"select_questions": "Select questions",
|
||||
"select_source_type_description": "Select the type of feedback source you want to connect.",
|
||||
"select_source_type_prompt": "Select the type of feedback source you want to connect:",
|
||||
"select_survey": "Select Survey",
|
||||
"select_survey_and_questions": "Select Survey & Questions",
|
||||
"select_survey_questions_description": "Choose which survey questions should create FeedbackRecords.",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "Showing 3 of {count} rows",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "Import feedback from CSV files",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "Connect feedback from your Formbricks surveys",
|
||||
"source_fields": "Source Fields",
|
||||
"source_id": "Source ID",
|
||||
"source_name": "Source Name",
|
||||
"source_type": "Source Type",
|
||||
"source_type_cannot_be_changed": "Source type cannot be changed",
|
||||
"sources": "Sources",
|
||||
"status_active": "In Progress",
|
||||
"status_completed": "Completed",
|
||||
"status_draft": "Draft",
|
||||
"status_error": "Error",
|
||||
"status_live_sync": "Live sync",
|
||||
"status_paused": "Paused",
|
||||
"status_ready": "Ready",
|
||||
"submission_id": "Submission ID",
|
||||
"survey_has_no_questions": "This survey has no questions",
|
||||
"survey_import_line": "{surveyName}: {responseCount} responses × {questionCount} questions = {total} Feedback Records",
|
||||
"total_feedback_records": "Total: {checked} of {total} Feedback Records selected across {surveyCount} surveys",
|
||||
"topics_and_subtopics": "Topics & Subtopics",
|
||||
"unify_feedback": "Unify Feedback",
|
||||
"update_mapping_description": "Update the mapping configuration for this source.",
|
||||
"updated_at": "Updated at",
|
||||
"upload_csv_data_description": "Upload a CSV file to import feedback data.",
|
||||
"upload_csv_file": "Upload CSV File",
|
||||
"user_identifier": "User",
|
||||
"value": "Value"
|
||||
"value": "Value",
|
||||
"value_boolean": "Value (Boolean)",
|
||||
"value_date": "Value (Date)",
|
||||
"value_number": "Value (Number)",
|
||||
"value_text": "Value (Text)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "Actividad",
|
||||
"add": "Añadir",
|
||||
"add_action": "Añadir acción",
|
||||
"add_chart": "Agregar gráfico",
|
||||
"add_charts": "Añadir gráficos",
|
||||
"add_existing_chart_description": "Busca y selecciona gráficos para añadir a este panel.",
|
||||
"add_filter": "Añadir filtro",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "No estás autenticado para realizar esta acción.",
|
||||
"not_authorized": "No autorizado",
|
||||
"not_connected": "No conectado",
|
||||
"not_set": "No establecido",
|
||||
"note": "Nota",
|
||||
"notifications": "Notificaciones",
|
||||
"number": "Número",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "Reportar encuesta",
|
||||
"request_trial_license": "Solicitar licencia de prueba",
|
||||
"reset_to_default": "Restablecer a valores predeterminados",
|
||||
"resize": "Cambiar tamaño",
|
||||
"response": "Respuesta",
|
||||
"response_id": "ID de respuesta",
|
||||
"responses": "Respuestas",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "Algunos archivos no se han podido subir",
|
||||
"something_went_wrong": "Algo ha salido mal",
|
||||
"something_went_wrong_please_try_again": "Algo ha salido mal. Por favor, inténtalo de nuevo.",
|
||||
"soon": "Próximamente",
|
||||
"sort_by": "Ordenar por",
|
||||
"start_free_trial": "Iniciar prueba gratuita",
|
||||
"status": "Estado",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "Selecciona un panel de control",
|
||||
"predefined_measures": "Medidas predefinidas",
|
||||
"preset": "Preajuste",
|
||||
"preview_chart": "Vista previa del gráfico",
|
||||
"query_executed_successfully": "Consulta ejecutada correctamente",
|
||||
"reset_to_ai_suggestion": "Restablecer a sugerencia de IA",
|
||||
"save_and_add_to_dashboard": "Guardar y agregar al panel",
|
||||
"save_chart": "Guardar gráfico",
|
||||
"save_chart_dialog_title": "Guardar gráfico",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "Fecha de inicio",
|
||||
"time_dimension": "Dimensión temporal",
|
||||
"time_dimension_title": "Añadir agrupación temporal",
|
||||
"time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo."
|
||||
"time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo.",
|
||||
"update_chart": "Cuadro de actualización"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Añadir {count} gráfico(s)",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "Crear panel",
|
||||
"create_dashboard_description": "Introduce un nombre para tu panel de control nuevo.",
|
||||
"create_failed": "Error al crear el panel de control",
|
||||
"create_new_chart": "Crear nuevo gráfico",
|
||||
"create_success": "Panel de control creado correctamente",
|
||||
"dashboard": "Panel",
|
||||
"dashboard_delete_confirmation": "¿Estás seguro de que quieres eliminar este panel? Esta acción no se puede deshacer.",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "Error al duplicar el panel de control",
|
||||
"duplicate_success": "Panel de control duplicado correctamente",
|
||||
"failed_to_load_chart_data": "Error al cargar los datos del gráfico",
|
||||
"no_charts_available_description": "No hay gráficos que se puedan añadir a este panel. O bien no existen gráficos todavía, o todos los gráficos existentes ya se han añadido. Ve a la página de Gráficos para crear nuevos gráficos.",
|
||||
"no_charts_to_add_message": "No hay gráficos para añadir a este panel.",
|
||||
"no_dashboards_found": "No se han encontrado paneles de control.",
|
||||
"no_data_message": "Sin datos. Actualmente no hay información que mostrar. Añade gráficos para crear tu panel.",
|
||||
"please_enter_name": "Por favor, introduce un nombre para el panel de control"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "No tienes registros de comentarios sobre los que informar. Configure fuentes de comentarios para introducir datos en el sistema.",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "Configurar fuentes de comentarios"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Añadir clave API",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "Directorios de Feedback",
|
||||
"no_access": "No tienes permiso para gestionar los directorios de registros de feedback.",
|
||||
"no_connectors": "Aún no hay conectores vinculados a este directorio.",
|
||||
"pause_connectors_confirmation_description": "Si pausas estos conectores, no se añadirán nuevos registros.",
|
||||
"pause_connectors_confirmation_title": "¿Pausar conectores vinculados?",
|
||||
"select_workspaces_placeholder": "Selecciona espacios de trabajo...",
|
||||
"show_archived": "Mostrar archivados",
|
||||
"title": "Directorios de Registros de Feedback",
|
||||
"unarchive": "Desarchivar"
|
||||
"unarchive": "Desarchivar",
|
||||
"unarchive_workspace_conflict": "No se puede desarchivar este directorio porque uno o más espacios de trabajo asignados están archivados.",
|
||||
"workspace_access": "Acceso al espacio de trabajo"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "Consulta qué equipos pueden acceder a este espacio de trabajo."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Agregar registro de comentarios",
|
||||
"add_feedback_record_description": "Cree un registro de comentarios manualmente.",
|
||||
"add_feedback_source": "Añadir fuente de feedback",
|
||||
"add_source": "Añadir fuente",
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"api_ingestion": "Ingesta de API",
|
||||
"api_ingestion_manage_api_keys": "Gestionar claves de API",
|
||||
"api_ingestion_settings_description": "Envía registros de feedback mediante la API de gestión.",
|
||||
"auto_generated": "Generado automáticamente",
|
||||
"change_file": "Cambiar archivo",
|
||||
"click_load_sample_csv": "Haz clic en 'Cargar CSV de muestra' para ver las columnas",
|
||||
"click_to_upload": "Haz clic para subir",
|
||||
"collected_at": "Recopilado el",
|
||||
"configure_import": "Configurar importación",
|
||||
"configure_mapping": "Configurar asignación",
|
||||
"connection": "Conexión",
|
||||
"connector_created_successfully": "Conector creado correctamente",
|
||||
"connector_deleted_successfully": "Conector eliminado correctamente",
|
||||
"connector_duplicated_successfully": "Conector duplicado correctamente",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "Importar datos dos veces creará registros duplicados.",
|
||||
"csv_inconsistent_columns": "La fila {row} tiene columnas inconsistentes. Todas las filas deben tener los mismos encabezados.",
|
||||
"csv_max_records": "Máximo de {max} registros permitidos.",
|
||||
"custom_source_type": "Tipo de fuente personalizado",
|
||||
"custom_source_type_placeholder": "Ingrese el tipo de fuente personalizado",
|
||||
"default_connector_name_csv": "Importación CSV",
|
||||
"default_connector_name_formbricks": "Conexión de encuesta de Formbricks",
|
||||
"deselect_all": "Deseleccionar todo",
|
||||
"discard_feedback_record_changes_description": "Sus cambios se perderán si cierra este cajón.",
|
||||
"discard_feedback_record_changes_title": "¿Descartar los cambios no guardados?",
|
||||
"drop_a_field_here": "Suelta un campo aquí",
|
||||
"drop_field_or": "Suelta el campo o",
|
||||
"edit_csv_mapping": "Editar mapeo de CSV",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
|
||||
"feedback_date": "Fecha actual",
|
||||
"feedback_record_created_successfully": "Registro de comentarios creado correctamente",
|
||||
"feedback_record_details": "Detalles del registro de comentarios",
|
||||
"feedback_record_details_description": "Revise y actualice los campos del registro de comentarios.",
|
||||
"feedback_record_directory": "Directorio de Registros de Comentarios",
|
||||
"feedback_record_fields": "Campos de registro de comentarios",
|
||||
"feedback_record_mcp": "MCP de registros de feedback",
|
||||
"feedback_record_updated_successfully": "Registro de comentarios actualizado correctamente",
|
||||
"feedback_record_value_required": "Se requiere un valor para el tipo de campo seleccionado",
|
||||
"feedback_records": "Registros de comentarios",
|
||||
"feedback_records_refreshed": "Registros de comentarios actualizados",
|
||||
"feedback_sources": "Fuentes de feedback",
|
||||
"feedback_sources_directory_access_multiple": "Los nuevos registros de estas fuentes se almacenarán en: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Los nuevos registros de esta fuente se almacenarán en: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Conecta y gestiona todas las fuentes de feedback para este espacio de trabajo.",
|
||||
"field_group_id": "ID de grupo de campos",
|
||||
"field_group_label": "Etiqueta de grupo de campos",
|
||||
"field_id": "ID de campo",
|
||||
"field_label": "Etiqueta de campo",
|
||||
"field_type": "Tipo de campo",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "El directorio de comentarios no se puede cambiar después de su creación.",
|
||||
"go_to_feedback_record_directories": "Ir a la configuración de directorios",
|
||||
"historical_import_complete": "Importación completada: {successes} correctas, {failures} fallidas, {skipped} omitidas (sin datos)",
|
||||
"import_csv_data": "Importar comentarios",
|
||||
"import_feedback": "Importar comentarios",
|
||||
"import_historical_responses": "Importar respuestas históricas",
|
||||
"import_historical_responses_description": "Importa las respuestas existentes de esta encuesta ahora.",
|
||||
"import_rows": "Importar {count} filas",
|
||||
"import_via_source_name": "Importar mediante \"{sourceName}\"",
|
||||
"importing_data": "Importando datos...",
|
||||
"importing_historical_data": "Importando datos históricos...",
|
||||
"invalid_enum_values": "Valores no válidos en la columna asignada a {field}",
|
||||
"invalid_values_found": "Encontrados: {values} (filas: {rows}) {extra}",
|
||||
"load_sample_csv": "Cargar CSV de muestra",
|
||||
"n_supported_questions": "{count} preguntas compatibles",
|
||||
"manage_directories": "Gestionar directorios",
|
||||
"manage_feedback_sources": "Administrar fuentes de comentarios",
|
||||
"metadata": "Metadatos",
|
||||
"metadata_key": "Clave de metadatos",
|
||||
"metadata_read_only_entries": "Valores de metadatos de solo lectura (no cadenas)",
|
||||
"metadata_value": "Valor de metadatos",
|
||||
"missing_feedback_source_title": "¿Falta alguna fuente de feedback?",
|
||||
"no_feedback_record_directory_available": "No hay ningún directorio de registros de comentarios asignado a este espacio de trabajo. Crea o asigna uno primero.",
|
||||
"no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.",
|
||||
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
|
||||
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
|
||||
"no_surveys_found": "No se encontraron encuestas en este entorno",
|
||||
"optional": "Opcional",
|
||||
"or_drag_and_drop": "o arrastra y suelta",
|
||||
"question_selected": "<strong>{count}</strong> pregunta seleccionada. Cada respuesta a esta pregunta creará un registro de feedback nuevo.",
|
||||
"question_type_not_supported": "Este tipo de pregunta no es compatible",
|
||||
"questions_selected": "<strong>{count}</strong> preguntas seleccionadas. Cada respuesta a estas preguntas creará un registro de feedback nuevo.",
|
||||
"records_will_go_to": "Los registros se enviarán a",
|
||||
"refresh_feedback_records": "Actualizar los registros de comentarios",
|
||||
"refreshing_feedback_records": "Actualizando registros de comentarios...",
|
||||
"request_feedback_source": "Solicitar integración de fuente",
|
||||
"required": "Obligatorio",
|
||||
"save_changes": "Guardar cambios",
|
||||
"select_a_survey_to_see_questions": "Selecciona una encuesta para ver sus preguntas",
|
||||
"select_a_value": "Selecciona un valor...",
|
||||
"select_all": "Seleccionar todo",
|
||||
"select_feedback_record_directory": "Selecciona un directorio",
|
||||
"select_feedback_record_source_type": "Seleccionar tipo de fuente",
|
||||
"select_questions": "Seleccionar preguntas",
|
||||
"select_source_type_description": "Selecciona el tipo de fuente de feedback que quieres conectar.",
|
||||
"select_source_type_prompt": "Selecciona el tipo de fuente de feedback que quieres conectar:",
|
||||
"select_survey": "Seleccionar encuesta",
|
||||
"select_survey_and_questions": "Seleccionar encuesta y preguntas",
|
||||
"select_survey_questions_description": "Elige qué preguntas de la encuesta deben crear FeedbackRecords.",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "Mostrando 3 de {count} filas",
|
||||
"source": "origen",
|
||||
"source_connect_csv_description": "Importar feedback desde archivos CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Envía registros de feedback a través de la integración MCP.",
|
||||
"source_connect_formbricks_description": "Conectar feedback de tus encuestas de Formbricks",
|
||||
"source_fields": "Campos de origen",
|
||||
"source_id": "ID de fuente",
|
||||
"source_name": "Nombre de origen",
|
||||
"source_type": "Tipo de fuente",
|
||||
"source_type_cannot_be_changed": "El tipo de origen no se puede cambiar",
|
||||
"sources": "Orígenes",
|
||||
"status_active": "En progreso",
|
||||
"status_completed": "Completado",
|
||||
"status_draft": "Borrador",
|
||||
"status_error": "Error",
|
||||
"status_live_sync": "Sincronización en vivo",
|
||||
"status_paused": "Pausado",
|
||||
"status_ready": "Listo",
|
||||
"submission_id": "ID de envío",
|
||||
"survey_has_no_questions": "Esta encuesta no tiene preguntas",
|
||||
"survey_import_line": "{surveyName}: {responseCount} respuestas × {questionCount} preguntas = {total} registros de feedback",
|
||||
"total_feedback_records": "Total: {checked} de {total} registros de feedback seleccionados en {surveyCount} encuestas",
|
||||
"topics_and_subtopics": "Temas y subtemas",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Actualiza la configuración de mapeo para esta fuente.",
|
||||
"updated_at": "Actualizado el",
|
||||
"upload_csv_data_description": "Sube un archivo CSV para importar datos de comentarios.",
|
||||
"upload_csv_file": "Subir archivo CSV",
|
||||
"user_identifier": "Usuario",
|
||||
"value": "Valor"
|
||||
"value": "Valor",
|
||||
"value_boolean": "Valor (booleano)",
|
||||
"value_date": "Valor (Fecha)",
|
||||
"value_number": "Valor (Número)",
|
||||
"value_text": "Valor (Texto)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "Activité",
|
||||
"add": "Ajouter",
|
||||
"add_action": "Ajouter une action",
|
||||
"add_chart": "Ajouter un graphique",
|
||||
"add_charts": "Ajouter des graphiques",
|
||||
"add_existing_chart_description": "Recherchez et sélectionnez des graphiques à ajouter à ce tableau de bord.",
|
||||
"add_filter": "Ajouter un filtre",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Vous n'êtes pas authentifié pour effectuer cette action.",
|
||||
"not_authorized": "Non autorisé",
|
||||
"not_connected": "Non connecté",
|
||||
"not_set": "Non défini",
|
||||
"note": "Remarque",
|
||||
"notifications": "Notifications",
|
||||
"number": "Numéro",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "Rapport d'enquête",
|
||||
"request_trial_license": "Demander une licence d'essai",
|
||||
"reset_to_default": "Réinitialiser par défaut",
|
||||
"resize": "Redimensionner",
|
||||
"response": "Réponse",
|
||||
"response_id": "ID de réponse",
|
||||
"responses": "Réponses",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "Certains fichiers n'ont pas pu être téléchargés",
|
||||
"something_went_wrong": "Quelque chose s'est mal passé.",
|
||||
"something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.",
|
||||
"soon": "Bientôt",
|
||||
"sort_by": "Trier par",
|
||||
"start_free_trial": "Commencer l'essai gratuit",
|
||||
"status": "Statut",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "Veuillez sélectionner un tableau de bord",
|
||||
"predefined_measures": "Mesures prédéfinies",
|
||||
"preset": "Préréglage",
|
||||
"preview_chart": "Aperçu du graphique",
|
||||
"query_executed_successfully": "Requête exécutée avec succès",
|
||||
"reset_to_ai_suggestion": "Réinitialiser à la suggestion IA",
|
||||
"save_and_add_to_dashboard": "Enregistrer et ajouter au tableau de bord",
|
||||
"save_chart": "Enregistrer le graphique",
|
||||
"save_chart_dialog_title": "Enregistrer le graphique",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "Date de début",
|
||||
"time_dimension": "Dimension temporelle",
|
||||
"time_dimension_title": "Ajouter un groupement temporel",
|
||||
"time_dimension_toggle_description": "Surveille les tendances dans le temps."
|
||||
"time_dimension_toggle_description": "Surveille les tendances dans le temps.",
|
||||
"update_chart": "Mettre à jour le graphique"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Ajouter {count} graphique(s)",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "Créer un tableau de bord",
|
||||
"create_dashboard_description": "Saisissez un nom pour votre nouveau tableau de bord.",
|
||||
"create_failed": "Échec de la création du tableau de bord",
|
||||
"create_new_chart": "Créer un nouveau graphique",
|
||||
"create_success": "Tableau de bord créé avec succès !",
|
||||
"dashboard": "Tableau de bord",
|
||||
"dashboard_delete_confirmation": "Es-tu sûr(e) de vouloir supprimer ce tableau de bord ? Cette action est irréversible.",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "Échec de la duplication du tableau de bord",
|
||||
"duplicate_success": "Tableau de bord dupliqué avec succès !",
|
||||
"failed_to_load_chart_data": "Échec du chargement des données du graphique",
|
||||
"no_charts_available_description": "Il n'y a aucun graphique pouvant être ajouté à ce tableau de bord. Soit aucun graphique n'existe encore, soit tous les graphiques existants ont déjà été ajoutés. Rendez-vous sur la page Graphiques pour créer de nouveaux graphiques.",
|
||||
"no_charts_to_add_message": "Aucun graphique à ajouter à ce tableau de bord.",
|
||||
"no_dashboards_found": "Aucun tableau de bord trouvé.",
|
||||
"no_data_message": "Aucune donnée. Il n'y a actuellement aucune information à afficher. Ajoute des graphiques pour construire ton tableau de bord.",
|
||||
"please_enter_name": "Veuillez saisir un nom de tableau de bord"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "Vous n'avez pas d'enregistrements de commentaires sur lesquels créer des rapports. Configurez des sources de commentaires pour introduire des données dans le système.",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "Configurer les sources de commentaires"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Ajouter une clé API",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "Répertoires de feedback",
|
||||
"no_access": "Tu n'as pas la permission de gérer les répertoires de feedback.",
|
||||
"no_connectors": "Aucun connecteur lié à ce répertoire pour le moment.",
|
||||
"pause_connectors_confirmation_description": "Si vous mettez ces connecteurs en pause, aucun nouvel enregistrement ne sera ajouté.",
|
||||
"pause_connectors_confirmation_title": "Mettre en pause les connecteurs liés ?",
|
||||
"select_workspaces_placeholder": "Sélectionner des espaces de travail...",
|
||||
"show_archived": "Afficher les éléments archivés",
|
||||
"title": "Répertoires d'enregistrement des retours",
|
||||
"unarchive": "Désarchiver"
|
||||
"unarchive": "Désarchiver",
|
||||
"unarchive_workspace_conflict": "Impossible de désarchiver ce répertoire, car un ou plusieurs espaces de travail attribués sont archivés.",
|
||||
"workspace_access": "Accès à l’espace de travail"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "Voir quelles équipes peuvent accéder à cet espace de travail."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Ajouter un enregistrement de commentaires",
|
||||
"add_feedback_record_description": "Créez manuellement un enregistrement de commentaires.",
|
||||
"add_feedback_source": "Ajouter une source de feedback",
|
||||
"add_source": "Ajouter une source",
|
||||
"allowed_values": "Valeurs autorisées : {values}",
|
||||
"api_ingestion": "Ingestion par API",
|
||||
"api_ingestion_manage_api_keys": "Gérer les clés API",
|
||||
"api_ingestion_settings_description": "Envoyer des enregistrements de feedback via l'API de gestion.",
|
||||
"auto_generated": "Généré automatiquement",
|
||||
"change_file": "Changer de fichier",
|
||||
"click_load_sample_csv": "Clique sur « Charger un exemple CSV » pour voir les colonnes",
|
||||
"click_to_upload": "Clique pour charger",
|
||||
"collected_at": "Collecté le",
|
||||
"configure_import": "Configurer l'importation",
|
||||
"configure_mapping": "Configurer le mappage",
|
||||
"connection": "Connexion",
|
||||
"connector_created_successfully": "Connecteur créé avec succès",
|
||||
"connector_deleted_successfully": "Connecteur supprimé avec succès",
|
||||
"connector_duplicated_successfully": "Connecteur dupliqué avec succès",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "Importer les données deux fois créera des enregistrements en double.",
|
||||
"csv_inconsistent_columns": "La ligne {row} a des colonnes incohérentes. Toutes les lignes doivent avoir les mêmes en-têtes.",
|
||||
"csv_max_records": "Maximum {max} enregistrements autorisés.",
|
||||
"custom_source_type": "Type de source personnalisé",
|
||||
"custom_source_type_placeholder": "Entrez le type de source personnalisé",
|
||||
"default_connector_name_csv": "Importation CSV",
|
||||
"default_connector_name_formbricks": "Connexion de sondage Formbricks",
|
||||
"deselect_all": "Tout désélectionner",
|
||||
"discard_feedback_record_changes_description": "Vos modifications seront perdues si vous fermez ce tiroir.",
|
||||
"discard_feedback_record_changes_title": "Supprimer les modifications non enregistrées ?",
|
||||
"drop_a_field_here": "Déposez un champ ici",
|
||||
"drop_field_or": "Déposez un champ ou",
|
||||
"edit_csv_mapping": "Modifier le mappage CSV",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
|
||||
"feedback_date": "Date actuelle",
|
||||
"feedback_record_created_successfully": "Enregistrement de commentaires créé avec succès",
|
||||
"feedback_record_details": "Détails de l'enregistrement des commentaires",
|
||||
"feedback_record_details_description": "Examiner et mettre à jour les champs d’enregistrement des commentaires.",
|
||||
"feedback_record_directory": "Répertoire d'enregistrements de retour d'expérience",
|
||||
"feedback_record_fields": "Champs d'enregistrement de feedback",
|
||||
"feedback_record_mcp": "MCP d'enregistrement de feedback",
|
||||
"feedback_record_updated_successfully": "L'enregistrement des commentaires a été mis à jour avec succès",
|
||||
"feedback_record_value_required": "Une valeur est requise pour le type de champ sélectionné",
|
||||
"feedback_records": "Enregistrements de feedback",
|
||||
"feedback_records_refreshed": "Enregistrements de feedback actualisés",
|
||||
"feedback_sources": "Sources de feedback",
|
||||
"feedback_sources_directory_access_multiple": "Les nouveaux enregistrements de ces sources seront stockés dans : {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Les nouveaux enregistrements de cette source seront stockés dans : {directoryNames}",
|
||||
"feedback_sources_settings_description": "Connecte et gère toutes les sources de feedback pour cet espace de travail.",
|
||||
"field_group_id": "ID de groupe de champs",
|
||||
"field_group_label": "Libellé du groupe de champs",
|
||||
"field_id": "Identifiant du champ",
|
||||
"field_label": "Libellé du champ",
|
||||
"field_type": "Type de champ",
|
||||
"formbricks_surveys": "Sondages Formbricks",
|
||||
"frd_cannot_be_changed": "Le répertoire de retours d'expérience ne peut pas être modifié après sa création.",
|
||||
"go_to_feedback_record_directories": "Accéder aux paramètres des répertoires",
|
||||
"historical_import_complete": "Importation terminée : {successes} réussies, {failures} échouées, {skipped} ignorées (aucune donnée)",
|
||||
"import_csv_data": "Importer les retours",
|
||||
"import_feedback": "Importer les retours",
|
||||
"import_historical_responses": "Importer les réponses historiques",
|
||||
"import_historical_responses_description": "Importe les réponses existantes de cette enquête maintenant.",
|
||||
"import_rows": "Importer {count} lignes",
|
||||
"import_via_source_name": "Importer via \"{sourceName}\"",
|
||||
"importing_data": "Importation des données...",
|
||||
"importing_historical_data": "Importation des données historiques...",
|
||||
"invalid_enum_values": "Valeurs non valides dans la colonne mappée à {field}",
|
||||
"invalid_values_found": "Trouvées : {values} (lignes : {rows}) {extra}",
|
||||
"load_sample_csv": "Charger un exemple de CSV",
|
||||
"n_supported_questions": "{count} questions prises en charge",
|
||||
"manage_directories": "Gérer les répertoires",
|
||||
"manage_feedback_sources": "Gérer les sources de commentaires",
|
||||
"metadata": "Métadonnées",
|
||||
"metadata_key": "Clé de métadonnées",
|
||||
"metadata_read_only_entries": "Valeurs de métadonnées en lecture seule (non-chaîne)",
|
||||
"metadata_value": "Valeur des métadonnées",
|
||||
"missing_feedback_source_title": "Il manque une source de feedback ?",
|
||||
"no_feedback_record_directory_available": "Aucun répertoire d'enregistrements de retour d'expérience n'est assigné à cet espace de travail. Créez-en un ou assignez-en un d'abord.",
|
||||
"no_feedback_records": "Aucun enregistrement de feedback pour le moment. Les enregistrements apparaîtront ici une fois que vos connecteurs commenceront à envoyer des données.",
|
||||
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
|
||||
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
|
||||
"no_surveys_found": "Aucune enquête trouvée dans cet environnement",
|
||||
"optional": "Facultatif",
|
||||
"or_drag_and_drop": "ou glisser-déposer",
|
||||
"question_selected": "<strong>{count}</strong> question sélectionnée. Chaque réponse à cette question créera un nouvel enregistrement de feedback.",
|
||||
"question_type_not_supported": "Ce type de question n'est pas pris en charge",
|
||||
"questions_selected": "<strong>{count}</strong> questions sélectionnées. Chaque réponse à ces questions créera un nouvel enregistrement de feedback.",
|
||||
"records_will_go_to": "Les enregistrements seront envoyés vers",
|
||||
"refresh_feedback_records": "Actualiser les enregistrements de retours",
|
||||
"refreshing_feedback_records": "Actualisation des enregistrements de feedback...",
|
||||
"request_feedback_source": "Demander une intégration de source",
|
||||
"required": "Requis",
|
||||
"save_changes": "Enregistrer les modifications",
|
||||
"select_a_survey_to_see_questions": "Sélectionnez une enquête pour voir ses questions",
|
||||
"select_a_value": "Sélectionnez une valeur...",
|
||||
"select_all": "Sélectionner tout",
|
||||
"select_feedback_record_directory": "Sélectionner un répertoire",
|
||||
"select_feedback_record_source_type": "Sélectionnez le type de source",
|
||||
"select_questions": "Sélectionner les questions",
|
||||
"select_source_type_description": "Sélectionnez le type de source de feedback que vous souhaitez connecter.",
|
||||
"select_source_type_prompt": "Sélectionnez le type de source de feedback que vous souhaitez connecter :",
|
||||
"select_survey": "Sélectionner l'enquête",
|
||||
"select_survey_and_questions": "Sélectionner l'enquête et les questions",
|
||||
"select_survey_questions_description": "Choisissez quelles questions d'enquête doivent créer des FeedbackRecords.",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "Affichage de 3 sur {count} lignes",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "Importer des feedbacks depuis des fichiers CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Envoyer des enregistrements de feedback via l'intégration MCP.",
|
||||
"source_connect_formbricks_description": "Connecter les feedbacks de vos enquêtes Formbricks",
|
||||
"source_fields": "Champs source",
|
||||
"source_id": "Identifiant de la source",
|
||||
"source_name": "Nom de la source",
|
||||
"source_type": "Type de source",
|
||||
"source_type_cannot_be_changed": "Le type de source ne peut pas être modifié",
|
||||
"sources": "Sources",
|
||||
"status_active": "En cours",
|
||||
"status_completed": "Terminé",
|
||||
"status_draft": "Brouillon",
|
||||
"status_error": "Erreur",
|
||||
"status_live_sync": "Synchronisation en direct",
|
||||
"status_paused": "En pause",
|
||||
"status_ready": "Prêt",
|
||||
"submission_id": "ID de soumission",
|
||||
"survey_has_no_questions": "Ce sondage n'a pas de questions",
|
||||
"survey_import_line": "{surveyName} : {responseCount} réponses × {questionCount} questions = {total} enregistrements de feedback",
|
||||
"total_feedback_records": "Total : {checked} sur {total} enregistrements de feedback sélectionnés parmi {surveyCount} sondages",
|
||||
"topics_and_subtopics": "Thèmes et sous-thèmes",
|
||||
"unify_feedback": "Unifier les retours",
|
||||
"update_mapping_description": "Mettre à jour la configuration de mappage pour cette source.",
|
||||
"updated_at": "Mis à jour à",
|
||||
"upload_csv_data_description": "Téléchargez un fichier CSV pour importer des données de feedback.",
|
||||
"upload_csv_file": "Télécharger un fichier CSV",
|
||||
"user_identifier": "Utilisateur",
|
||||
"value": "Valeur"
|
||||
"value": "Valeur",
|
||||
"value_boolean": "Valeur (booléenne)",
|
||||
"value_date": "Valeur (Date)",
|
||||
"value_number": "Valeur (Nombre)",
|
||||
"value_text": "Valeur (texte)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "Tevékenység",
|
||||
"add": "Hozzáadás",
|
||||
"add_action": "Művelet hozzáadása",
|
||||
"add_chart": "Diagram hozzáadása",
|
||||
"add_charts": "Diagramok hozzáadása",
|
||||
"add_existing_chart_description": "Keressen és válasszon diagramokat a műszerfalhoz való hozzáadáshoz.",
|
||||
"add_filter": "Szűrő hozzáadása",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Nincs jogosultsága ennek a műveletnek a végrehajtásához.",
|
||||
"not_authorized": "Nincs felhatalmazva",
|
||||
"not_connected": "Nincs kapcsolódva",
|
||||
"not_set": "Nincs beállítva",
|
||||
"note": "Jegyzet",
|
||||
"notifications": "Értesítések",
|
||||
"number": "Szám",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "Kérdőív jelentése",
|
||||
"request_trial_license": "Próbaidőszaki licenc kérése",
|
||||
"reset_to_default": "Visszaállítás az alapértelmezettre",
|
||||
"resize": "Átméretezés",
|
||||
"response": "Válasz",
|
||||
"response_id": "Válaszazonosító",
|
||||
"responses": "Válaszok",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "Néhány fájlt nem sikerült feltölteni",
|
||||
"something_went_wrong": "Valami probléma történt",
|
||||
"something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.",
|
||||
"soon": "Hamarosan",
|
||||
"sort_by": "Rendezési sorrend",
|
||||
"start_free_trial": "Ingyenes próbaidőszak indítása",
|
||||
"status": "Állapot",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "Kérjük, válassz egy vezérlőpultot",
|
||||
"predefined_measures": "Előre definiált mérőszámok",
|
||||
"preset": "Előbeállítás",
|
||||
"preview_chart": "Előnézet diagram",
|
||||
"query_executed_successfully": "Lekérdezés sikeresen végrehajtva",
|
||||
"reset_to_ai_suggestion": "Visszaállítás AI javaslatra",
|
||||
"save_and_add_to_dashboard": "Mentés és hozzáadása az irányítópulthoz",
|
||||
"save_chart": "Diagram mentése",
|
||||
"save_chart_dialog_title": "Diagram mentése",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "Kezdési dátum",
|
||||
"time_dimension": "Időbeli dimenzió",
|
||||
"time_dimension_title": "Időalapú csoportosítás hozzáadása",
|
||||
"time_dimension_toggle_description": "Trendek figyelemmel kísérése az idő függvényében."
|
||||
"time_dimension_toggle_description": "Trendek figyelemmel kísérése az idő függvényében.",
|
||||
"update_chart": "Frissítse a diagramot"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} diagram hozzáadása",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "Műszerfal létrehozása",
|
||||
"create_dashboard_description": "Adjon nevet az új vezérlőpultnak.",
|
||||
"create_failed": "A vezérlőpult létrehozása sikertelen",
|
||||
"create_new_chart": "Új diagram létrehozása",
|
||||
"create_success": "A vezérlőpult sikeresen létrehozva!",
|
||||
"dashboard": "Műszerfal",
|
||||
"dashboard_delete_confirmation": "Biztos benne, hogy törölni kívánja ezt a műszerfalat? Ez a művelet nem vonható vissza.",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "A vezérlőpult másolása sikertelen",
|
||||
"duplicate_success": "A vezérlőpult sikeresen lemásolva!",
|
||||
"failed_to_load_chart_data": "A diagram adatainak betöltése sikertelen",
|
||||
"no_charts_available_description": "Nincsenek diagramok, amelyek hozzáadhatók ehhez az irányítópulthoz. Vagy még nem léteznek diagramok, vagy az összes meglévő diagram már hozzá lett adva. Látogassa meg a Diagramok oldalt új diagramok létrehozásához.",
|
||||
"no_charts_to_add_message": "Nincsenek hozzáadható diagramok ehhez az irányítópulthoz.",
|
||||
"no_dashboards_found": "Nem található vezérlőpult.",
|
||||
"no_data_message": "Nincsenek adatok. Jelenleg nincsenek megjeleníthető információk. Adjon hozzá diagramokat az irányítópult felépítéséhez.",
|
||||
"please_enter_name": "Kérjük, adjon nevet a vezérlőpultnak"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "Nincsenek visszajelzési rekordjai, amelyekről jelentést tehetne. Állítsa be a visszacsatolási forrásokat, hogy adatokat tápláljon be a rendszerbe.",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "Visszajelzési források beállítása"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "API-kulcs hozzáadása",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "Visszajelzési könyvtárak",
|
||||
"no_access": "Nem rendelkezik jogosultsággal a visszajelzési nyilvántartási könyvtárak kezeléséhez.",
|
||||
"no_connectors": "Még nincsenek csatlakozók társítva ehhez a könyvtárhoz.",
|
||||
"pause_connectors_confirmation_description": "Ha szünetelteti ezeket a csatlakozókat, nem kerülnek be új rekordok.",
|
||||
"pause_connectors_confirmation_title": "Szünetelteti a kapcsolódó csatlakozókat?",
|
||||
"select_workspaces_placeholder": "Munkaterületek kiválasztása...",
|
||||
"show_archived": "Archivált elemek megjelenítése",
|
||||
"title": "Visszajelzési Nyilvántartási Könyvtárak",
|
||||
"unarchive": "Archiválás visszavonása"
|
||||
"unarchive": "Archiválás visszavonása",
|
||||
"unarchive_workspace_conflict": "A könyvtár nem állítható vissza, mert egy vagy több hozzárendelt munkaterület archiválva van.",
|
||||
"workspace_access": "Munkaterület-hozzáférés"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "Annak megtekintése, hogy mely csapatok férhetnek hozzá ehhez a munkaterülethez."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Visszajelzés hozzáadása",
|
||||
"add_feedback_record_description": "Készítsen visszajelzési rekordot manuálisan.",
|
||||
"add_feedback_source": "Visszajelzési forrás hozzáadása",
|
||||
"add_source": "Forrás hozzáadása",
|
||||
"allowed_values": "Engedélyezett értékek: {values}",
|
||||
"api_ingestion": "API betöltés",
|
||||
"api_ingestion_manage_api_keys": "API kulcsok kezelése",
|
||||
"api_ingestion_settings_description": "Visszajelzési rekordok küldése a Management API használatával.",
|
||||
"auto_generated": "Automatikusan generált",
|
||||
"change_file": "Fájl módosítása",
|
||||
"click_load_sample_csv": "Kattintson a 'Minta CSV betöltése' gombra az oszlopok megtekintéséhez",
|
||||
"click_to_upload": "Kattintson a feltöltéshez",
|
||||
"collected_at": "Gyűjtve",
|
||||
"configure_import": "Importálás konfigurálása",
|
||||
"configure_mapping": "Leképezés konfigurálása",
|
||||
"connection": "Kapcsolat",
|
||||
"connector_created_successfully": "Csatlakozó sikeresen létrehozva",
|
||||
"connector_deleted_successfully": "Csatlakozó sikeresen törölve",
|
||||
"connector_duplicated_successfully": "Csatlakozó sikeresen duplikálva",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "Az adatok kétszeri importálása duplikált rekordokat hoz létre.",
|
||||
"csv_inconsistent_columns": "A(z) {row}. sor inkonzisztens oszlopokat tartalmaz. Minden sornak ugyanazokkal a fejlécekkel kell rendelkeznie.",
|
||||
"csv_max_records": "Maximum {max} rekord engedélyezett.",
|
||||
"custom_source_type": "Egyéni forrástípus",
|
||||
"custom_source_type_placeholder": "Adja meg az egyéni forrástípust",
|
||||
"default_connector_name_csv": "CSV importálás",
|
||||
"default_connector_name_formbricks": "Formbricks kérdőív kapcsolat",
|
||||
"deselect_all": "Összes kijelölés törlése",
|
||||
"discard_feedback_record_changes_description": "A módosítások elvesznek, ha bezárja ezt a fiókot.",
|
||||
"discard_feedback_record_changes_title": "Elveti a nem mentett módosításokat?",
|
||||
"drop_a_field_here": "Húzz ide egy mezőt",
|
||||
"drop_field_or": "Húzz ide egy mezőt vagy",
|
||||
"edit_csv_mapping": "CSV leképezés szerkesztése",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "felsorolás",
|
||||
"failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat",
|
||||
"feedback_date": "Aktuális dátum",
|
||||
"feedback_record_created_successfully": "A visszajelzési rekord sikeresen létrehozva",
|
||||
"feedback_record_details": "A visszajelzési rekord részletei",
|
||||
"feedback_record_details_description": "Tekintse át és frissítse a visszajelzési rekordmezőket.",
|
||||
"feedback_record_directory": "Visszajelzési Rekord Könyvtár",
|
||||
"feedback_record_fields": "Visszajelzési rekord mezők",
|
||||
"feedback_record_mcp": "Visszajelzési rekord MCP",
|
||||
"feedback_record_updated_successfully": "A visszajelzési rekord sikeresen frissítve",
|
||||
"feedback_record_value_required": "A kiválasztott mezőtípushoz értéket kell megadni",
|
||||
"feedback_records": "Visszajelzési rekordok",
|
||||
"feedback_records_refreshed": "Visszajelzési rekordok frissítve",
|
||||
"feedback_sources": "Visszajelzési források",
|
||||
"feedback_sources_directory_access_multiple": "Az ezekből a forrásokból származó új rekordok a következő helyen lesznek tárolva: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Az ebből a forrásból származó új rekordok a következő helyen lesznek tárolva: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Összes visszajelzési forrás csatlakoztatása és kezelése ezen munkaterület számára.",
|
||||
"field_group_id": "Mezőcsoport azonosítója",
|
||||
"field_group_label": "Mezőcsoport címke",
|
||||
"field_id": "Mezőazonosító",
|
||||
"field_label": "Mező címke",
|
||||
"field_type": "Mező típus",
|
||||
"formbricks_surveys": "Formbricks kérdőívek",
|
||||
"frd_cannot_be_changed": "A visszajelzési könyvtár a létrehozás után nem módosítható.",
|
||||
"go_to_feedback_record_directories": "Ugrás a könyvtárbeállításokhoz",
|
||||
"historical_import_complete": "Importálás befejezve: {successes} sikeres, {failures} sikertelen, {skipped} kihagyva (nincs adat)",
|
||||
"import_csv_data": "Visszajelzés importálása",
|
||||
"import_feedback": "Visszajelzés importálása",
|
||||
"import_historical_responses": "Korábbi válaszok importálása",
|
||||
"import_historical_responses_description": "Meglévő válaszok importálása ebből a felmérésből most.",
|
||||
"import_rows": "{count} sor importálása",
|
||||
"import_via_source_name": "Importálás a következőn keresztül: \"{sourceName}\"",
|
||||
"importing_data": "Adatok importálása...",
|
||||
"importing_historical_data": "Történeti adatok importálása...",
|
||||
"invalid_enum_values": "Érvénytelen értékek a(z) {field} mezőhöz rendelt oszlopban",
|
||||
"invalid_values_found": "Talált értékek: {values} (sorok: {rows}) {extra}",
|
||||
"load_sample_csv": "Minta CSV betöltése",
|
||||
"n_supported_questions": "{count} támogatott kérdés",
|
||||
"manage_directories": "Könyvtárak kezelése",
|
||||
"manage_feedback_sources": "Visszajelzési források kezelése",
|
||||
"metadata": "Metaadatok",
|
||||
"metadata_key": "Metaadatkulcs",
|
||||
"metadata_read_only_entries": "Csak olvasható metaadatértékek (nem karakterlánc)",
|
||||
"metadata_value": "A metaadat értéke",
|
||||
"missing_feedback_source_title": "Hiányzik egy visszajelzési forrás?",
|
||||
"no_feedback_record_directory_available": "Ehhez a munkaterülethez nem tartozik visszajelzési rekord könyvtár. Először hozzon létre vagy rendeljen hozzá egyet.",
|
||||
"no_feedback_records": "Még nincsenek visszajelzési rekordok. A rekordok itt fognak megjelenni, amint a csatlakozók elkezdik küldeni az adatokat.",
|
||||
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
|
||||
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
|
||||
"no_surveys_found": "Nem találhatók kérdőívek ebben a környezetben",
|
||||
"optional": "Elhagyható",
|
||||
"or_drag_and_drop": "vagy húzd ide",
|
||||
"question_selected": "<strong>{count}</strong> kérdés kiválasztva. Minden válasz ezekre a kérdésekre új visszajelzési rekordot hoz létre.",
|
||||
"question_type_not_supported": "Ez a kérdéstípus nem támogatott",
|
||||
"questions_selected": "<strong>{count}</strong> kérdés kiválasztva. Minden válasz ezekre a kérdésekre új visszajelzési rekordot hoz létre.",
|
||||
"records_will_go_to": "A rekordok ide kerülnek",
|
||||
"refresh_feedback_records": "Visszajelzési rekordok frissítése",
|
||||
"refreshing_feedback_records": "Visszajelzési rekordok frissítése...",
|
||||
"request_feedback_source": "Forrásintegráció kérése",
|
||||
"required": "Kötelező",
|
||||
"save_changes": "Változtatások mentése",
|
||||
"select_a_survey_to_see_questions": "Válassz egy kérdőívet a kérdések megtekintéséhez",
|
||||
"select_a_value": "Válassz egy értéket...",
|
||||
"select_all": "Összes kiválasztása",
|
||||
"select_feedback_record_directory": "Válasszon egy könyvtárat",
|
||||
"select_feedback_record_source_type": "Válassza ki a forrás típusát",
|
||||
"select_questions": "Kérdések kiválasztása",
|
||||
"select_source_type_description": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát.",
|
||||
"select_source_type_prompt": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát:",
|
||||
"select_survey": "Kérdőív kiválasztása",
|
||||
"select_survey_and_questions": "Kérdőív és kérdések kiválasztása",
|
||||
"select_survey_questions_description": "Válassza ki, mely kérdőívkérdések hozzanak létre visszajelzési rekordokat.",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "3 megjelenítve {count} sorból",
|
||||
"source": "forrás",
|
||||
"source_connect_csv_description": "Visszajelzések importálása CSV fájlokból",
|
||||
"source_connect_feedback_record_mcp_description": "Visszajelzési rekordok küldése az MCP integráción keresztül.",
|
||||
"source_connect_formbricks_description": "Visszajelzések csatlakoztatása a Formbricks kérdőívekből",
|
||||
"source_fields": "Forrásmezők",
|
||||
"source_id": "Forrásazonosító",
|
||||
"source_name": "Forrásnév",
|
||||
"source_type": "Forrás típus",
|
||||
"source_type_cannot_be_changed": "A forrástípus nem módosítható",
|
||||
"sources": "Források",
|
||||
"status_active": "Folyamatban",
|
||||
"status_completed": "Befejezve",
|
||||
"status_draft": "Piszkozat",
|
||||
"status_error": "Hiba",
|
||||
"status_live_sync": "Élő szinkronizálás",
|
||||
"status_paused": "Szüneteltetve",
|
||||
"status_ready": "Kész",
|
||||
"submission_id": "Beküldés azonosítója",
|
||||
"survey_has_no_questions": "Ez a felmérés nem tartalmaz kérdéseket",
|
||||
"survey_import_line": "{surveyName}: {responseCount} válasz × {questionCount} kérdés = {total} visszajelzési rekord",
|
||||
"total_feedback_records": "Összesen: {checked} / {total} visszajelzési rekord kiválasztva {surveyCount} felmérésből",
|
||||
"topics_and_subtopics": "Témák és altémák",
|
||||
"unify_feedback": "Visszajelzések egyesítése",
|
||||
"update_mapping_description": "Frissítse a leképezési konfigurációt ehhez a forráshoz.",
|
||||
"updated_at": "Frissítve",
|
||||
"upload_csv_data_description": "Tölts fel egy CSV fájlt a visszajelzési adatok importálásához.",
|
||||
"upload_csv_file": "CSV fájl feltöltése",
|
||||
"user_identifier": "Felhasználó",
|
||||
"value": "Érték"
|
||||
"value": "Érték",
|
||||
"value_boolean": "Érték (logikai)",
|
||||
"value_date": "Érték (dátum)",
|
||||
"value_number": "Érték (szám)",
|
||||
"value_text": "Érték (szöveg)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "アクティビティ",
|
||||
"add": "追加",
|
||||
"add_action": "アクションを追加",
|
||||
"add_chart": "チャートを追加",
|
||||
"add_charts": "グラフを追加",
|
||||
"add_existing_chart_description": "このダッシュボードに追加するグラフを検索して選択してください。",
|
||||
"add_filter": "フィルターを追加",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "このアクションを実行するための認証がされていません。",
|
||||
"not_authorized": "権限がありません",
|
||||
"not_connected": "未接続",
|
||||
"not_set": "未設定",
|
||||
"note": "メモ",
|
||||
"notifications": "通知",
|
||||
"number": "数値",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "フォームを報告",
|
||||
"request_trial_license": "トライアルライセンスをリクエスト",
|
||||
"reset_to_default": "デフォルトにリセット",
|
||||
"resize": "サイズ変更",
|
||||
"response": "回答",
|
||||
"response_id": "回答ID",
|
||||
"responses": "回答",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "一部のファイルのアップロードに失敗しました",
|
||||
"something_went_wrong": "問題が発生しました",
|
||||
"something_went_wrong_please_try_again": "問題が発生しました。もう一度お試しください。",
|
||||
"soon": "近日公開",
|
||||
"sort_by": "並び替え",
|
||||
"start_free_trial": "無料トライアルを開始",
|
||||
"status": "ステータス",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "ダッシュボードを選択してください",
|
||||
"predefined_measures": "事前定義されたメジャー",
|
||||
"preset": "プリセット",
|
||||
"preview_chart": "グラフのプレビュー",
|
||||
"query_executed_successfully": "クエリが正常に実行されました",
|
||||
"reset_to_ai_suggestion": "AIの提案にリセット",
|
||||
"save_and_add_to_dashboard": "保存してダッシュボードに追加",
|
||||
"save_chart": "チャートを保存",
|
||||
"save_chart_dialog_title": "チャートを保存",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "開始日",
|
||||
"time_dimension": "時間ディメンション",
|
||||
"time_dimension_title": "時間ベースのグループ化を追加",
|
||||
"time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。"
|
||||
"time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。",
|
||||
"update_chart": "チャートを更新"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count}個のグラフを追加",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "ダッシュボードを作成",
|
||||
"create_dashboard_description": "新しいダッシュボードの名前を入力してください。",
|
||||
"create_failed": "ダッシュボードの作成に失敗しました",
|
||||
"create_new_chart": "新しいチャートを作成する",
|
||||
"create_success": "ダッシュボードを正常に作成しました!",
|
||||
"dashboard": "ダッシュボード",
|
||||
"dashboard_delete_confirmation": "このダッシュボードを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "ダッシュボードの複製に失敗しました",
|
||||
"duplicate_success": "ダッシュボードを正常に複製しました!",
|
||||
"failed_to_load_chart_data": "チャートデータの読み込みに失敗しました",
|
||||
"no_charts_available_description": "このダッシュボードに追加できるチャートがありません。チャートがまだ存在しないか、既存のチャートがすべて追加済みです。新しいチャートを作成するには、チャートページに移動してください。",
|
||||
"no_charts_to_add_message": "このダッシュボードに追加するチャートがありません。",
|
||||
"no_dashboards_found": "ダッシュボードが見つかりません。",
|
||||
"no_data_message": "データがありません。現在表示する情報がありません。ダッシュボードを構築するにはチャートを追加してください。",
|
||||
"please_enter_name": "ダッシュボード名を入力してください"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "レポートするフィードバック レコードがありません。データをシステムにフィードするためのフィードバック ソースをセットアップします。",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "フィードバックソースのセットアップ"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "APIキーを追加",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "フィードバックディレクトリ",
|
||||
"no_access": "フィードバック記録ディレクトリを管理する権限がありません。",
|
||||
"no_connectors": "このディレクトリにリンクされているコネクタはまだありません。",
|
||||
"pause_connectors_confirmation_description": "これらのコネクタを一時停止すると、新しいレコードは追加されません。",
|
||||
"pause_connectors_confirmation_title": "関連するコネクタを一時停止しますか?",
|
||||
"select_workspaces_placeholder": "ワークスペースを選択...",
|
||||
"show_archived": "アーカイブ済みを表示",
|
||||
"title": "フィードバック記録ディレクトリ",
|
||||
"unarchive": "アーカイブ解除"
|
||||
"unarchive": "アーカイブ解除",
|
||||
"unarchive_workspace_conflict": "割り当てられているワークスペースの1つ以上がアーカイブされているため、このディレクトリをアーカイブ解除できません。",
|
||||
"workspace_access": "ワークスペースアクセス"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "このワークスペースにアクセスできるチームを確認します。"
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "フィードバックレコードを追加する",
|
||||
"add_feedback_record_description": "フィードバック記録を手動で作成します。",
|
||||
"add_feedback_source": "フィードバックソースを追加",
|
||||
"add_source": "ソースを追加",
|
||||
"allowed_values": "許可される値: {values}",
|
||||
"api_ingestion": "API取り込み",
|
||||
"api_ingestion_manage_api_keys": "APIキーを管理",
|
||||
"api_ingestion_settings_description": "管理APIを使用してフィードバックレコードを送信します。",
|
||||
"auto_generated": "自動生成",
|
||||
"change_file": "ファイルを変更",
|
||||
"click_load_sample_csv": "「サンプルCSVを読み込む」をクリックして列を表示",
|
||||
"click_to_upload": "クリックしてアップロード",
|
||||
"collected_at": "収集日時",
|
||||
"configure_import": "インポートを設定",
|
||||
"configure_mapping": "マッピングを設定",
|
||||
"connection": "接続",
|
||||
"connector_created_successfully": "コネクタが正常に作成されました",
|
||||
"connector_deleted_successfully": "コネクタが正常に削除されました",
|
||||
"connector_duplicated_successfully": "コネクタが正常に複製されました",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "データを2回インポートすると、重複したレコードが作成されます。",
|
||||
"csv_inconsistent_columns": "行 {row} の列が一致しません。すべての行は同じヘッダーを持つ必要があります。",
|
||||
"csv_max_records": "最大 {max} 件のレコードまで許可されています。",
|
||||
"custom_source_type": "カスタムソースタイプ",
|
||||
"custom_source_type_placeholder": "カスタムソースタイプを入力してください",
|
||||
"default_connector_name_csv": "CSVインポート",
|
||||
"default_connector_name_formbricks": "Formbricks フォーム接続",
|
||||
"deselect_all": "すべて選択解除",
|
||||
"discard_feedback_record_changes_description": "このドロワーを閉じると、変更内容は失われます。",
|
||||
"discard_feedback_record_changes_title": "保存されていない変更を破棄しますか?",
|
||||
"drop_a_field_here": "ここにフィールドをドロップ",
|
||||
"drop_field_or": "フィールドをドロップまたは",
|
||||
"edit_csv_mapping": "CSVマッピングを編集",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "列挙型",
|
||||
"failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました",
|
||||
"feedback_date": "現在の日付",
|
||||
"feedback_record_created_successfully": "フィードバックレコードが正常に作成されました",
|
||||
"feedback_record_details": "フィードバック記録の詳細",
|
||||
"feedback_record_details_description": "フィードバック レコード フィールドを確認して更新します。",
|
||||
"feedback_record_directory": "フィードバックレコードディレクトリ",
|
||||
"feedback_record_fields": "フィードバックレコードフィールド",
|
||||
"feedback_record_mcp": "フィードバックレコードMCP",
|
||||
"feedback_record_updated_successfully": "フィードバックレコードが正常に更新されました",
|
||||
"feedback_record_value_required": "選択したフィールド タイプには値が必要です",
|
||||
"feedback_records": "フィードバックレコード",
|
||||
"feedback_records_refreshed": "フィードバックレコードを更新しました",
|
||||
"feedback_sources": "フィードバックソース",
|
||||
"feedback_sources_directory_access_multiple": "これらのソースからの新しいレコードは次の場所に保存されます:{directoryNames}",
|
||||
"feedback_sources_directory_access_single": "このソースからの新しいレコードは次の場所に保存されます:{directoryNames}",
|
||||
"feedback_sources_settings_description": "このワークスペースのすべてのフィードバックソースを接続・管理します。",
|
||||
"field_group_id": "フィールドグループID",
|
||||
"field_group_label": "フィールドグループラベル",
|
||||
"field_id": "フィールドID",
|
||||
"field_label": "フィールドラベル",
|
||||
"field_type": "フィールドタイプ",
|
||||
"formbricks_surveys": "Formbricks フォーム",
|
||||
"frd_cannot_be_changed": "フィードバックディレクトリは作成後に変更できません。",
|
||||
"go_to_feedback_record_directories": "ディレクトリ設定へ移動",
|
||||
"historical_import_complete": "インポート完了: {successes}件成功、{failures}件失敗、{skipped}件スキップ(データなし)",
|
||||
"import_csv_data": "フィードバックをインポート",
|
||||
"import_feedback": "フィードバックをインポート",
|
||||
"import_historical_responses": "過去の回答をインポート",
|
||||
"import_historical_responses_description": "このアンケートから既存の回答を今すぐインポートします。",
|
||||
"import_rows": "{count}行をインポート",
|
||||
"import_via_source_name": "「{sourceName}」経由でインポート",
|
||||
"importing_data": "データをインポート中...",
|
||||
"importing_historical_data": "過去のデータをインポート中...",
|
||||
"invalid_enum_values": "{field}にマッピングされた列に無効な値があります",
|
||||
"invalid_values_found": "検出された値: {values}(行: {rows}){extra}",
|
||||
"load_sample_csv": "サンプルCSVを読み込む",
|
||||
"n_supported_questions": "{count} 件のサポートされている質問",
|
||||
"manage_directories": "ディレクトリを管理",
|
||||
"manage_feedback_sources": "フィードバックソースを管理する",
|
||||
"metadata": "メタデータ",
|
||||
"metadata_key": "メタデータキー",
|
||||
"metadata_read_only_entries": "読み取り専用メタデータ値 (非文字列)",
|
||||
"metadata_value": "メタデータ値",
|
||||
"missing_feedback_source_title": "フィードバックソースが見つかりませんか?",
|
||||
"no_feedback_record_directory_available": "このワークスペースにフィードバックレコードディレクトリが割り当てられていません。まず作成または割り当てを行ってください。",
|
||||
"no_feedback_records": "フィードバックレコードはまだありません。コネクタがデータの送信を開始すると、ここにレコードが表示されます。",
|
||||
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
|
||||
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
|
||||
"no_surveys_found": "この環境にフォームが見つかりません",
|
||||
"optional": "任意",
|
||||
"or_drag_and_drop": "またはドラッグ&ドロップ",
|
||||
"question_selected": "<strong>{count}</strong>件の質問が選択されています。これらの質問への各回答は、新しいフィードバックレコードを作成します。",
|
||||
"question_type_not_supported": "この質問タイプはサポートされていません",
|
||||
"questions_selected": "<strong>{count}</strong>件の質問が選択されています。これらの質問への各回答は、新しいフィードバックレコードを作成します。",
|
||||
"records_will_go_to": "レコードの保存先",
|
||||
"refresh_feedback_records": "フィードバック記録を更新",
|
||||
"refreshing_feedback_records": "フィードバックレコードを更新中...",
|
||||
"request_feedback_source": "ソース統合をリクエスト",
|
||||
"required": "必須",
|
||||
"save_changes": "変更を保存",
|
||||
"select_a_survey_to_see_questions": "フォームを選択して質問を表示",
|
||||
"select_a_value": "値を選択...",
|
||||
"select_all": "すべて選択",
|
||||
"select_feedback_record_directory": "ディレクトリを選択",
|
||||
"select_feedback_record_source_type": "ソースタイプを選択してください",
|
||||
"select_questions": "質問を選択",
|
||||
"select_source_type_description": "接続するフィードバックソースの種類を選択してください。",
|
||||
"select_source_type_prompt": "接続するフィードバックソースの種類を選択してください:",
|
||||
"select_survey": "フォームを選択",
|
||||
"select_survey_and_questions": "フォームと質問を選択",
|
||||
"select_survey_questions_description": "フィードバックレコードを作成するフォームの質問を選択してください。",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "{count}行中3行を表示",
|
||||
"source": "ソース",
|
||||
"source_connect_csv_description": "CSVファイルからフィードバックをインポート",
|
||||
"source_connect_feedback_record_mcp_description": "MCP統合を通じてフィードバックレコードを送信します。",
|
||||
"source_connect_formbricks_description": "Formbricksフォームからフィードバックを接続",
|
||||
"source_fields": "ソースフィールド",
|
||||
"source_id": "ソースID",
|
||||
"source_name": "ソース名",
|
||||
"source_type": "ソースタイプ",
|
||||
"source_type_cannot_be_changed": "ソースタイプは変更できません",
|
||||
"sources": "ソース",
|
||||
"status_active": "進行中",
|
||||
"status_completed": "完了",
|
||||
"status_draft": "下書き",
|
||||
"status_error": "エラー",
|
||||
"status_live_sync": "リアルタイム同期",
|
||||
"status_paused": "一時停止",
|
||||
"status_ready": "準備完了",
|
||||
"submission_id": "提出ID",
|
||||
"survey_has_no_questions": "このアンケートには質問がありません",
|
||||
"survey_import_line": "{surveyName}: {responseCount}件の回答 × {questionCount}件の質問 = {total}件のフィードバックレコード",
|
||||
"total_feedback_records": "合計: {surveyCount}件のアンケート全体で{total}件中{checked}件のフィードバックレコードが選択されています",
|
||||
"topics_and_subtopics": "トピックとサブトピック",
|
||||
"unify_feedback": "フィードバックを統合",
|
||||
"update_mapping_description": "このソースのマッピング設定を更新します。",
|
||||
"updated_at": "更新日時",
|
||||
"upload_csv_data_description": "CSVファイルをアップロードして、フィードバックデータをインポートします。",
|
||||
"upload_csv_file": "CSVファイルをアップロード",
|
||||
"user_identifier": "ユーザー",
|
||||
"value": "値"
|
||||
"value": "値",
|
||||
"value_boolean": "値 (ブール値)",
|
||||
"value_date": "値 (日付)",
|
||||
"value_number": "値(数値)",
|
||||
"value_text": "値 (テキスト)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "Activiteit",
|
||||
"add": "Toevoegen",
|
||||
"add_action": "Actie toevoegen",
|
||||
"add_chart": "Diagram toevoegen",
|
||||
"add_charts": "Grafieken toevoegen",
|
||||
"add_existing_chart_description": "Zoek en selecteer grafieken om toe te voegen aan dit dashboard.",
|
||||
"add_filter": "Filter toevoegen",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "U bent niet geverifieerd om deze actie uit te voeren.",
|
||||
"not_authorized": "Niet geautoriseerd",
|
||||
"not_connected": "Niet verbonden",
|
||||
"not_set": "Niet ingesteld",
|
||||
"note": "Opmerking",
|
||||
"notifications": "Meldingen",
|
||||
"number": "Nummer",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "Enquête melden",
|
||||
"request_trial_license": "Proeflicentie aanvragen",
|
||||
"reset_to_default": "Resetten naar standaard",
|
||||
"resize": "Formaat wijzigen",
|
||||
"response": "Antwoord",
|
||||
"response_id": "Antwoord-ID",
|
||||
"responses": "Reacties",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "Sommige bestanden konden niet worden geüpload",
|
||||
"something_went_wrong": "Er is iets misgegaan",
|
||||
"something_went_wrong_please_try_again": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||
"soon": "Binnenkort",
|
||||
"sort_by": "Sorteer op",
|
||||
"start_free_trial": "Start gratis proefperiode",
|
||||
"status": "Status",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "Selecteer een dashboard",
|
||||
"predefined_measures": "Vooraf gedefinieerde metingen",
|
||||
"preset": "Voorinstelling",
|
||||
"preview_chart": "Voorbeeldgrafiek",
|
||||
"query_executed_successfully": "Query succesvol uitgevoerd",
|
||||
"reset_to_ai_suggestion": "Herstel naar AI-suggestie",
|
||||
"save_and_add_to_dashboard": "Opslaan en toevoegen aan dashboard",
|
||||
"save_chart": "Diagram opslaan",
|
||||
"save_chart_dialog_title": "Diagram opslaan",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "Startdatum",
|
||||
"time_dimension": "Tijdsdimensie",
|
||||
"time_dimension_title": "Tijdgebaseerde groepering toevoegen",
|
||||
"time_dimension_toggle_description": "Volg trends over tijd."
|
||||
"time_dimension_toggle_description": "Volg trends over tijd.",
|
||||
"update_chart": "Diagram bijwerken"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} grafiek(en) toevoegen",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "Dashboard maken",
|
||||
"create_dashboard_description": "Voer een naam in voor je nieuwe dashboard.",
|
||||
"create_failed": "Dashboard creëren mislukt",
|
||||
"create_new_chart": "Maak een nieuw diagram",
|
||||
"create_success": "Dashboard succesvol aangemaakt!",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_delete_confirmation": "Weet je zeker dat je dit dashboard wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "Dashboard dupliceren mislukt",
|
||||
"duplicate_success": "Dashboard succesvol gedupliceerd!",
|
||||
"failed_to_load_chart_data": "Grafiekgegevens laden mislukt",
|
||||
"no_charts_available_description": "Er zijn geen grafieken die aan dit dashboard kunnen worden toegevoegd. Er bestaan nog geen grafieken, of alle bestaande grafieken zijn al toegevoegd. Ga naar de pagina Grafieken om nieuwe grafieken te maken.",
|
||||
"no_charts_to_add_message": "Geen grafieken om toe te voegen aan dit dashboard.",
|
||||
"no_dashboards_found": "Geen dashboards gevonden.",
|
||||
"no_data_message": "Geen gegevens. Er is momenteel geen informatie om weer te geven. Voeg grafieken toe om je dashboard op te bouwen.",
|
||||
"please_enter_name": "Voer een dashboardnaam in"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "U heeft geen feedbackrecords om over te rapporteren. Stel feedbackbronnen in om gegevens in het systeem in te voeren.",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "Feedbackbronnen instellen"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "API-sleutel toevoegen",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "Feedbackmappen",
|
||||
"no_access": "Je hebt geen toestemming om feedbackregistratiemappen te beheren.",
|
||||
"no_connectors": "Nog geen connectoren gekoppeld aan deze map.",
|
||||
"pause_connectors_confirmation_description": "Als je deze connectoren pauzeert, worden er geen nieuwe records meer toegevoegd.",
|
||||
"pause_connectors_confirmation_title": "Gekoppelde connectoren pauzeren?",
|
||||
"select_workspaces_placeholder": "Selecteer werkruimtes...",
|
||||
"show_archived": "Gearchiveerde weergeven",
|
||||
"title": "Feedbackregistratiemappen",
|
||||
"unarchive": "Dearchiveren"
|
||||
"unarchive": "Dearchiveren",
|
||||
"unarchive_workspace_conflict": "Deze map kan niet worden gedearchiveerd omdat een of meer toegewezen workspaces zijn gearchiveerd.",
|
||||
"workspace_access": "Workspace-toegang"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Dataverrijking & analyse (AI)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "Bekijk welke teams toegang hebben tot deze workspace."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Feedbackrecord toevoegen",
|
||||
"add_feedback_record_description": "Maak handmatig een feedbackrecord.",
|
||||
"add_feedback_source": "Feedbackbron toevoegen",
|
||||
"add_source": "Bron toevoegen",
|
||||
"allowed_values": "Toegestane waarden: {values}",
|
||||
"api_ingestion": "API-inname",
|
||||
"api_ingestion_manage_api_keys": "API-sleutels beheren",
|
||||
"api_ingestion_settings_description": "Verstuur feedbackrecords via de Management API.",
|
||||
"auto_generated": "Automatisch gegenereerd",
|
||||
"change_file": "Bestand wijzigen",
|
||||
"click_load_sample_csv": "Klik op 'Voorbeeld CSV laden' om kolommen te zien",
|
||||
"click_to_upload": "Klik om te uploaden",
|
||||
"collected_at": "Verzameld op",
|
||||
"configure_import": "Import configureren",
|
||||
"configure_mapping": "Koppeling configureren",
|
||||
"connection": "Verbinding",
|
||||
"connector_created_successfully": "Connector succesvol aangemaakt",
|
||||
"connector_deleted_successfully": "Connector succesvol verwijderd",
|
||||
"connector_duplicated_successfully": "Connector succesvol gedupliceerd",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "Gegevens twee keer importeren zal dubbele records aanmaken.",
|
||||
"csv_inconsistent_columns": "Rij {row} heeft inconsistente kolommen. Alle rijen moeten dezelfde headers hebben.",
|
||||
"csv_max_records": "Maximaal {max} records toegestaan.",
|
||||
"custom_source_type": "Aangepast brontype",
|
||||
"custom_source_type_placeholder": "Voer een aangepast brontype in",
|
||||
"default_connector_name_csv": "CSV import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey verbinding",
|
||||
"deselect_all": "Alles deselecteren",
|
||||
"discard_feedback_record_changes_description": "Als u deze lade sluit, gaan uw wijzigingen verloren.",
|
||||
"discard_feedback_record_changes_title": "Niet-opgeslagen wijzigingen verwijderen?",
|
||||
"drop_a_field_here": "Zet hier een veld neer",
|
||||
"drop_field_or": "Zet veld neer of",
|
||||
"edit_csv_mapping": "CSV-mapping bewerken",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Kan feedbackrecords niet laden",
|
||||
"feedback_date": "Huidige datum",
|
||||
"feedback_record_created_successfully": "Feedbackrecord is succesvol aangemaakt",
|
||||
"feedback_record_details": "Details van feedbackrecord",
|
||||
"feedback_record_details_description": "Controleer en update de feedbackrecordvelden.",
|
||||
"feedback_record_directory": "Feedbackrecordmap",
|
||||
"feedback_record_fields": "Feedbackrecordvelden",
|
||||
"feedback_record_mcp": "Feedbackrecord MCP",
|
||||
"feedback_record_updated_successfully": "Feedbackrecord is succesvol bijgewerkt",
|
||||
"feedback_record_value_required": "Er is een waarde vereist voor het geselecteerde veldtype",
|
||||
"feedback_records": "Feedbackrecords",
|
||||
"feedback_records_refreshed": "Feedbackrecords vernieuwd",
|
||||
"feedback_sources": "Feedbackbronnen",
|
||||
"feedback_sources_directory_access_multiple": "Nieuwe records van deze bronnen worden opgeslagen in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Nieuwe records van deze bron worden opgeslagen in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Verbind en beheer alle feedbackbronnen voor deze werkruimte.",
|
||||
"field_group_id": "Veldgroep-ID",
|
||||
"field_group_label": "Veldgroeplabel",
|
||||
"field_id": "Veld-ID",
|
||||
"field_label": "Veldlabel",
|
||||
"field_type": "Veldtype",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "Feedbackmap kan niet worden gewijzigd na aanmaak.",
|
||||
"go_to_feedback_record_directories": "Ga naar map-instellingen",
|
||||
"historical_import_complete": "Import voltooid: {successes} geslaagd, {failures} mislukt, {skipped} overgeslagen (geen data)",
|
||||
"import_csv_data": "Feedback importeren",
|
||||
"import_feedback": "Feedback importeren",
|
||||
"import_historical_responses": "Historische reacties importeren",
|
||||
"import_historical_responses_description": "Importeer bestaande reacties van deze enquête nu.",
|
||||
"import_rows": "{count, plural, one {Importeer 1 rij} other {Importeer # rijen}}",
|
||||
"import_via_source_name": "Importeren via \"{sourceName}\"",
|
||||
"importing_data": "Gegevens importeren...",
|
||||
"importing_historical_data": "Historische gegevens importeren...",
|
||||
"invalid_enum_values": "Ongeldige waarden in kolom gekoppeld aan {field}",
|
||||
"invalid_values_found": "Gevonden: {values} (rijen: {rows}) {extra}",
|
||||
"load_sample_csv": "Voorbeeld-CSV laden",
|
||||
"n_supported_questions": "{count} ondersteunde vragen",
|
||||
"manage_directories": "Mappen beheren",
|
||||
"manage_feedback_sources": "Beheer feedbackbronnen",
|
||||
"metadata": "Metagegevens",
|
||||
"metadata_key": "Metagegevenssleutel",
|
||||
"metadata_read_only_entries": "Alleen-lezen metadatawaarden (niet-tekenreeks)",
|
||||
"metadata_value": "Metagegevenswaarde",
|
||||
"missing_feedback_source_title": "Mis je een feedbackbron?",
|
||||
"no_feedback_record_directory_available": "Geen feedbackrecordmap toegewezen aan deze workspace. Maak er eerst een aan of wijs er een toe.",
|
||||
"no_feedback_records": "Nog geen feedbackrecords. Records verschijnen hier zodra je connectoren gegevens beginnen te verzenden.",
|
||||
"no_source_fields_loaded": "Nog geen bronvelden geladen",
|
||||
"no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.",
|
||||
"no_surveys_found": "Geen enquêtes gevonden in deze omgeving",
|
||||
"optional": "Optioneel",
|
||||
"or_drag_and_drop": "of sleep en zet neer",
|
||||
"question_selected": "<strong>{count}</strong> vraag geselecteerd. Elk antwoord op deze vraag zal een nieuw feedbackrecord aanmaken.",
|
||||
"question_type_not_supported": "Dit vraagtype wordt niet ondersteund",
|
||||
"questions_selected": "<strong>{count}</strong> vragen geselecteerd. Elk antwoord op deze vragen zal een nieuw feedbackrecord aanmaken.",
|
||||
"records_will_go_to": "Records gaan naar",
|
||||
"refresh_feedback_records": "Feedbackrecords verversen",
|
||||
"refreshing_feedback_records": "Feedbackrecords vernieuwen...",
|
||||
"request_feedback_source": "Bronintegratie aanvragen",
|
||||
"required": "Vereist",
|
||||
"save_changes": "Wijzigingen opslaan",
|
||||
"select_a_survey_to_see_questions": "Selecteer een enquête om de vragen te zien",
|
||||
"select_a_value": "Selecteer een waarde...",
|
||||
"select_all": "Selecteer alles",
|
||||
"select_feedback_record_directory": "Selecteer een map",
|
||||
"select_feedback_record_source_type": "Selecteer brontype",
|
||||
"select_questions": "Selecteer vragen",
|
||||
"select_source_type_description": "Selecteer het type feedbackbron dat je wilt verbinden.",
|
||||
"select_source_type_prompt": "Selecteer het type feedbackbron dat je wilt verbinden:",
|
||||
"select_survey": "Selecteer enquête",
|
||||
"select_survey_and_questions": "Selecteer enquête & vragen",
|
||||
"select_survey_questions_description": "Kies welke enquêtevragen FeedbackRecords moeten aanmaken.",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "3 van {count} rijen weergegeven",
|
||||
"source": "bron",
|
||||
"source_connect_csv_description": "Importeer feedback uit CSV-bestanden",
|
||||
"source_connect_feedback_record_mcp_description": "Verstuur feedbackrecords via de MCP-integratie.",
|
||||
"source_connect_formbricks_description": "Verbind feedback van je Formbricks-enquêtes",
|
||||
"source_fields": "Bronvelden",
|
||||
"source_id": "Bron-ID",
|
||||
"source_name": "Bronnaam",
|
||||
"source_type": "Brontype",
|
||||
"source_type_cannot_be_changed": "Brontype kan niet worden gewijzigd",
|
||||
"sources": "Bronnen",
|
||||
"status_active": "In uitvoering",
|
||||
"status_completed": "Voltooid",
|
||||
"status_draft": "Voorlopige versie",
|
||||
"status_error": "Fout",
|
||||
"status_live_sync": "Live synchronisatie",
|
||||
"status_paused": "Gepauzeerd",
|
||||
"status_ready": "Klaar",
|
||||
"submission_id": "Inzendings-ID",
|
||||
"survey_has_no_questions": "Deze enquête heeft geen vragen",
|
||||
"survey_import_line": "{surveyName}: {responseCount} antwoorden × {questionCount} vragen = {total} feedbackrecords",
|
||||
"total_feedback_records": "Totaal: {checked} van {total} feedbackrecords geselecteerd over {surveyCount} enquêtes",
|
||||
"topics_and_subtopics": "Onderwerpen en subonderwerpen",
|
||||
"unify_feedback": "Feedback verenigen",
|
||||
"update_mapping_description": "Werk de mappingconfiguratie voor deze bron bij.",
|
||||
"updated_at": "Bijgewerkt op",
|
||||
"upload_csv_data_description": "Upload een CSV-bestand om feedbackgegevens te importeren.",
|
||||
"upload_csv_file": "CSV-bestand uploaden",
|
||||
"user_identifier": "Gebruiker",
|
||||
"value": "Waarde"
|
||||
"value": "Waarde",
|
||||
"value_boolean": "Waarde (Booleaans)",
|
||||
"value_date": "Waarde (datum)",
|
||||
"value_number": "Waarde (getal)",
|
||||
"value_text": "Waarde (tekst)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "Atividade",
|
||||
"add": "Adicionar",
|
||||
"add_action": "Adicionar ação",
|
||||
"add_chart": "Adicionar gráfico",
|
||||
"add_charts": "Adicionar gráficos",
|
||||
"add_existing_chart_description": "Pesquise e selecione gráficos para adicionar a este painel.",
|
||||
"add_filter": "Adicionar filtro",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Você não está autenticado para realizar essa ação.",
|
||||
"not_authorized": "Não autorizado",
|
||||
"not_connected": "Desconectado",
|
||||
"not_set": "Não definido",
|
||||
"note": "Nota",
|
||||
"notifications": "Notificações",
|
||||
"number": "Número",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "Relatório de Pesquisa",
|
||||
"request_trial_license": "Pedir licença de teste",
|
||||
"reset_to_default": "Restaurar para o padrão",
|
||||
"resize": "Redimensionar",
|
||||
"response": "Resposta",
|
||||
"response_id": "ID da resposta",
|
||||
"responses": "Respostas",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "Alguns arquivos falharam ao enviar",
|
||||
"something_went_wrong": "Algo deu errado",
|
||||
"something_went_wrong_please_try_again": "Algo deu errado. Tente novamente.",
|
||||
"soon": "Em breve",
|
||||
"sort_by": "Ordenar por",
|
||||
"start_free_trial": "Iniciar teste gratuito",
|
||||
"status": "status",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "Por favor, selecione um painel",
|
||||
"predefined_measures": "Medidas predefinidas",
|
||||
"preset": "Predefinição",
|
||||
"preview_chart": "Visualizar gráfico",
|
||||
"query_executed_successfully": "Consulta executada com sucesso",
|
||||
"reset_to_ai_suggestion": "Redefinir para sugestão da IA",
|
||||
"save_and_add_to_dashboard": "Salvar e adicionar ao painel",
|
||||
"save_chart": "Salvar gráfico",
|
||||
"save_chart_dialog_title": "Salvar gráfico",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "Data inicial",
|
||||
"time_dimension": "Dimensão temporal",
|
||||
"time_dimension_title": "Adicionar agrupamento por tempo",
|
||||
"time_dimension_toggle_description": "Monitore tendências ao longo do tempo."
|
||||
"time_dimension_toggle_description": "Monitore tendências ao longo do tempo.",
|
||||
"update_chart": "Atualizar gráfico"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adicionar {count} gráfico(s)",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "Criar painel",
|
||||
"create_dashboard_description": "Digite um nome para o seu novo painel.",
|
||||
"create_failed": "Falha ao criar painel",
|
||||
"create_new_chart": "Criar novo gráfico",
|
||||
"create_success": "Painel criado com sucesso!",
|
||||
"dashboard": "Painel",
|
||||
"dashboard_delete_confirmation": "Tem certeza de que deseja excluir este painel? Esta ação não pode ser desfeita.",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "Falha ao duplicar painel",
|
||||
"duplicate_success": "Painel duplicado com sucesso!",
|
||||
"failed_to_load_chart_data": "Falha ao carregar os dados do gráfico",
|
||||
"no_charts_available_description": "Não há gráficos que possam ser adicionados a este painel. Ou nenhum gráfico existe ainda, ou todos os gráficos existentes já foram adicionados. Vá para a página de Gráficos para criar novos gráficos.",
|
||||
"no_charts_to_add_message": "Nenhum gráfico para adicionar a este painel.",
|
||||
"no_dashboards_found": "Nenhum painel encontrado.",
|
||||
"no_data_message": "Sem Dados. Não há informações para exibir no momento. Adicione gráficos para construir seu painel.",
|
||||
"please_enter_name": "Por favor, digite um nome para o painel"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "Você não tem registros de feedback para relatar. Configure fontes de feedback para alimentar dados no sistema.",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "Configurar fontes de feedback"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Adicionar chave de API",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "Diretórios de Feedback",
|
||||
"no_access": "Você não tem permissão para gerenciar diretórios de registros de feedback.",
|
||||
"no_connectors": "Nenhum conector vinculado a este diretório ainda.",
|
||||
"pause_connectors_confirmation_description": "Ao pausar esses conectores, novos registros deixarão de ser adicionados.",
|
||||
"pause_connectors_confirmation_title": "Pausar conectores vinculados?",
|
||||
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
|
||||
"show_archived": "Mostrar arquivados",
|
||||
"title": "Diretórios de Registros de Feedback",
|
||||
"unarchive": "Desarquivar"
|
||||
"unarchive": "Desarquivar",
|
||||
"unarchive_workspace_conflict": "Não é possível desarquivar este diretório porque um ou mais workspaces atribuídos estão arquivados.",
|
||||
"workspace_access": "Acesso ao workspace"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "Veja quais equipes podem acessar este workspace."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Adicionar registro de feedback",
|
||||
"add_feedback_record_description": "Crie um registro de feedback manualmente.",
|
||||
"add_feedback_source": "Adicionar fonte de feedback",
|
||||
"add_source": "Adicionar fonte",
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"api_ingestion": "Ingestão de API",
|
||||
"api_ingestion_manage_api_keys": "Gerenciar chaves de API",
|
||||
"api_ingestion_settings_description": "Envie registros de feedback usando a API de Gerenciamento.",
|
||||
"auto_generated": "Gerado automaticamente",
|
||||
"change_file": "Alterar arquivo",
|
||||
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
|
||||
"click_to_upload": "Clique para fazer upload",
|
||||
"collected_at": "Coletado em",
|
||||
"configure_import": "Configurar importação",
|
||||
"configure_mapping": "Configurar mapeamento",
|
||||
"connection": "Conexão",
|
||||
"connector_created_successfully": "Conector criado com sucesso",
|
||||
"connector_deleted_successfully": "Conector excluído com sucesso",
|
||||
"connector_duplicated_successfully": "Conector duplicado com sucesso",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "Importar dados duas vezes criará registros duplicados.",
|
||||
"csv_inconsistent_columns": "A linha {row} possui colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.",
|
||||
"csv_max_records": "Máximo de {max} registros permitidos.",
|
||||
"custom_source_type": "Tipo de origem personalizado",
|
||||
"custom_source_type_placeholder": "Insira o tipo de fonte personalizado",
|
||||
"default_connector_name_csv": "Importação CSV",
|
||||
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
|
||||
"deselect_all": "Desmarcar tudo",
|
||||
"discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.",
|
||||
"discard_feedback_record_changes_title": "Descartar alterações não salvas?",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
"drop_field_or": "Solte o campo ou",
|
||||
"edit_csv_mapping": "Editar mapeamento CSV",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registros de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_record_created_successfully": "Registro de feedback criado com sucesso",
|
||||
"feedback_record_details": "Detalhes do registro de feedback",
|
||||
"feedback_record_details_description": "Revise e atualize os campos de registro de feedback.",
|
||||
"feedback_record_directory": "Diretório de Registros de Feedback",
|
||||
"feedback_record_fields": "Campos do registro de feedback",
|
||||
"feedback_record_mcp": "Registro de Feedback MCP",
|
||||
"feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso",
|
||||
"feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado",
|
||||
"feedback_records": "Registros de feedback",
|
||||
"feedback_records_refreshed": "Registros de feedback atualizados",
|
||||
"feedback_sources": "Fontes de Feedback",
|
||||
"feedback_sources_directory_access_multiple": "Novos registros dessas fontes serão armazenados em: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Novos registros desta fonte serão armazenados em: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Conecte e gerencie todas as fontes de feedback para este workspace.",
|
||||
"field_group_id": "ID do grupo de campos",
|
||||
"field_group_label": "Etiqueta do grupo de campos",
|
||||
"field_id": "ID do campo",
|
||||
"field_label": "Rótulo do campo",
|
||||
"field_type": "Tipo de campo",
|
||||
"formbricks_surveys": "Pesquisas Formbricks",
|
||||
"frd_cannot_be_changed": "O diretório de feedback não pode ser alterado após a criação.",
|
||||
"go_to_feedback_record_directories": "Ir para configurações de diretórios",
|
||||
"historical_import_complete": "Importação concluída: {successes} bem-sucedidas, {failures} falharam, {skipped} ignoradas (sem dados)",
|
||||
"import_csv_data": "Importar feedback",
|
||||
"import_feedback": "Importar feedback",
|
||||
"import_historical_responses": "Importar respostas históricas",
|
||||
"import_historical_responses_description": "Importe respostas existentes desta pesquisa agora.",
|
||||
"import_rows": "Importar {count} linhas",
|
||||
"import_via_source_name": "Importar via \"{sourceName}\"",
|
||||
"importing_data": "Importando dados...",
|
||||
"importing_historical_data": "Importando dados históricos...",
|
||||
"invalid_enum_values": "Valores inválidos na coluna mapeada para {field}",
|
||||
"invalid_values_found": "Encontrados: {values} (linhas: {rows}) {extra}",
|
||||
"load_sample_csv": "Carregar CSV de exemplo",
|
||||
"n_supported_questions": "{count} perguntas suportadas",
|
||||
"manage_directories": "Gerenciar diretórios",
|
||||
"manage_feedback_sources": "Gerenciar fontes de feedback",
|
||||
"metadata": "Metadados",
|
||||
"metadata_key": "Chave de metadados",
|
||||
"metadata_read_only_entries": "Valores de metadados somente leitura (sem string)",
|
||||
"metadata_value": "Valor dos metadados",
|
||||
"missing_feedback_source_title": "Faltando alguma fonte de feedback?",
|
||||
"no_feedback_record_directory_available": "Nenhum diretório de registros de feedback atribuído a este workspace. Crie ou atribua um primeiro.",
|
||||
"no_feedback_records": "Nenhum registro de feedback ainda. Os registros aparecerão aqui assim que seus conectores começarem a enviar dados.",
|
||||
"no_source_fields_loaded": "Nenhum campo de origem carregado ainda",
|
||||
"no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.",
|
||||
"no_surveys_found": "Nenhuma pesquisa encontrada neste ambiente",
|
||||
"optional": "Opcional",
|
||||
"or_drag_and_drop": "ou arraste e solte",
|
||||
"question_selected": "<strong>{count}</strong> pergunta selecionada. Cada resposta a esta pergunta criará um novo registro de feedback.",
|
||||
"question_type_not_supported": "Este tipo de pergunta não é suportado",
|
||||
"questions_selected": "<strong>{count}</strong> perguntas selecionadas. Cada resposta a estas perguntas criará um novo registro de feedback.",
|
||||
"records_will_go_to": "Os registros serão enviados para",
|
||||
"refresh_feedback_records": "Atualizar registros de feedback",
|
||||
"refreshing_feedback_records": "Atualizando registros de feedback...",
|
||||
"request_feedback_source": "Solicitar integração de fonte",
|
||||
"required": "Obrigatório",
|
||||
"save_changes": "Salvar alterações",
|
||||
"select_a_survey_to_see_questions": "Selecione uma pesquisa para ver suas perguntas",
|
||||
"select_a_value": "Selecione um valor...",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_feedback_record_directory": "Selecione um diretório",
|
||||
"select_feedback_record_source_type": "Selecione o tipo de fonte",
|
||||
"select_questions": "Selecionar perguntas",
|
||||
"select_source_type_description": "Selecione o tipo de fonte de feedback que você deseja conectar.",
|
||||
"select_source_type_prompt": "Selecione o tipo de fonte de feedback que você deseja conectar:",
|
||||
"select_survey": "Selecionar pesquisa",
|
||||
"select_survey_and_questions": "Selecionar pesquisa e perguntas",
|
||||
"select_survey_questions_description": "Escolha quais perguntas da pesquisa devem criar FeedbackRecords.",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "Mostrando 3 de {count} linhas",
|
||||
"source": "fonte",
|
||||
"source_connect_csv_description": "Importar feedback de arquivos CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Envie registros de feedback através da integração MCP.",
|
||||
"source_connect_formbricks_description": "Conectar feedback das suas pesquisas Formbricks",
|
||||
"source_fields": "Campos de origem",
|
||||
"source_id": "ID da fonte",
|
||||
"source_name": "Nome da origem",
|
||||
"source_type": "Tipo de fonte",
|
||||
"source_type_cannot_be_changed": "O tipo de origem não pode ser alterado",
|
||||
"sources": "Origens",
|
||||
"status_active": "Em andamento",
|
||||
"status_completed": "Concluído",
|
||||
"status_draft": "Rascunho",
|
||||
"status_error": "Erro",
|
||||
"status_live_sync": "Sincronização ao vivo",
|
||||
"status_paused": "Pausado",
|
||||
"status_ready": "Pronto",
|
||||
"submission_id": "ID de envio",
|
||||
"survey_has_no_questions": "Esta pesquisa não possui perguntas",
|
||||
"survey_import_line": "{surveyName}: {responseCount} respostas × {questionCount} perguntas = {total} registros de feedback",
|
||||
"total_feedback_records": "Total: {checked} de {total} registros de feedback selecionados em {surveyCount} pesquisas",
|
||||
"topics_and_subtopics": "Tópicos e subtópicos",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Atualize a configuração de mapeamento para esta fonte.",
|
||||
"updated_at": "Atualizado em",
|
||||
"upload_csv_data_description": "Faça upload de um arquivo CSV para importar dados de feedback.",
|
||||
"upload_csv_file": "Fazer upload de arquivo CSV",
|
||||
"user_identifier": "Usuário",
|
||||
"value": "Valor"
|
||||
"value": "Valor",
|
||||
"value_boolean": "Valor (Booleano)",
|
||||
"value_date": "Valor (Data)",
|
||||
"value_number": "Valor (Número)",
|
||||
"value_text": "Valor (Texto)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "Atividade",
|
||||
"add": "Adicionar",
|
||||
"add_action": "Adicionar ação",
|
||||
"add_chart": "Adicionar gráfico",
|
||||
"add_charts": "Adicionar gráficos",
|
||||
"add_existing_chart_description": "Pesquisa e seleciona gráficos para adicionar a este painel.",
|
||||
"add_filter": "Adicionar filtro",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Não está autenticado para realizar esta ação.",
|
||||
"not_authorized": "Não autorizado",
|
||||
"not_connected": "Não Conectado",
|
||||
"not_set": "Não definido",
|
||||
"note": "Nota",
|
||||
"notifications": "Notificações",
|
||||
"number": "Número",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "Relatório de Inquérito",
|
||||
"request_trial_license": "Solicitar licença de teste",
|
||||
"reset_to_default": "Repor para o padrão",
|
||||
"resize": "Redimensionar",
|
||||
"response": "Resposta",
|
||||
"response_id": "ID de resposta",
|
||||
"responses": "Respostas",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "Alguns ficheiros falharam ao carregar",
|
||||
"something_went_wrong": "Algo correu mal",
|
||||
"something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.",
|
||||
"soon": "Em breve",
|
||||
"sort_by": "Ordem",
|
||||
"start_free_trial": "Iniciar teste gratuito",
|
||||
"status": "Estado",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "Por favor, seleciona um painel",
|
||||
"predefined_measures": "Medidas predefinidas",
|
||||
"preset": "Predefinição",
|
||||
"preview_chart": "Visualizar gráfico",
|
||||
"query_executed_successfully": "Consulta executada com sucesso",
|
||||
"reset_to_ai_suggestion": "Repor sugestão da IA",
|
||||
"save_and_add_to_dashboard": "Salvar e adicionar ao painel",
|
||||
"save_chart": "Guardar gráfico",
|
||||
"save_chart_dialog_title": "Guardar gráfico",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "Data de início",
|
||||
"time_dimension": "Dimensão temporal",
|
||||
"time_dimension_title": "Adicionar agrupamento temporal",
|
||||
"time_dimension_toggle_description": "Monitoriza tendências ao longo do tempo."
|
||||
"time_dimension_toggle_description": "Monitoriza tendências ao longo do tempo.",
|
||||
"update_chart": "Atualizar gráfico"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adicionar {count} gráfico(s)",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "Criar painel",
|
||||
"create_dashboard_description": "Introduza um nome para o seu novo painel.",
|
||||
"create_failed": "Falha ao criar painel",
|
||||
"create_new_chart": "Criar novo gráfico",
|
||||
"create_success": "Painel criado com sucesso!",
|
||||
"dashboard": "Painel",
|
||||
"dashboard_delete_confirmation": "Tens a certeza de que queres eliminar este painel? Esta ação não pode ser revertida.",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "Falha ao duplicar painel",
|
||||
"duplicate_success": "Painel duplicado com sucesso!",
|
||||
"failed_to_load_chart_data": "Falha ao carregar os dados do gráfico",
|
||||
"no_charts_available_description": "Não há gráficos que possam ser adicionados a este painel. Ou ainda não existem gráficos, ou todos os gráficos existentes já foram adicionados. Vai à página de Gráficos para criar novos gráficos.",
|
||||
"no_charts_to_add_message": "Não há gráficos para adicionar a este painel.",
|
||||
"no_dashboards_found": "Nenhum painel encontrado.",
|
||||
"no_data_message": "Sem Dados. Atualmente não há informação para apresentar. Adiciona gráficos para construir o teu painel.",
|
||||
"please_enter_name": "Por favor, introduza um nome para o painel"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "Você não tem registros de feedback para relatar. Configure fontes de feedback para alimentar dados no sistema.",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "Configurar fontes de feedback"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Adicionar chave API",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "Diretórios de Feedback",
|
||||
"no_access": "Não tens permissão para gerir diretórios de registos de feedback.",
|
||||
"no_connectors": "Ainda não há conectores associados a este diretório.",
|
||||
"pause_connectors_confirmation_description": "Ao pausar estes conectores, deixam de ser adicionados novos registos.",
|
||||
"pause_connectors_confirmation_title": "Pausar conectores associados?",
|
||||
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
|
||||
"show_archived": "Mostrar arquivados",
|
||||
"title": "Diretórios de Registos de Feedback",
|
||||
"unarchive": "Desarquivar"
|
||||
"unarchive": "Desarquivar",
|
||||
"unarchive_workspace_conflict": "Não é possível desarquivar este diretório porque um ou mais workspaces atribuídos estão arquivados.",
|
||||
"workspace_access": "Acesso ao workspace"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "Veja quais as equipas que podem aceder a este espaço de trabalho."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Adicionar registro de feedback",
|
||||
"add_feedback_record_description": "Crie um registro de feedback manualmente.",
|
||||
"add_feedback_source": "Adicionar fonte de feedback",
|
||||
"add_source": "Adicionar fonte",
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"api_ingestion": "Ingestão de API",
|
||||
"api_ingestion_manage_api_keys": "Gerir chaves de API",
|
||||
"api_ingestion_settings_description": "Envia registos de feedback através da API de gestão.",
|
||||
"auto_generated": "Gerado automaticamente",
|
||||
"change_file": "Alterar ficheiro",
|
||||
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
|
||||
"click_to_upload": "Clique para carregar",
|
||||
"collected_at": "Recolhido em",
|
||||
"configure_import": "Configurar importação",
|
||||
"configure_mapping": "Configurar mapeamento",
|
||||
"connection": "Conexão",
|
||||
"connector_created_successfully": "Conector criado com sucesso",
|
||||
"connector_deleted_successfully": "Conector eliminado com sucesso",
|
||||
"connector_duplicated_successfully": "Conector duplicado com sucesso",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "Importar dados duas vezes irá criar registos duplicados.",
|
||||
"csv_inconsistent_columns": "A linha {row} tem colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.",
|
||||
"csv_max_records": "Máximo de {max} registos permitidos.",
|
||||
"custom_source_type": "Tipo de origem personalizado",
|
||||
"custom_source_type_placeholder": "Insira o tipo de fonte personalizado",
|
||||
"default_connector_name_csv": "Importação CSV",
|
||||
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
|
||||
"deselect_all": "Desselecionar tudo",
|
||||
"discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.",
|
||||
"discard_feedback_record_changes_title": "Descartar alterações não salvas?",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
"drop_field_or": "Solte o campo ou",
|
||||
"edit_csv_mapping": "Editar mapeamento CSV",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registos de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_record_created_successfully": "Registro de feedback criado com sucesso",
|
||||
"feedback_record_details": "Detalhes do registro de feedback",
|
||||
"feedback_record_details_description": "Revise e atualize os campos de registro de feedback.",
|
||||
"feedback_record_directory": "Diretório de Registos de Feedback",
|
||||
"feedback_record_fields": "Campos de registo de feedback",
|
||||
"feedback_record_mcp": "MCP de Registo de Feedback",
|
||||
"feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso",
|
||||
"feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado",
|
||||
"feedback_records": "Registos de feedback",
|
||||
"feedback_records_refreshed": "Registos de feedback atualizados",
|
||||
"feedback_sources": "Fontes de Feedback",
|
||||
"feedback_sources_directory_access_multiple": "Novos registos destas fontes serão armazenados em: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Novos registos desta fonte serão armazenados em: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Liga e gere todas as fontes de feedback para este espaço de trabalho.",
|
||||
"field_group_id": "ID do grupo de campos",
|
||||
"field_group_label": "Etiqueta do grupo de campos",
|
||||
"field_id": "ID do campo",
|
||||
"field_label": "Etiqueta do campo",
|
||||
"field_type": "Tipo de campo",
|
||||
"formbricks_surveys": "Pesquisas Formbricks",
|
||||
"frd_cannot_be_changed": "O diretório de feedback não pode ser alterado após a criação.",
|
||||
"go_to_feedback_record_directories": "Ir para definições de diretórios",
|
||||
"historical_import_complete": "Importação concluída: {successes} com sucesso, {failures} falharam, {skipped} ignorados (sem dados)",
|
||||
"import_csv_data": "Importar feedback",
|
||||
"import_feedback": "Importar feedback",
|
||||
"import_historical_responses": "Importar respostas históricas",
|
||||
"import_historical_responses_description": "Importa agora as respostas existentes deste inquérito.",
|
||||
"import_rows": "Importar {count} linhas",
|
||||
"import_via_source_name": "Importar via \"{sourceName}\"",
|
||||
"importing_data": "A importar dados...",
|
||||
"importing_historical_data": "A importar dados históricos...",
|
||||
"invalid_enum_values": "Valores inválidos na coluna mapeada para {field}",
|
||||
"invalid_values_found": "Encontrados: {values} (linhas: {rows}) {extra}",
|
||||
"load_sample_csv": "Carregar CSV de exemplo",
|
||||
"n_supported_questions": "{count} perguntas suportadas",
|
||||
"manage_directories": "Gerir diretórios",
|
||||
"manage_feedback_sources": "Gerenciar fontes de feedback",
|
||||
"metadata": "Metadados",
|
||||
"metadata_key": "Chave de metadados",
|
||||
"metadata_read_only_entries": "Valores de metadados somente leitura (sem string)",
|
||||
"metadata_value": "Valor dos metadados",
|
||||
"missing_feedback_source_title": "Falta alguma fonte de feedback?",
|
||||
"no_feedback_record_directory_available": "Não há nenhum diretório de registos de feedback atribuído a este espaço de trabalho. Cria ou atribui um primeiro.",
|
||||
"no_feedback_records": "Ainda não há registos de feedback. Os registos aparecerão aqui assim que os teus conectores começarem a enviar dados.",
|
||||
"no_source_fields_loaded": "Ainda não foram carregados campos de origem",
|
||||
"no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.",
|
||||
"no_surveys_found": "Nenhum inquérito encontrado neste ambiente",
|
||||
"optional": "Opcional",
|
||||
"or_drag_and_drop": "ou arraste e largue",
|
||||
"question_selected": "<strong>{count}</strong> pergunta selecionada. Cada resposta a esta pergunta criará um novo registo de feedback.",
|
||||
"question_type_not_supported": "Este tipo de pergunta não é suportado",
|
||||
"questions_selected": "<strong>{count}</strong> perguntas selecionadas. Cada resposta a estas perguntas criará um novo registo de feedback.",
|
||||
"records_will_go_to": "Os registos irão para",
|
||||
"refresh_feedback_records": "Atualizar registos de feedback",
|
||||
"refreshing_feedback_records": "A atualizar registos de feedback...",
|
||||
"request_feedback_source": "Solicitar integração de fonte",
|
||||
"required": "Obrigatório",
|
||||
"save_changes": "Guardar alterações",
|
||||
"select_a_survey_to_see_questions": "Selecione um inquérito para ver as suas perguntas",
|
||||
"select_a_value": "Selecione um valor...",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_feedback_record_directory": "Selecionar um diretório",
|
||||
"select_feedback_record_source_type": "Selecione o tipo de fonte",
|
||||
"select_questions": "Selecionar perguntas",
|
||||
"select_source_type_description": "Selecione o tipo de fonte de feedback que pretende conectar.",
|
||||
"select_source_type_prompt": "Selecione o tipo de fonte de feedback que pretende conectar:",
|
||||
"select_survey": "Selecionar inquérito",
|
||||
"select_survey_and_questions": "Selecionar inquérito e perguntas",
|
||||
"select_survey_questions_description": "Escolha quais perguntas do inquérito devem criar FeedbackRecords.",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "A mostrar 3 de {count} linhas",
|
||||
"source": "fonte",
|
||||
"source_connect_csv_description": "Importar feedback de ficheiros CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Envia registos de feedback através da integração MCP.",
|
||||
"source_connect_formbricks_description": "Conectar feedback dos seus inquéritos Formbricks",
|
||||
"source_fields": "Campos da fonte",
|
||||
"source_id": "ID da fonte",
|
||||
"source_name": "Nome da fonte",
|
||||
"source_type": "Tipo de fonte",
|
||||
"source_type_cannot_be_changed": "O tipo de fonte não pode ser alterado",
|
||||
"sources": "Fontes",
|
||||
"status_active": "Em progresso",
|
||||
"status_completed": "Concluído",
|
||||
"status_draft": "Rascunho",
|
||||
"status_error": "Erro",
|
||||
"status_live_sync": "Sincronização em direto",
|
||||
"status_paused": "Em pausa",
|
||||
"status_ready": "Pronto",
|
||||
"submission_id": "ID de envio",
|
||||
"survey_has_no_questions": "Este inquérito não tem perguntas",
|
||||
"survey_import_line": "{surveyName}: {responseCount} respostas × {questionCount} perguntas = {total} registos de feedback",
|
||||
"total_feedback_records": "Total: {checked} de {total} registos de feedback selecionados em {surveyCount} inquéritos",
|
||||
"topics_and_subtopics": "Tópicos e subtópicos",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Atualiza a configuração de mapeamento para esta origem.",
|
||||
"updated_at": "Atualizado em",
|
||||
"upload_csv_data_description": "Carrega um ficheiro CSV para importar dados de feedback.",
|
||||
"upload_csv_file": "Carregar ficheiro CSV",
|
||||
"user_identifier": "Utilizador",
|
||||
"value": "Valor"
|
||||
"value": "Valor",
|
||||
"value_boolean": "Valor (Booleano)",
|
||||
"value_date": "Valor (Data)",
|
||||
"value_number": "Valor (Número)",
|
||||
"value_text": "Valor (Texto)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "Activitate",
|
||||
"add": "Adaugă",
|
||||
"add_action": "Adăugați acțiune",
|
||||
"add_chart": "Adăugați diagramă",
|
||||
"add_charts": "Adaugă grafice",
|
||||
"add_existing_chart_description": "Caută și selectează grafice pentru a le adăuga la acest panou de control.",
|
||||
"add_filter": "Adăugați filtru",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Nu sunteți autentificat pentru a efectua această acțiune.",
|
||||
"not_authorized": "Neautorizat",
|
||||
"not_connected": "Neconectat",
|
||||
"not_set": "Nu setat",
|
||||
"note": "Notă",
|
||||
"notifications": "Notificări",
|
||||
"number": "Număr",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "Raportează chestionarul",
|
||||
"request_trial_license": "Solicitați o licență de încercare",
|
||||
"reset_to_default": "Revino la implicit",
|
||||
"resize": "Redimensionați",
|
||||
"response": "Răspuns",
|
||||
"response_id": "ID răspuns",
|
||||
"responses": "Răspunsuri",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "Unele fișiere nu au reușit să se încarce",
|
||||
"something_went_wrong": "Ceva nu a mers bine",
|
||||
"something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.",
|
||||
"soon": "În curând",
|
||||
"sort_by": "Sortare după",
|
||||
"start_free_trial": "Începe perioada de probă gratuită",
|
||||
"status": "Stare",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "Te rugăm să selectezi un tablou de bord",
|
||||
"predefined_measures": "Măsurători predefinite",
|
||||
"preset": "Presetare",
|
||||
"preview_chart": "Previzualizare diagramă",
|
||||
"query_executed_successfully": "Interogarea a fost executată cu succes",
|
||||
"reset_to_ai_suggestion": "Resetează la sugestia AI",
|
||||
"save_and_add_to_dashboard": "Salvați și adăugați în tabloul de bord",
|
||||
"save_chart": "Salvează graficul",
|
||||
"save_chart_dialog_title": "Salvează graficul",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "Data de început",
|
||||
"time_dimension": "Dimensiune temporală",
|
||||
"time_dimension_title": "Adaugă grupare pe bază de timp",
|
||||
"time_dimension_toggle_description": "Monitorizează tendințele în timp."
|
||||
"time_dimension_toggle_description": "Monitorizează tendințele în timp.",
|
||||
"update_chart": "Actualizați diagrama"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adaugă {count} grafic(e)",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "Creează panou de control",
|
||||
"create_dashboard_description": "Introdu un nume pentru noul tău tablou de bord.",
|
||||
"create_failed": "Crearea tabloului de bord a eșuat",
|
||||
"create_new_chart": "Creați o nouă diagramă",
|
||||
"create_success": "Tablou de bord creat cu succes!",
|
||||
"dashboard": "Panou de control",
|
||||
"dashboard_delete_confirmation": "Ești sigur că vrei să ștergi acest panou de control? Această acțiune nu poate fi anulată.",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "Duplicarea tabloului de bord a eșuat",
|
||||
"duplicate_success": "Tablou de bord duplicat cu succes!",
|
||||
"failed_to_load_chart_data": "Încărcarea datelor graficului a eșuat",
|
||||
"no_charts_available_description": "Nu există grafice care pot fi adăugate la acest tablou de bord. Fie nu există încă grafice, fie toate graficele existente au fost deja adăugate. Mergi la pagina Grafice pentru a crea grafice noi.",
|
||||
"no_charts_to_add_message": "Nu există grafice de adăugat la acest tablou de bord.",
|
||||
"no_dashboards_found": "Nu s-a găsit niciun tablou de bord.",
|
||||
"no_data_message": "Fără date. În prezent nu există informații de afișat. Adaugă grafice pentru a-ți construi tabloul de bord.",
|
||||
"please_enter_name": "Te rugăm să introduci un nume pentru tablou de bord"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "Nu aveți înregistrări de feedback despre care să raportați. Configurați sursele de feedback pentru a introduce date în sistem.",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "Configurați sursele de feedback"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Adaugă cheie API",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "Directoare de feedback",
|
||||
"no_access": "Nu ai permisiunea de a gestiona directoarele de înregistrări de feedback.",
|
||||
"no_connectors": "Niciun conector asociat acestui director încă.",
|
||||
"pause_connectors_confirmation_description": "Dacă pui pe pauză acești conectori, nu vor mai fi adăugate înregistrări noi.",
|
||||
"pause_connectors_confirmation_title": "Pauzezi conectorii asociați?",
|
||||
"select_workspaces_placeholder": "Selectează spații de lucru...",
|
||||
"show_archived": "Afișează arhivate",
|
||||
"title": "Directoare de Înregistrări Feedback",
|
||||
"unarchive": "Dezarhivează"
|
||||
"unarchive": "Dezarhivează",
|
||||
"unarchive_workspace_conflict": "Acest director nu poate fi dezarhivat deoarece unul sau mai multe spații de lucru alocate sunt arhivate.",
|
||||
"workspace_access": "Acces la spațiul de lucru"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Îmbogățire și analiză de date (AI)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "Vedeți ce echipe pot accesa acest spațiu de lucru."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Adăugați înregistrarea de feedback",
|
||||
"add_feedback_record_description": "Creați manual o înregistrare de feedback.",
|
||||
"add_feedback_source": "Adaugă sursă de feedback",
|
||||
"add_source": "Adaugă sursă",
|
||||
"allowed_values": "Valori permise: {values}",
|
||||
"api_ingestion": "Ingestie API",
|
||||
"api_ingestion_manage_api_keys": "Gestionează cheile API",
|
||||
"api_ingestion_settings_description": "Trimite înregistrări de feedback folosind API-ul de management.",
|
||||
"auto_generated": "Generat automat",
|
||||
"change_file": "Schimbă fișierul",
|
||||
"click_load_sample_csv": "Apasă pe „Încarcă CSV de exemplu” pentru a vedea coloanele",
|
||||
"click_to_upload": "Apasă pentru a încărca",
|
||||
"collected_at": "Colectat la",
|
||||
"configure_import": "Configurează importul",
|
||||
"configure_mapping": "Configurează maparea",
|
||||
"connection": "Conexiune",
|
||||
"connector_created_successfully": "Conector creat cu succes",
|
||||
"connector_deleted_successfully": "Conector șters cu succes",
|
||||
"connector_duplicated_successfully": "Conector duplicat cu succes",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "Importarea datelor de două ori va crea înregistrări duplicate.",
|
||||
"csv_inconsistent_columns": "Rândul {row} are coloane inconsistente. Toate rândurile trebuie să aibă aceleași antete.",
|
||||
"csv_max_records": "Sunt permise maximum {max} înregistrări.",
|
||||
"custom_source_type": "Tip sursă personalizat",
|
||||
"custom_source_type_placeholder": "Introduceți tipul de sursă personalizat",
|
||||
"default_connector_name_csv": "Import CSV",
|
||||
"default_connector_name_formbricks": "Conexiune chestionar Formbricks",
|
||||
"deselect_all": "Deselectează tot",
|
||||
"discard_feedback_record_changes_description": "Modificările dvs. se vor pierde dacă închideți acest sertar.",
|
||||
"discard_feedback_record_changes_title": "Renunțați la modificările nesalvate?",
|
||||
"drop_a_field_here": "Trage un câmp aici",
|
||||
"drop_field_or": "Trage câmpul sau",
|
||||
"edit_csv_mapping": "Editează maparea CSV",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback",
|
||||
"feedback_date": "Data curentă",
|
||||
"feedback_record_created_successfully": "Înregistrare de feedback creată cu succes",
|
||||
"feedback_record_details": "Detaliile înregistrării feedback-ului",
|
||||
"feedback_record_details_description": "Examinați și actualizați câmpurile pentru înregistrarea de feedback.",
|
||||
"feedback_record_directory": "Director de înregistrări feedback",
|
||||
"feedback_record_fields": "Câmpuri înregistrare feedback",
|
||||
"feedback_record_mcp": "MCP Înregistrări Feedback",
|
||||
"feedback_record_updated_successfully": "Înregistrarea feedback-ului a fost actualizată cu succes",
|
||||
"feedback_record_value_required": "Este necesară o valoare pentru tipul de câmp selectat",
|
||||
"feedback_records": "Înregistrări de feedback",
|
||||
"feedback_records_refreshed": "Înregistrările de feedback au fost actualizate",
|
||||
"feedback_sources": "Surse de feedback",
|
||||
"feedback_sources_directory_access_multiple": "Înregistrările noi din aceste surse vor fi stocate în: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Înregistrările noi din această sursă vor fi stocate în: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Conectează și gestionează toate sursele de feedback pentru acest spațiu de lucru.",
|
||||
"field_group_id": "ID grup de câmpuri",
|
||||
"field_group_label": "Eticheta grupului de câmpuri",
|
||||
"field_id": "ID-ul câmpului",
|
||||
"field_label": "Etichetă câmp",
|
||||
"field_type": "Tip câmp",
|
||||
"formbricks_surveys": "Chestionare Formbricks",
|
||||
"frd_cannot_be_changed": "Directorul de feedback nu poate fi modificat după creare.",
|
||||
"go_to_feedback_record_directories": "Mergi la setările directoarelor",
|
||||
"historical_import_complete": "Import finalizat: {successes} reușite, {failures} eșuate, {skipped} omise (fără date)",
|
||||
"import_csv_data": "Importă feedback",
|
||||
"import_feedback": "Importă feedback",
|
||||
"import_historical_responses": "Importă răspunsuri istorice",
|
||||
"import_historical_responses_description": "Importă acum răspunsurile existente din acest sondaj.",
|
||||
"import_rows": "Importă {count, plural, one {# rând} few {# rânduri} other {# de rânduri}}",
|
||||
"import_via_source_name": "Import prin „{sourceName}”",
|
||||
"importing_data": "Se importă datele...",
|
||||
"importing_historical_data": "Se importă datele istorice...",
|
||||
"invalid_enum_values": "Valori invalide în coloana mapată la {field}",
|
||||
"invalid_values_found": "Găsite: {values} (rânduri: {rows}) {extra}",
|
||||
"load_sample_csv": "Încarcă un CSV de exemplu",
|
||||
"n_supported_questions": "{count} întrebări acceptate",
|
||||
"manage_directories": "Gestionează directoarele",
|
||||
"manage_feedback_sources": "Gestionați sursele de feedback",
|
||||
"metadata": "Metadate",
|
||||
"metadata_key": "Cheia de metadate",
|
||||
"metadata_read_only_entries": "Valori de metadate numai pentru citire (fără șir)",
|
||||
"metadata_value": "Valoarea metadatelor",
|
||||
"missing_feedback_source_title": "Lipsește o sursă de feedback?",
|
||||
"no_feedback_record_directory_available": "Niciun director de înregistrări feedback atribuit acestui spațiu de lucru. Creează sau atribuie unul mai întâi.",
|
||||
"no_feedback_records": "Nu există încă înregistrări de feedback. Înregistrările vor apărea aici după ce conectorii tăi vor începe să trimită date.",
|
||||
"no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă",
|
||||
"no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.",
|
||||
"no_surveys_found": "Nu s-au găsit sondaje în acest mediu",
|
||||
"optional": "Opțional",
|
||||
"or_drag_and_drop": "sau trage și lasă aici",
|
||||
"question_selected": "<strong>{count}</strong> întrebare selectată. Fiecare răspuns la aceste întrebări va crea un nou Feedback Record.",
|
||||
"question_type_not_supported": "Acest tip de întrebare nu este suportat",
|
||||
"questions_selected": "<strong>{count}</strong> întrebări selectate. Fiecare răspuns la aceste întrebări va crea un nou Feedback Record.",
|
||||
"records_will_go_to": "Înregistrările vor ajunge în",
|
||||
"refresh_feedback_records": "Reîmprospătează înregistrările de feedback",
|
||||
"refreshing_feedback_records": "Se actualizează înregistrările de feedback...",
|
||||
"request_feedback_source": "Solicită integrarea sursei",
|
||||
"required": "Obligatoriu",
|
||||
"save_changes": "Salvează modificările",
|
||||
"select_a_survey_to_see_questions": "Selectează un chestionar pentru a vedea întrebările",
|
||||
"select_a_value": "Selectează o valoare...",
|
||||
"select_all": "Selectează tot",
|
||||
"select_feedback_record_directory": "Selectează un director",
|
||||
"select_feedback_record_source_type": "Selectați tipul sursei",
|
||||
"select_questions": "Selectează întrebări",
|
||||
"select_source_type_description": "Selectează tipul sursei de feedback pe care vrei să o conectezi.",
|
||||
"select_source_type_prompt": "Selectează tipul sursei de feedback pe care vrei să o conectezi:",
|
||||
"select_survey": "Selectează chestionar",
|
||||
"select_survey_and_questions": "Selectează chestionar și întrebări",
|
||||
"select_survey_questions_description": "Alege ce întrebări din chestionar vor crea FeedbackRecords.",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "Se afișează 3 din {count} rânduri",
|
||||
"source": "sursă",
|
||||
"source_connect_csv_description": "Importă feedback din fișiere CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Trimite înregistrări de feedback prin integrarea MCP.",
|
||||
"source_connect_formbricks_description": "Conectează feedback din sondajele Formbricks",
|
||||
"source_fields": "Câmpuri sursă",
|
||||
"source_id": "ID sursă",
|
||||
"source_name": "Nume sursă",
|
||||
"source_type": "Tip sursă",
|
||||
"source_type_cannot_be_changed": "Tipul sursei nu poate fi schimbat",
|
||||
"sources": "Surse",
|
||||
"status_active": "În progres",
|
||||
"status_completed": "Finalizat",
|
||||
"status_draft": "Schiță",
|
||||
"status_error": "Eroare",
|
||||
"status_live_sync": "Sincronizare în timp real",
|
||||
"status_paused": "Pauzat",
|
||||
"status_ready": "Gata",
|
||||
"submission_id": "ID-ul trimiterii",
|
||||
"survey_has_no_questions": "Acest sondaj nu are întrebări",
|
||||
"survey_import_line": "{surveyName}: {responseCount} răspunsuri × {questionCount} întrebări = {total} Feedback Records",
|
||||
"total_feedback_records": "Total: {checked} din {total} Feedback Records selectate în {surveyCount} sondaje",
|
||||
"topics_and_subtopics": "Subiecte și subiecte secundare",
|
||||
"unify_feedback": "Unify Feedback",
|
||||
"update_mapping_description": "Actualizează configurația de mapare pentru această sursă.",
|
||||
"updated_at": "Actualizat la",
|
||||
"upload_csv_data_description": "Încarcă un fișier CSV pentru a importa date de feedback.",
|
||||
"upload_csv_file": "Încarcă fișier CSV",
|
||||
"user_identifier": "Utilizator",
|
||||
"value": "Valoare"
|
||||
"value": "Valoare",
|
||||
"value_boolean": "Valoare (booleană)",
|
||||
"value_date": "Valoare (data)",
|
||||
"value_number": "Valoare (număr)",
|
||||
"value_text": "Valoare (Text)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "Активность",
|
||||
"add": "Добавить",
|
||||
"add_action": "Добавить действие",
|
||||
"add_chart": "Добавить диаграмму",
|
||||
"add_charts": "Добавить графики",
|
||||
"add_existing_chart_description": "Найдите и выберите графики для добавления на этот дашборд.",
|
||||
"add_filter": "Добавить фильтр",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "У вас нет прав для выполнения этого действия.",
|
||||
"not_authorized": "Нет доступа",
|
||||
"not_connected": "Нет подключения",
|
||||
"not_set": "Не установлено",
|
||||
"note": "Примечание",
|
||||
"notifications": "Уведомления",
|
||||
"number": "Номер",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "Пожаловаться на опрос",
|
||||
"request_trial_license": "Запросить пробную лицензию",
|
||||
"reset_to_default": "Сбросить по умолчанию",
|
||||
"resize": "Изменить размер",
|
||||
"response": "Ответ",
|
||||
"response_id": "ID ответа",
|
||||
"responses": "Ответы",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "Не удалось загрузить некоторые файлы",
|
||||
"something_went_wrong": "Что-то пошло не так",
|
||||
"something_went_wrong_please_try_again": "Что-то пошло не так. Пожалуйста, попробуйте ещё раз.",
|
||||
"soon": "Скоро",
|
||||
"sort_by": "Сортировать по",
|
||||
"start_free_trial": "Начать бесплатный пробный период",
|
||||
"status": "Статус",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "Пожалуйста, выбери панель управления",
|
||||
"predefined_measures": "Предустановленные показатели",
|
||||
"preset": "Пресет",
|
||||
"preview_chart": "Предварительный просмотр диаграммы",
|
||||
"query_executed_successfully": "Запрос успешно выполнен",
|
||||
"reset_to_ai_suggestion": "Сбросить к предложению ИИ",
|
||||
"save_and_add_to_dashboard": "Сохранить и добавить на панель управления",
|
||||
"save_chart": "Сохранить график",
|
||||
"save_chart_dialog_title": "Сохранить график",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "Дата начала",
|
||||
"time_dimension": "Временное измерение",
|
||||
"time_dimension_title": "Добавить группировку по времени",
|
||||
"time_dimension_toggle_description": "Отслеживайте тренды с течением времени."
|
||||
"time_dimension_toggle_description": "Отслеживайте тренды с течением времени.",
|
||||
"update_chart": "Обновить диаграмму"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Добавить {count} график(ов)",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "Создать дашборд",
|
||||
"create_dashboard_description": "Введите название для новой панели управления.",
|
||||
"create_failed": "Не удалось создать панель управления",
|
||||
"create_new_chart": "Создать новую диаграмму",
|
||||
"create_success": "Панель управления успешно создана!",
|
||||
"dashboard": "Дашборд",
|
||||
"dashboard_delete_confirmation": "Вы уверены, что хотите удалить этот дашборд? Это действие нельзя отменить.",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "Не удалось дублировать панель управления",
|
||||
"duplicate_success": "Панель управления успешно продублирована!",
|
||||
"failed_to_load_chart_data": "Не удалось загрузить данные графика",
|
||||
"no_charts_available_description": "Нет графиков, которые можно добавить к этому дашборду. Либо графики ещё не созданы, либо все существующие графики уже добавлены. Перейдите на страницу «Графики», чтобы создать новые графики.",
|
||||
"no_charts_to_add_message": "Нет графиков для добавления к этому дашборду.",
|
||||
"no_dashboards_found": "Панели управления не найдены.",
|
||||
"no_data_message": "Нет данных. В настоящее время нет информации для отображения. Добавьте графики, чтобы построить свой дашборд.",
|
||||
"please_enter_name": "Пожалуйста, введите название панели управления"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "У вас нет записей обратной связи, о которых можно сообщить. Настройте источники обратной связи для подачи данных в систему.",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "Настройка источников обратной связи"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Добавить API-ключ",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "Каталоги отзывов",
|
||||
"no_access": "У тебя нет прав для управления каталогами записей отзывов.",
|
||||
"no_connectors": "К этому каталогу пока не привязано ни одного коннектора.",
|
||||
"pause_connectors_confirmation_description": "Если приостановить эти коннекторы, новые записи больше не будут добавляться.",
|
||||
"pause_connectors_confirmation_title": "Приостановить связанные коннекторы?",
|
||||
"select_workspaces_placeholder": "Выберите рабочие области...",
|
||||
"show_archived": "Показать архивные",
|
||||
"title": "Директории записей обратной связи",
|
||||
"unarchive": "Разархивировать"
|
||||
"unarchive": "Разархивировать",
|
||||
"unarchive_workspace_conflict": "Невозможно разархивировать этот каталог, потому что один или несколько назначенных рабочих пространств архивированы.",
|
||||
"workspace_access": "Доступ к рабочему пространству"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "Посмотрите, какие команды имеют доступ к этому рабочему пространству."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Добавить запись отзыва",
|
||||
"add_feedback_record_description": "Создайте запись обратной связи вручную.",
|
||||
"add_feedback_source": "Добавить источник отзывов",
|
||||
"add_source": "Добавить источник",
|
||||
"allowed_values": "Допустимые значения: {values}",
|
||||
"api_ingestion": "Импорт через API",
|
||||
"api_ingestion_manage_api_keys": "Управление API-ключами",
|
||||
"api_ingestion_settings_description": "Отправляйте записи обратной связи через Management API.",
|
||||
"auto_generated": "Автоматически генерируется",
|
||||
"change_file": "Изменить файл",
|
||||
"click_load_sample_csv": "Нажмите «Загрузить пример CSV», чтобы увидеть столбцы",
|
||||
"click_to_upload": "Кликните для загрузки",
|
||||
"collected_at": "Собрано",
|
||||
"configure_import": "Настроить импорт",
|
||||
"configure_mapping": "Настроить сопоставление",
|
||||
"connection": "Подключение",
|
||||
"connector_created_successfully": "Коннектор успешно создан",
|
||||
"connector_deleted_successfully": "Коннектор успешно удалён",
|
||||
"connector_duplicated_successfully": "Коннектор успешно дублирован",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "Импорт уже загруженных данных может создать дубликаты записей.",
|
||||
"csv_inconsistent_columns": "В строке {row} несоответствие столбцов. Во всех строках должны быть одинаковые заголовки.",
|
||||
"csv_max_records": "Допустимо не более {max} записей.",
|
||||
"custom_source_type": "Пользовательский тип источника",
|
||||
"custom_source_type_placeholder": "Введите собственный тип источника",
|
||||
"default_connector_name_csv": "Импорт CSV",
|
||||
"default_connector_name_formbricks": "Подключение опроса Formbricks",
|
||||
"deselect_all": "Снять выделение со всех",
|
||||
"discard_feedback_record_changes_description": "Ваши изменения будут потеряны, если вы закроете этот ящик.",
|
||||
"discard_feedback_record_changes_title": "Отменить несохраненные изменения?",
|
||||
"drop_a_field_here": "Перетащи сюда поле",
|
||||
"drop_field_or": "Перетащи поле или",
|
||||
"edit_csv_mapping": "Редактировать сопоставление CSV",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Не удалось загрузить отзывы",
|
||||
"feedback_date": "Текущая дата",
|
||||
"feedback_record_created_successfully": "Запись отзыва успешно создана",
|
||||
"feedback_record_details": "Детали записи обратной связи",
|
||||
"feedback_record_details_description": "Просмотрите и обновите поля записи отзыва.",
|
||||
"feedback_record_directory": "Каталог записей обратной связи",
|
||||
"feedback_record_fields": "Поля записи отзыва",
|
||||
"feedback_record_mcp": "MCP для записей обратной связи",
|
||||
"feedback_record_updated_successfully": "Запись отзыва успешно обновлена.",
|
||||
"feedback_record_value_required": "Требуется значение для выбранного типа поля.",
|
||||
"feedback_records": "Записи отзывов",
|
||||
"feedback_records_refreshed": "Записи отзывов обновлены",
|
||||
"feedback_sources": "Источники обратной связи",
|
||||
"feedback_sources_directory_access_multiple": "Новые записи из этих источников будут сохранены в: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "Новые записи из этого источника будут сохранены в: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Подключайте источники обратной связи и управляйте ими для этого рабочего пространства.",
|
||||
"field_group_id": "Идентификатор группы полей",
|
||||
"field_group_label": "Метка группы полей",
|
||||
"field_id": "Идентификатор поля",
|
||||
"field_label": "Метка поля",
|
||||
"field_type": "Тип поля",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "Каталог обратной связи нельзя изменить после создания.",
|
||||
"go_to_feedback_record_directories": "Перейти к настройкам каталогов",
|
||||
"historical_import_complete": "Импорт завершён: {successes} успешно, {failures} с ошибками, {skipped} пропущено (нет данных)",
|
||||
"import_csv_data": "Импортировать отзывы",
|
||||
"import_feedback": "Импортировать отзывы",
|
||||
"import_historical_responses": "Импортировать предыдущие ответы",
|
||||
"import_historical_responses_description": "Импортируйте существующие ответы из этого опроса прямо сейчас.",
|
||||
"import_rows": "Импортировать {count, plural, one {# строку} few {# строки} many {# строк} other {# строки}}",
|
||||
"import_via_source_name": "Импорт через «{sourceName}»",
|
||||
"importing_data": "Импорт данных...",
|
||||
"importing_historical_data": "Импорт исторических данных...",
|
||||
"invalid_enum_values": "Недопустимые значения в столбце, сопоставленном с {field}",
|
||||
"invalid_values_found": "Найдено: {values} (строки: {rows}) {extra}",
|
||||
"load_sample_csv": "Загрузить пример CSV",
|
||||
"n_supported_questions": "Поддерживается {count} вопрос(ов)",
|
||||
"manage_directories": "Управление директориями",
|
||||
"manage_feedback_sources": "Управление источниками обратной связи",
|
||||
"metadata": "Метаданные",
|
||||
"metadata_key": "Ключ метаданных",
|
||||
"metadata_read_only_entries": "Значения метаданных только для чтения (нестроковые)",
|
||||
"metadata_value": "Значение метаданных",
|
||||
"missing_feedback_source_title": "Не нашли нужный источник обратной связи?",
|
||||
"no_feedback_record_directory_available": "К этому рабочему пространству не назначен каталог записей обратной связи. Сначала создайте или назначьте каталог.",
|
||||
"no_feedback_records": "Пока нет записей отзывов. Они появятся здесь, когда коннекторы начнут отправлять данные.",
|
||||
"no_source_fields_loaded": "Поля источника ещё не загружены",
|
||||
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
|
||||
"no_surveys_found": "В этой среде не найдено опросов",
|
||||
"optional": "Необязательно",
|
||||
"or_drag_and_drop": "или перетащите файл",
|
||||
"question_selected": "<strong>{count}</strong> выбранный вопрос. Каждый ответ на эти вопросы создаст новую запись обратной связи.",
|
||||
"question_type_not_supported": "Этот тип вопроса не поддерживается",
|
||||
"questions_selected": "<strong>{count}</strong> выбранных вопроса. Каждый ответ на эти вопросы создаст новую запись обратной связи.",
|
||||
"records_will_go_to": "Записи будут отправлены в",
|
||||
"refresh_feedback_records": "Обновить записи отзывов",
|
||||
"refreshing_feedback_records": "Обновляем записи отзывов...",
|
||||
"request_feedback_source": "Запросить интеграцию источника",
|
||||
"required": "Обязательно",
|
||||
"save_changes": "Сохранить изменения",
|
||||
"select_a_survey_to_see_questions": "Выберите опрос, чтобы увидеть его вопросы",
|
||||
"select_a_value": "Выберите значение...",
|
||||
"select_all": "Выбрать все",
|
||||
"select_feedback_record_directory": "Выберите каталог",
|
||||
"select_feedback_record_source_type": "Выберите тип источника",
|
||||
"select_questions": "Выберите вопросы",
|
||||
"select_source_type_description": "Выберите тип источника отзывов, который хотите подключить.",
|
||||
"select_source_type_prompt": "Выберите тип источника отзывов, который хотите подключить:",
|
||||
"select_survey": "Выбрать опрос",
|
||||
"select_survey_and_questions": "Выбрать опрос и вопросы",
|
||||
"select_survey_questions_description": "Выберите, какие вопросы опроса должны создавать FeedbackRecords.",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "Показано 3 из {count} строк",
|
||||
"source": "источник",
|
||||
"source_connect_csv_description": "Импортировать отзывы из CSV-файлов",
|
||||
"source_connect_feedback_record_mcp_description": "Отправляйте записи обратной связи через интеграцию MCP.",
|
||||
"source_connect_formbricks_description": "Подключить отзывы из ваших опросов Formbricks",
|
||||
"source_fields": "Поля источника",
|
||||
"source_id": "Идентификатор источника",
|
||||
"source_name": "Имя источника",
|
||||
"source_type": "Тип источника",
|
||||
"source_type_cannot_be_changed": "Тип источника нельзя изменить",
|
||||
"sources": "Источники",
|
||||
"status_active": "В процессе",
|
||||
"status_completed": "Завершён",
|
||||
"status_draft": "Черновик",
|
||||
"status_error": "Ошибка",
|
||||
"status_live_sync": "Синхронизация в реальном времени",
|
||||
"status_paused": "Приостановлен",
|
||||
"status_ready": "Готово",
|
||||
"submission_id": "Идентификатор отправки",
|
||||
"survey_has_no_questions": "В этом опросе нет вопросов",
|
||||
"survey_import_line": "{surveyName}: {responseCount} ответов × {questionCount} вопросов = {total} записей обратной связи",
|
||||
"total_feedback_records": "Всего: выбрано {checked} из {total} записей обратной связи в {surveyCount} опросах",
|
||||
"topics_and_subtopics": "Темы и подтемы",
|
||||
"unify_feedback": "Обратная связь Unify",
|
||||
"update_mapping_description": "Обнови настройки сопоставления для этого источника.",
|
||||
"updated_at": "Обновлено",
|
||||
"upload_csv_data_description": "Загрузи CSV-файл, чтобы импортировать данные отзывов.",
|
||||
"upload_csv_file": "Загрузить CSV-файл",
|
||||
"user_identifier": "Пользователь",
|
||||
"value": "Значение"
|
||||
"value": "Значение",
|
||||
"value_boolean": "Значение (логическое)",
|
||||
"value_date": "Значение (Дата)",
|
||||
"value_number": "Значение (число)",
|
||||
"value_text": "Значение (текст)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "Aktivitet",
|
||||
"add": "Lägg till",
|
||||
"add_action": "Lägg till åtgärd",
|
||||
"add_chart": "Lägg till diagram",
|
||||
"add_charts": "Lägg till diagram",
|
||||
"add_existing_chart_description": "Sök och välj diagram att lägga till i den här instrumentpanelen.",
|
||||
"add_filter": "Lägg till filter",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Du är inte autentiserad för att utföra denna åtgärd.",
|
||||
"not_authorized": "Ej behörig",
|
||||
"not_connected": "Ej ansluten",
|
||||
"not_set": "Inte inställt",
|
||||
"note": "Anteckning",
|
||||
"notifications": "Aviseringar",
|
||||
"number": "Nummer",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "Rapportera enkät",
|
||||
"request_trial_license": "Begär provlicens",
|
||||
"reset_to_default": "Återställ till standard",
|
||||
"resize": "Ändra storlek",
|
||||
"response": "Svar",
|
||||
"response_id": "Svar-ID",
|
||||
"responses": "Svar",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "Några filer misslyckades att laddas upp",
|
||||
"something_went_wrong": "Något gick fel",
|
||||
"something_went_wrong_please_try_again": "Något gick fel. Försök igen.",
|
||||
"soon": "Snart",
|
||||
"sort_by": "Sortera efter",
|
||||
"start_free_trial": "Starta gratis provperiod",
|
||||
"status": "Status",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "Välj en instrumentpanel",
|
||||
"predefined_measures": "Fördefinierade mått",
|
||||
"preset": "Förinställning",
|
||||
"preview_chart": "Förhandsgranska diagram",
|
||||
"query_executed_successfully": "Frågan kördes utan problem",
|
||||
"reset_to_ai_suggestion": "Återställ till AI-förslag",
|
||||
"save_and_add_to_dashboard": "Spara och lägg till i instrumentpanelen",
|
||||
"save_chart": "Spara diagram",
|
||||
"save_chart_dialog_title": "Spara diagram",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "Startdatum",
|
||||
"time_dimension": "Tidsdimension",
|
||||
"time_dimension_title": "Lägg till tidsbaserad gruppering",
|
||||
"time_dimension_toggle_description": "Övervaka trender över tid."
|
||||
"time_dimension_toggle_description": "Övervaka trender över tid.",
|
||||
"update_chart": "Uppdatera diagram"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Lägg till {count} diagram",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "Skapa instrumentpanel",
|
||||
"create_dashboard_description": "Ange ett namn för din nya instrumentpanel.",
|
||||
"create_failed": "Det gick inte att skapa instrumentpanelen",
|
||||
"create_new_chart": "Skapa nytt diagram",
|
||||
"create_success": "Instrumentpanelen har skapats!",
|
||||
"dashboard": "Instrumentpanel",
|
||||
"dashboard_delete_confirmation": "Är du säker på att du vill ta bort den här instrumentpanelen? Den här åtgärden kan inte ångras.",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "Det gick inte att duplicera instrumentpanelen",
|
||||
"duplicate_success": "Instrumentpanelen har duplicerats!",
|
||||
"failed_to_load_chart_data": "Det gick inte att ladda diagramdata",
|
||||
"no_charts_available_description": "Det finns inga diagram som kan läggas till på den här instrumentpanelen. Antingen finns inga diagram än, eller så har alla befintliga diagram redan lagts till. Gå till sidan Diagram för att skapa nya diagram.",
|
||||
"no_charts_to_add_message": "Inga diagram att lägga till på den här instrumentpanelen.",
|
||||
"no_dashboards_found": "Inga instrumentpaneler hittades.",
|
||||
"no_data_message": "Ingen data. Det finns för närvarande ingen information att visa. Lägg till diagram för att bygga din instrumentpanel.",
|
||||
"please_enter_name": "Ange ett namn på instrumentpanelen"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "Du har inga feedbackposter att rapportera om. Ställ in återkopplingskällor för att mata in data i systemet.",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "Ställ in feedbackkällor"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Lägg till API-nyckel",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "Feedbackkataloger",
|
||||
"no_access": "Du har inte behörighet att hantera kataloger för feedbackposter.",
|
||||
"no_connectors": "Inga kopplingar länkade till den här katalogen ännu.",
|
||||
"pause_connectors_confirmation_description": "Om du pausar dessa kopplingar kommer inga nya poster att läggas till.",
|
||||
"pause_connectors_confirmation_title": "Pausa länkade kopplingar?",
|
||||
"select_workspaces_placeholder": "Välj arbetsytor...",
|
||||
"show_archived": "Visa arkiverade",
|
||||
"title": "Feedbackkataloger",
|
||||
"unarchive": "Avarkivera"
|
||||
"unarchive": "Avarkivera",
|
||||
"unarchive_workspace_conflict": "Den här katalogen kan inte avarkiveras eftersom en eller flera tilldelade arbetsytor är arkiverade.",
|
||||
"workspace_access": "Arbetsyteåtkomst"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Dataförbättring & analys (AI)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "Se vilka team som har tillgång till denna arbetsyta."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Lägg till feedbackpost",
|
||||
"add_feedback_record_description": "Skapa en feedbackpost manuellt.",
|
||||
"add_feedback_source": "Lägg till feedbackkälla",
|
||||
"add_source": "Lägg till källa",
|
||||
"allowed_values": "Tillåtna värden: {values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_manage_api_keys": "Manage API keys",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "Automatiskt genererad",
|
||||
"change_file": "Byt fil",
|
||||
"click_load_sample_csv": "Klicka på 'Ladda exempel-CSV' för att se kolumner",
|
||||
"click_to_upload": "Klicka för att ladda upp",
|
||||
"collected_at": "Insamlad",
|
||||
"configure_import": "Konfigurera import",
|
||||
"configure_mapping": "Konfigurera mappning",
|
||||
"connection": "Anslutning",
|
||||
"connector_created_successfully": "Kopplingen skapades",
|
||||
"connector_deleted_successfully": "Kopplingen togs bort",
|
||||
"connector_duplicated_successfully": "Kopplingen har duplicerats",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "Om du importerar data två gånger kommer det att skapa dubbletter.",
|
||||
"csv_inconsistent_columns": "Rad {row} har inkonsekventa kolumner. Alla rader måste ha samma rubriker.",
|
||||
"csv_max_records": "Maximalt {max} poster tillåtna.",
|
||||
"custom_source_type": "Anpassad källtyp",
|
||||
"custom_source_type_placeholder": "Ange anpassad källtyp",
|
||||
"default_connector_name_csv": "CSV-import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey-anslutning",
|
||||
"deselect_all": "Avmarkera alla",
|
||||
"discard_feedback_record_changes_description": "Dina ändringar kommer att gå förlorade om du stänger den här lådan.",
|
||||
"discard_feedback_record_changes_title": "Vill du ignorera osparade ändringar?",
|
||||
"drop_a_field_here": "Släpp ett fält här",
|
||||
"drop_field_or": "Släpp fält eller",
|
||||
"edit_csv_mapping": "Redigera CSV-mappning",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter",
|
||||
"feedback_date": "Aktuellt datum",
|
||||
"feedback_record_created_successfully": "Feedbackposten har skapats",
|
||||
"feedback_record_details": "Feedbackpostdetaljer",
|
||||
"feedback_record_details_description": "Granska och uppdatera fält för feedbackposter.",
|
||||
"feedback_record_directory": "Katalog för feedbackposter",
|
||||
"feedback_record_fields": "Fält för feedbackpost",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"feedback_record_updated_successfully": "Feedbackposten har uppdaterats",
|
||||
"feedback_record_value_required": "Ett värde krävs för den valda fälttypen",
|
||||
"feedback_records": "Feedbackposter",
|
||||
"feedback_records_refreshed": "Feedbackposter har uppdaterats",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
|
||||
"field_group_id": "Fältgrupp-ID",
|
||||
"field_group_label": "Fältgruppsetikett",
|
||||
"field_id": "Fält-ID",
|
||||
"field_label": "Fältetikett",
|
||||
"field_type": "Fälttyp",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "Feedbackkatalog kan inte ändras efter att den skapats.",
|
||||
"go_to_feedback_record_directories": "Gå till kataloginställningar",
|
||||
"historical_import_complete": "Importen klar: {successes} lyckades, {failures} misslyckades, {skipped} hoppades över (ingen data)",
|
||||
"import_csv_data": "Importera feedback",
|
||||
"import_feedback": "Importera feedback",
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "Importera {count} rader",
|
||||
"import_via_source_name": "Importera via \"{sourceName}\"",
|
||||
"importing_data": "Importerar data...",
|
||||
"importing_historical_data": "Importerar historisk data...",
|
||||
"invalid_enum_values": "Ogiltiga värden i kolumnen som är kopplad till {field}",
|
||||
"invalid_values_found": "Hittade: {values} (rader: {rows}) {extra}",
|
||||
"load_sample_csv": "Ladda exempel-CSV",
|
||||
"n_supported_questions": "{count} stödda frågor",
|
||||
"manage_directories": "Manage directories",
|
||||
"manage_feedback_sources": "Hantera feedbackkällor",
|
||||
"metadata": "Metadata",
|
||||
"metadata_key": "Metadatanyckel",
|
||||
"metadata_read_only_entries": "Skrivskyddade metadatavärden (icke-sträng)",
|
||||
"metadata_value": "Metadatavärde",
|
||||
"missing_feedback_source_title": "Missing feedback source?",
|
||||
"no_feedback_record_directory_available": "Ingen katalog för feedbackposter tilldelad till den här arbetsytan. Skapa eller tilldela en först.",
|
||||
"no_feedback_records": "Inga feedbackposter ännu. Poster visas här när dina connectors börjar skicka data.",
|
||||
"no_source_fields_loaded": "Inga källfält har laddats än",
|
||||
"no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.",
|
||||
"no_surveys_found": "Inga enkäter hittades i denna miljö",
|
||||
"optional": "Valfritt",
|
||||
"or_drag_and_drop": "eller dra och släpp",
|
||||
"question_selected": "<strong>{count}</strong> fråga vald. Varje svar på dessa frågor skapar en ny feedbackpost.",
|
||||
"question_type_not_supported": "Den här frågetypen stöds inte",
|
||||
"questions_selected": "<strong>{count}</strong> frågor valda. Varje svar på dessa frågor skapar en ny feedbackpost.",
|
||||
"records_will_go_to": "Poster kommer att hamna i",
|
||||
"refresh_feedback_records": "Uppdatera feedbackposter",
|
||||
"refreshing_feedback_records": "Uppdaterar feedbackposter...",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "Obligatoriskt",
|
||||
"save_changes": "Spara ändringar",
|
||||
"select_a_survey_to_see_questions": "Välj en enkät för att se dess frågor",
|
||||
"select_a_value": "Välj ett värde...",
|
||||
"select_all": "Välj alla",
|
||||
"select_feedback_record_directory": "Välj en katalog",
|
||||
"select_feedback_record_source_type": "Välj källtyp",
|
||||
"select_questions": "Välj frågor",
|
||||
"select_source_type_description": "Välj vilken typ av feedbackkälla du vill ansluta.",
|
||||
"select_source_type_prompt": "Välj vilken typ av feedbackkälla du vill ansluta:",
|
||||
"select_survey": "Välj enkät",
|
||||
"select_survey_and_questions": "Välj enkät & frågor",
|
||||
"select_survey_questions_description": "Välj vilka enkätfrågor som ska skapa FeedbackRecords.",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "Visar 3 av {count} rader",
|
||||
"source": "källa",
|
||||
"source_connect_csv_description": "Importera feedback från CSV-filer",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "Anslut feedback från dina Formbricks-enkäter",
|
||||
"source_fields": "Källfält",
|
||||
"source_id": "Käll-ID",
|
||||
"source_name": "Källnamn",
|
||||
"source_type": "Källtyp",
|
||||
"source_type_cannot_be_changed": "Källtyp kan inte ändras",
|
||||
"sources": "Källor",
|
||||
"status_active": "Pågående",
|
||||
"status_completed": "Slutförd",
|
||||
"status_draft": "Utkast",
|
||||
"status_error": "Fel",
|
||||
"status_live_sync": "Live sync",
|
||||
"status_paused": "Pausad",
|
||||
"status_ready": "Ready",
|
||||
"submission_id": "Inlämnings-ID",
|
||||
"survey_has_no_questions": "Den här enkäten har inga frågor",
|
||||
"survey_import_line": "{surveyName}: {responseCount} svar × {questionCount} frågor = {total} feedbackposter",
|
||||
"total_feedback_records": "Totalt: {checked} av {total} feedbackposter valda i {surveyCount} enkäter",
|
||||
"topics_and_subtopics": "Ämnen och delämnen",
|
||||
"unify_feedback": "Samla feedback",
|
||||
"update_mapping_description": "Uppdatera mappningskonfigurationen för den här källan.",
|
||||
"updated_at": "Uppdaterad",
|
||||
"upload_csv_data_description": "Ladda upp en CSV-fil för att importera feedbackdata.",
|
||||
"upload_csv_file": "Ladda upp CSV-fil",
|
||||
"user_identifier": "Användare",
|
||||
"value": "Värde"
|
||||
"value": "Värde",
|
||||
"value_boolean": "Värde (booleskt)",
|
||||
"value_date": "Värde (datum)",
|
||||
"value_number": "Värde (antal)",
|
||||
"value_text": "Värde (text)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
+65
-22
@@ -125,6 +125,7 @@
|
||||
"activity": "Etkinlik",
|
||||
"add": "Ekle",
|
||||
"add_action": "Eylem ekle",
|
||||
"add_chart": "Grafik ekle",
|
||||
"add_charts": "Grafik ekle",
|
||||
"add_existing_chart_description": "Bu panoya eklemek için grafikleri ara ve seç.",
|
||||
"add_filter": "Filtre ekle",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "Bu işlemi gerçekleştirmek için yetkiniz yok.",
|
||||
"not_authorized": "Yetkisiz",
|
||||
"not_connected": "Bağlı Değil",
|
||||
"not_set": "Ayarlanmadı",
|
||||
"note": "Not",
|
||||
"notifications": "Bildirimler",
|
||||
"number": "Sayı",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "Anketi Raporla",
|
||||
"request_trial_license": "Deneme lisansı iste",
|
||||
"reset_to_default": "Varsayılana sıfırla",
|
||||
"resize": "Yeniden boyutlandır",
|
||||
"response": "Yanıt",
|
||||
"response_id": "Yanıt ID",
|
||||
"responses": "Yanıtlar",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "Bazı dosyalar yüklenemedi",
|
||||
"something_went_wrong": "Bir şeyler ters gitti",
|
||||
"something_went_wrong_please_try_again": "Bir sorun oluştu. Lütfen tekrar deneyin.",
|
||||
"soon": "Yakında",
|
||||
"sort_by": "Sıralama",
|
||||
"start_free_trial": "Ücretsiz denemeyi başlat",
|
||||
"status": "Durum",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "Lütfen bir kontrol paneli seç",
|
||||
"predefined_measures": "Önceden Tanımlanmış Ölçümler",
|
||||
"preset": "Ön Ayar",
|
||||
"preview_chart": "Grafiği önizleyin",
|
||||
"query_executed_successfully": "Sorgu başarıyla çalıştırıldı",
|
||||
"reset_to_ai_suggestion": "Yapay zeka önerisine sıfırla",
|
||||
"save_and_add_to_dashboard": "Kaydet ve kontrol paneline ekle",
|
||||
"save_chart": "Grafiği Kaydet",
|
||||
"save_chart_dialog_title": "Grafiği Kaydet",
|
||||
"select_data_source": "Bir veri kaynağı seç",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "Başlangıç tarihi",
|
||||
"time_dimension": "Zaman Boyutu",
|
||||
"time_dimension_title": "Zaman tabanlı gruplama ekle",
|
||||
"time_dimension_toggle_description": "Zaman içindeki eğilimleri izle."
|
||||
"time_dimension_toggle_description": "Zaman içindeki eğilimleri izle.",
|
||||
"update_chart": "Grafiği güncelle"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} grafik ekle",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "Pano oluştur",
|
||||
"create_dashboard_description": "Yeni panon için bir isim gir.",
|
||||
"create_failed": "Pano oluşturulamadı",
|
||||
"create_new_chart": "Yeni grafik oluştur",
|
||||
"create_success": "Pano başarıyla oluşturuldu!",
|
||||
"dashboard": "Pano",
|
||||
"dashboard_delete_confirmation": "Bu gösterge panelini silmek istediğinden emin misin? Bu işlem geri alınamaz.",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "Gösterge paneli kopyalanamadı",
|
||||
"duplicate_success": "Gösterge paneli başarıyla kopyalandı!",
|
||||
"failed_to_load_chart_data": "Grafik verileri yüklenemedi",
|
||||
"no_charts_available_description": "Bu gösterge paneline eklenebilecek grafik bulunmuyor. Ya henüz hiç grafik oluşturulmadı ya da mevcut tüm grafikler zaten eklendi. Yeni grafikler oluşturmak için Grafikler sayfasına git.",
|
||||
"no_charts_to_add_message": "Bu gösterge paneline eklenecek grafik yok.",
|
||||
"no_dashboards_found": "Gösterge paneli bulunamadı.",
|
||||
"no_data_message": "Veri Yok. Şu anda görüntülenecek bilgi bulunmuyor. Gösterge panelini oluşturmak için grafik ekle.",
|
||||
"please_enter_name": "Lütfen bir gösterge paneli adı gir"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "Raporlayabileceğiniz Geri Bildirim Kayıtlarınız yok. Verileri sisteme beslemek için Geri Bildirim Kaynaklarını ayarlayın.",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "Geri bildirim kaynaklarını ayarlayın"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "API Anahtarı Ekle",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "Geri Bildirim Dizinleri",
|
||||
"no_access": "Geri bildirim kayıt dizinlerini yönetme izniniz yok.",
|
||||
"no_connectors": "Bu dizine henüz bağlı bağlayıcı yok.",
|
||||
"pause_connectors_confirmation_description": "Bu bağlayıcıları duraklatırsanız yeni kayıtlar eklenmez.",
|
||||
"pause_connectors_confirmation_title": "Bağlı bağlayıcılar duraklatılsın mı?",
|
||||
"select_workspaces_placeholder": "Çalışma alanlarını seç...",
|
||||
"show_archived": "Arşivlenmişleri göster",
|
||||
"title": "Geri Bildirim Kayıt Dizinleri",
|
||||
"unarchive": "Arşivden çıkar"
|
||||
"unarchive": "Arşivden çıkar",
|
||||
"unarchive_workspace_conflict": "Atanmış çalışma alanlarından biri veya daha fazlası arşivlendiği için bu dizin arşivden çıkarılamaz.",
|
||||
"workspace_access": "Çalışma alanı erişimi"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Veri zenginleştirme ve analiz (Yapay Zeka)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "Bu çalışma alanına hangi takımların erişebildiğini görün."
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "Geri bildirim kaydı ekle",
|
||||
"add_feedback_record_description": "Manuel olarak bir geri bildirim kaydı oluşturun.",
|
||||
"add_feedback_source": "Geri Bildirim Kaynağı Ekle",
|
||||
"add_source": "Kaynak ekle",
|
||||
"allowed_values": "İzin verilen değerler: {values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_manage_api_keys": "Manage API keys",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "Otomatik olarak oluşturuldu",
|
||||
"change_file": "Dosyayı değiştir",
|
||||
"click_load_sample_csv": "Sütunları görmek için 'Örnek CSV yükle'ye tıkla",
|
||||
"click_to_upload": "Yüklemek için tıkla",
|
||||
"collected_at": "Toplandığı Tarih",
|
||||
"configure_import": "İçe aktarmayı yapılandır",
|
||||
"configure_mapping": "Eşleştirmeyi yapılandır",
|
||||
"connection": "Bağlantı",
|
||||
"connector_created_successfully": "Bağlayıcı başarıyla oluşturuldu",
|
||||
"connector_deleted_successfully": "Bağlayıcı başarıyla silindi",
|
||||
"connector_duplicated_successfully": "Bağlayıcı başarıyla kopyalandı",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "Verileri iki kez içe aktarmak yinelenen kayıtlar oluşturacaktır.",
|
||||
"csv_inconsistent_columns": "Satır {row} tutarsız sütunlara sahip. Tüm satırlar aynı başlıklara sahip olmalıdır.",
|
||||
"csv_max_records": "Maksimum {max} kayda izin verilir.",
|
||||
"custom_source_type": "Özel kaynak türü",
|
||||
"custom_source_type_placeholder": "Özel kaynak türünü girin",
|
||||
"default_connector_name_csv": "CSV İçe Aktarma",
|
||||
"default_connector_name_formbricks": "Formbricks Anket Bağlantısı",
|
||||
"deselect_all": "Tümünün seçimini kaldır",
|
||||
"discard_feedback_record_changes_description": "Bu çekmeceyi kapatırsanız değişiklikleriniz kaybolacak.",
|
||||
"discard_feedback_record_changes_title": "Kaydedilmemiş değişiklikler silinsin mi?",
|
||||
"drop_a_field_here": "Buraya bir alan bırakın",
|
||||
"drop_field_or": "Alan bırakın veya",
|
||||
"edit_csv_mapping": "CSV eşlemesini düzenle",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi",
|
||||
"feedback_date": "Geçerli tarih",
|
||||
"feedback_record_created_successfully": "Geri bildirim kaydı başarıyla oluşturuldu",
|
||||
"feedback_record_details": "Geri bildirim kaydı ayrıntıları",
|
||||
"feedback_record_details_description": "Geri bildirim kayıt alanlarını inceleyin ve güncelleyin.",
|
||||
"feedback_record_directory": "Geri Bildirim Kayıt Dizini",
|
||||
"feedback_record_fields": "Geri Bildirim Kayıt Alanları",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"feedback_record_updated_successfully": "Geri bildirim kaydı başarıyla güncellendi",
|
||||
"feedback_record_value_required": "Seçilen alan türü için bir değer gerekli",
|
||||
"feedback_records": "Geri Bildirim Kayıtları",
|
||||
"feedback_records_refreshed": "Geri bildirim kayıtları yenilendi",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
|
||||
"field_group_id": "Alan Grubu Kimliği",
|
||||
"field_group_label": "Alan Grubu Etiketi",
|
||||
"field_id": "Alan Kimliği",
|
||||
"field_label": "Alan Etiketi",
|
||||
"field_type": "Alan Türü",
|
||||
"formbricks_surveys": "Formbricks Anketleri",
|
||||
"frd_cannot_be_changed": "Geri bildirim dizini oluşturulduktan sonra değiştirilemez.",
|
||||
"go_to_feedback_record_directories": "Dizin ayarlarına git",
|
||||
"historical_import_complete": "İçe aktarma tamamlandı: {successes} başarılı, {failures} başarısız, {skipped} atlandı (veri yok)",
|
||||
"import_csv_data": "Geri bildirimi içe aktar",
|
||||
"import_feedback": "Geri bildirimi içe aktar",
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "{count} satır içe aktar",
|
||||
"import_via_source_name": "\"{sourceName}\" yoluyla içe aktar",
|
||||
"importing_data": "Veri içe aktarılıyor...",
|
||||
"importing_historical_data": "Geçmiş veriler içe aktarılıyor...",
|
||||
"invalid_enum_values": "{field} alanına eşlenen sütunda geçersiz değerler",
|
||||
"invalid_values_found": "Bulunan: {values} (satırlar: {rows}) {extra}",
|
||||
"load_sample_csv": "Örnek CSV yükle",
|
||||
"n_supported_questions": "{count} desteklenen soru",
|
||||
"manage_directories": "Manage directories",
|
||||
"manage_feedback_sources": "Geri bildirim kaynaklarını yönetin",
|
||||
"metadata": "Meta veriler",
|
||||
"metadata_key": "Meta veri anahtarı",
|
||||
"metadata_read_only_entries": "Salt okunur meta veri değerleri (dize dışı)",
|
||||
"metadata_value": "Meta veri değeri",
|
||||
"missing_feedback_source_title": "Missing feedback source?",
|
||||
"no_feedback_record_directory_available": "Bu çalışma alanına atanmış bir geri bildirim kayıt dizini yok. Önce bir tane oluştur veya ata.",
|
||||
"no_feedback_records": "Henüz geri bildirim kaydı yok. Bağlayıcıların veri göndermeye başlamasıyla kayıtlar burada görünecek.",
|
||||
"no_source_fields_loaded": "Henüz kaynak alan yüklenmedi",
|
||||
"no_sources_connected": "Henüz bağlı kaynak yok. Başlamak için bir kaynak ekle.",
|
||||
"no_surveys_found": "Bu ortamda anket bulunamadı",
|
||||
"optional": "İsteğe bağlı",
|
||||
"or_drag_and_drop": "veya sürükle bırak",
|
||||
"question_selected": "<strong>{count}</strong> soru seçildi. Bu soruya verilen her yanıt yeni bir Geri Bildirim Kaydı oluşturacak.",
|
||||
"question_type_not_supported": "Bu soru türü desteklenmiyor",
|
||||
"questions_selected": "<strong>{count}</strong> soru seçildi. Bu sorulara verilen her yanıt yeni bir Geri Bildirim Kaydı oluşturacak.",
|
||||
"records_will_go_to": "Kayıtlar şuraya gidecek",
|
||||
"refresh_feedback_records": "Geri bildirim kayıtlarını yenile",
|
||||
"refreshing_feedback_records": "Geri bildirim kayıtları yenileniyor...",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "Gerekli",
|
||||
"save_changes": "Değişiklikleri kaydet",
|
||||
"select_a_survey_to_see_questions": "Sorularını görmek için bir anket seç",
|
||||
"select_a_value": "Bir değer seç...",
|
||||
"select_all": "Tümünü seç",
|
||||
"select_feedback_record_directory": "Bir dizin seç",
|
||||
"select_feedback_record_source_type": "Kaynak türünü seçin",
|
||||
"select_questions": "Soru seç",
|
||||
"select_source_type_description": "Bağlamak istediğin geri bildirim kaynağının türünü seç.",
|
||||
"select_source_type_prompt": "Bağlamak istediğiniz geri bildirim kaynağının türünü seçin:",
|
||||
"select_survey": "Anket Seç",
|
||||
"select_survey_and_questions": "Anket ve Soruları Seç",
|
||||
"select_survey_questions_description": "Hangi anket sorularının GeriBildirimKayıtları oluşturması gerektiğini seçin.",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "{count} satırdan 3'ü gösteriliyor",
|
||||
"source": "kaynak",
|
||||
"source_connect_csv_description": "CSV dosyalarından geri bildirim içe aktar",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "Formbricks anketlerinizdeki geri bildirimleri bağlayın",
|
||||
"source_fields": "Kaynak Alanları",
|
||||
"source_id": "Kaynak kimliği",
|
||||
"source_name": "Kaynak Adı",
|
||||
"source_type": "Kaynak Türü",
|
||||
"source_type_cannot_be_changed": "Kaynak türü değiştirilemez",
|
||||
"sources": "Kaynaklar",
|
||||
"status_active": "Devam Ediyor",
|
||||
"status_completed": "Tamamlandı",
|
||||
"status_draft": "Taslak",
|
||||
"status_error": "Hata",
|
||||
"status_live_sync": "Live sync",
|
||||
"status_paused": "Duraklatıldı",
|
||||
"status_ready": "Ready",
|
||||
"submission_id": "Gönderim Kimliği",
|
||||
"survey_has_no_questions": "Bu ankette soru yok",
|
||||
"survey_import_line": "{surveyName}: {responseCount} yanıt × {questionCount} soru = {total} Geri Bildirim Kaydı",
|
||||
"total_feedback_records": "Toplam: {surveyCount} anket genelinde {total} Geri Bildirim Kaydından {checked} tanesi seçildi",
|
||||
"topics_and_subtopics": "Konular ve alt konular",
|
||||
"unify_feedback": "Geri Bildirimleri Birleştir",
|
||||
"update_mapping_description": "Bu kaynak için eşleme yapılandırmasını güncelle.",
|
||||
"updated_at": "Güncellenme tarihi",
|
||||
"upload_csv_data_description": "Geri bildirim verilerini içe aktarmak için bir CSV dosyası yükle.",
|
||||
"upload_csv_file": "CSV Dosyası Yükle",
|
||||
"user_identifier": "Kullanıcı",
|
||||
"value": "Değer"
|
||||
"value": "Değer",
|
||||
"value_boolean": "Değer (Boolean)",
|
||||
"value_date": "Değer (Tarih)",
|
||||
"value_number": "Değer (Sayı)",
|
||||
"value_text": "Değer (Metin)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"activity": "活动",
|
||||
"add": "添加",
|
||||
"add_action": "添加 操作",
|
||||
"add_chart": "添加图表",
|
||||
"add_charts": "添加图表",
|
||||
"add_existing_chart_description": "搜索并选择要添加到此仪表板的图表。",
|
||||
"add_filter": "添加 过滤器",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "您 未 认证 以 执行 该 操作。",
|
||||
"not_authorized": "未授权",
|
||||
"not_connected": "未连接",
|
||||
"not_set": "未设置",
|
||||
"note": "注释",
|
||||
"notifications": "通知",
|
||||
"number": "数字",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "报告调查",
|
||||
"request_trial_license": "申请试用许可证",
|
||||
"reset_to_default": "重置为 默认",
|
||||
"resize": "调整大小",
|
||||
"response": "响应",
|
||||
"response_id": "响应 ID",
|
||||
"responses": "反馈",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "某些文件上传失败",
|
||||
"something_went_wrong": "出错了",
|
||||
"something_went_wrong_please_try_again": "出错了 。请 尝试 再次 操作 。",
|
||||
"soon": "即将推出",
|
||||
"sort_by": "排序 依据",
|
||||
"start_free_trial": "开始免费试用",
|
||||
"status": "状态",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "请选择一个 Dashboard",
|
||||
"predefined_measures": "预设度量",
|
||||
"preset": "预设",
|
||||
"preview_chart": "预览图表",
|
||||
"query_executed_successfully": "查询执行成功",
|
||||
"reset_to_ai_suggestion": "重置为 AI 建议",
|
||||
"save_and_add_to_dashboard": "保存并添加到仪表板",
|
||||
"save_chart": "保存图表",
|
||||
"save_chart_dialog_title": "保存图表",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "开始日期",
|
||||
"time_dimension": "时间维度",
|
||||
"time_dimension_title": "添加基于时间的分组",
|
||||
"time_dimension_toggle_description": "监控随时间变化的趋势。"
|
||||
"time_dimension_toggle_description": "监控随时间变化的趋势。",
|
||||
"update_chart": "更新图表"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "添加 {count} 个图表",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "创建仪表板",
|
||||
"create_dashboard_description": "请输入新 Dashboard 的名称。",
|
||||
"create_failed": "创建 Dashboard 失败",
|
||||
"create_new_chart": "创建新图表",
|
||||
"create_success": "Dashboard 创建成功!",
|
||||
"dashboard": "仪表板",
|
||||
"dashboard_delete_confirmation": "你确定要删除此仪表板吗?此操作无法撤销。",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "复制 Dashboard 失败",
|
||||
"duplicate_success": "Dashboard 复制成功!",
|
||||
"failed_to_load_chart_data": "加载图表数据失败",
|
||||
"no_charts_available_description": "没有可以添加到此仪表板的图表。要么还没有创建任何图表,要么所有现有图表都已添加。请前往图表页面创建新图表。",
|
||||
"no_charts_to_add_message": "没有可添加到此仪表板的图表。",
|
||||
"no_dashboards_found": "未找到 Dashboard。",
|
||||
"no_data_message": "暂无数据。当前没有可显示的信息。请添加图表来构建你的仪表板。",
|
||||
"please_enter_name": "请输入 Dashboard 名称"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "您没有可供报告的反馈记录。设置反馈源以将数据输入系统。",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "设置反馈源"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "添加 API 密钥",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "反馈目录",
|
||||
"no_access": "你没有管理反馈记录目录的权限。",
|
||||
"no_connectors": "此目录尚未链接任何连接器。",
|
||||
"pause_connectors_confirmation_description": "暂停这些连接器后,将不会再添加新记录。",
|
||||
"pause_connectors_confirmation_title": "暂停已关联的连接器?",
|
||||
"select_workspaces_placeholder": "选择工作区...",
|
||||
"show_archived": "显示已归档",
|
||||
"title": "反馈记录目录",
|
||||
"unarchive": "取消归档"
|
||||
"unarchive": "取消归档",
|
||||
"unarchive_workspace_conflict": "无法取消归档该目录,因为一个或多个已分配工作区已归档。",
|
||||
"workspace_access": "工作区访问权限"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "数据增强与分析(AI)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "查看哪些团队可以访问此工作区。"
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "添加反馈记录",
|
||||
"add_feedback_record_description": "手动创建反馈记录。",
|
||||
"add_feedback_source": "添加反馈来源",
|
||||
"add_source": "添加来源",
|
||||
"allowed_values": "允许的值:{values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_manage_api_keys": "Manage API keys",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "自动生成",
|
||||
"change_file": "更换文件",
|
||||
"click_load_sample_csv": "点击“加载示例 CSV”查看列",
|
||||
"click_to_upload": "点击上传",
|
||||
"collected_at": "收集时间",
|
||||
"configure_import": "配置导入",
|
||||
"configure_mapping": "配置映射",
|
||||
"connection": "连接",
|
||||
"connector_created_successfully": "连接器创建成功",
|
||||
"connector_deleted_successfully": "连接器删除成功",
|
||||
"connector_duplicated_successfully": "连接器复制成功",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "重复导入数据会产生重复记录。",
|
||||
"csv_inconsistent_columns": "第 {row} 行的列数不一致。所有行必须有相同的表头。",
|
||||
"csv_max_records": "最多允许 {max} 条记录。",
|
||||
"custom_source_type": "自定义源类型",
|
||||
"custom_source_type_placeholder": "输入自定义来源类型",
|
||||
"default_connector_name_csv": "CSV 导入",
|
||||
"default_connector_name_formbricks": "Formbricks 调查连接",
|
||||
"deselect_all": "取消全选",
|
||||
"discard_feedback_record_changes_description": "如果关闭此抽屉,您的更改将会丢失。",
|
||||
"discard_feedback_record_changes_title": "放弃未保存的更改?",
|
||||
"drop_a_field_here": "将字段拖到这里",
|
||||
"drop_field_or": "拖放字段或",
|
||||
"edit_csv_mapping": "编辑 CSV 映射",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "枚举",
|
||||
"failed_to_load_feedback_records": "加载反馈记录失败",
|
||||
"feedback_date": "当前日期",
|
||||
"feedback_record_created_successfully": "反馈记录创建成功",
|
||||
"feedback_record_details": "反馈记录详情",
|
||||
"feedback_record_details_description": "查看并更新反馈记录字段。",
|
||||
"feedback_record_directory": "反馈记录目录",
|
||||
"feedback_record_fields": "反馈记录字段",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"feedback_record_updated_successfully": "反馈记录更新成功",
|
||||
"feedback_record_value_required": "所选字段类型需要一个值",
|
||||
"feedback_records": "反馈记录",
|
||||
"feedback_records_refreshed": "反馈记录已刷新",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
|
||||
"field_group_id": "字段组 ID",
|
||||
"field_group_label": "字段组标签",
|
||||
"field_id": "字段ID",
|
||||
"field_label": "字段标签",
|
||||
"field_type": "字段类型",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"frd_cannot_be_changed": "反馈目录创建后无法更改。",
|
||||
"go_to_feedback_record_directories": "前往目录设置",
|
||||
"historical_import_complete": "导入完成:{successes} 个成功,{failures} 个失败,{skipped} 个跳过(无数据)",
|
||||
"import_csv_data": "导入反馈",
|
||||
"import_feedback": "导入反馈",
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "导入{count}行数据",
|
||||
"import_via_source_name": "通过“{sourceName}”导入",
|
||||
"importing_data": "正在导入数据…",
|
||||
"importing_historical_data": "正在导入历史数据…",
|
||||
"invalid_enum_values": "映射到 {field} 的列中存在无效值",
|
||||
"invalid_values_found": "发现:{values}(行:{rows}){extra}",
|
||||
"load_sample_csv": "加载示例 CSV",
|
||||
"n_supported_questions": "{count} 个支持的问题",
|
||||
"manage_directories": "Manage directories",
|
||||
"manage_feedback_sources": "管理反馈来源",
|
||||
"metadata": "元数据",
|
||||
"metadata_key": "元数据键",
|
||||
"metadata_read_only_entries": "只读元数据值(非字符串)",
|
||||
"metadata_value": "元数据值",
|
||||
"missing_feedback_source_title": "Missing feedback source?",
|
||||
"no_feedback_record_directory_available": "此工作区未分配反馈记录目录。请先创建或分配一个。",
|
||||
"no_feedback_records": "暂无反馈记录。当你的连接器开始发送数据后,记录会显示在这里。",
|
||||
"no_source_fields_loaded": "尚未加载源字段",
|
||||
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
|
||||
"no_surveys_found": "此环境下未找到调查",
|
||||
"optional": "可选",
|
||||
"or_drag_and_drop": "或拖放",
|
||||
"question_selected": "<strong>{count}</strong> 个问题已选。每个问题的回答都会创建一条新的反馈记录。",
|
||||
"question_type_not_supported": "不支持此问题类型",
|
||||
"questions_selected": "<strong>{count}</strong> 个问题已选。每个问题的回答都会创建一条新的反馈记录。",
|
||||
"records_will_go_to": "记录将发送至",
|
||||
"refresh_feedback_records": "刷新反馈记录",
|
||||
"refreshing_feedback_records": "正在刷新反馈记录…",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "必填",
|
||||
"save_changes": "保存更改",
|
||||
"select_a_survey_to_see_questions": "请选择一个调查以查看其问题",
|
||||
"select_a_value": "选择一个值...",
|
||||
"select_all": "全选",
|
||||
"select_feedback_record_directory": "选择目录",
|
||||
"select_feedback_record_source_type": "选择来源类型",
|
||||
"select_questions": "选择问题",
|
||||
"select_source_type_description": "请选择你想要连接的反馈来源类型。",
|
||||
"select_source_type_prompt": "请选择你想要连接的反馈来源类型:",
|
||||
"select_survey": "选择调查",
|
||||
"select_survey_and_questions": "选择调查和问题",
|
||||
"select_survey_questions_description": "选择哪些调查问题会创建反馈记录。",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "显示 {count} 行中的 3 行",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "从 CSV 文件导入反馈",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "连接来自你 Formbricks 调查的反馈",
|
||||
"source_fields": "来源字段",
|
||||
"source_id": "源ID",
|
||||
"source_name": "来源名称",
|
||||
"source_type": "来源类型",
|
||||
"source_type_cannot_be_changed": "来源类型无法更改",
|
||||
"sources": "来源",
|
||||
"status_active": "进行中",
|
||||
"status_completed": "已完成",
|
||||
"status_draft": "草稿",
|
||||
"status_error": "错误",
|
||||
"status_live_sync": "Live sync",
|
||||
"status_paused": "已暂停",
|
||||
"status_ready": "Ready",
|
||||
"submission_id": "提交ID",
|
||||
"survey_has_no_questions": "该调查没有任何问题",
|
||||
"survey_import_line": "{surveyName}:{responseCount} 份答卷 × {questionCount} 个问题 = {total} 条反馈记录",
|
||||
"total_feedback_records": "总计:{surveyCount} 个调查中已选 {checked} / {total} 条反馈记录",
|
||||
"topics_and_subtopics": "主题和子主题",
|
||||
"unify_feedback": "统一反馈",
|
||||
"update_mapping_description": "更新此来源的映射配置。",
|
||||
"updated_at": "更新于",
|
||||
"upload_csv_data_description": "上传 CSV 文件以导入反馈数据。",
|
||||
"upload_csv_file": "上传 CSV 文件",
|
||||
"user_identifier": "用户",
|
||||
"value": "值"
|
||||
"value": "值",
|
||||
"value_boolean": "值(布尔值)",
|
||||
"value_date": "值(日期)",
|
||||
"value_number": "值(数量)",
|
||||
"value_text": "值(文本)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "客户努力评分",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"activity": "活動",
|
||||
"add": "新增",
|
||||
"add_action": "新增操作",
|
||||
"add_chart": "新增圖表",
|
||||
"add_charts": "新增圖表",
|
||||
"add_existing_chart_description": "搜尋並選擇要新增至此儀表板的圖表。",
|
||||
"add_filter": "新增篩選器",
|
||||
@@ -331,6 +332,7 @@
|
||||
"not_authenticated": "您未經授權執行此操作。",
|
||||
"not_authorized": "未授權",
|
||||
"not_connected": "未連線",
|
||||
"not_set": "未設定",
|
||||
"note": "筆記",
|
||||
"notifications": "通知",
|
||||
"number": "數字",
|
||||
@@ -390,6 +392,7 @@
|
||||
"report_survey": "報告問卷",
|
||||
"request_trial_license": "請求試用授權",
|
||||
"reset_to_default": "重設為預設值",
|
||||
"resize": "調整大小",
|
||||
"response": "回應",
|
||||
"response_id": "回應 ID",
|
||||
"responses": "回應",
|
||||
@@ -431,6 +434,7 @@
|
||||
"some_files_failed_to_upload": "部分檔案上傳失敗",
|
||||
"something_went_wrong": "發生錯誤",
|
||||
"something_went_wrong_please_try_again": "發生錯誤。請再試一次。",
|
||||
"soon": "即將推出",
|
||||
"sort_by": "排序方式",
|
||||
"start_free_trial": "開始免費試用",
|
||||
"status": "狀態",
|
||||
@@ -1781,8 +1785,10 @@
|
||||
"please_select_dashboard": "請選擇一個儀表板",
|
||||
"predefined_measures": "預設指標",
|
||||
"preset": "預設",
|
||||
"preview_chart": "預覽圖表",
|
||||
"query_executed_successfully": "查詢執行成功",
|
||||
"reset_to_ai_suggestion": "重設為 AI 建議",
|
||||
"save_and_add_to_dashboard": "儲存並新增到儀表板",
|
||||
"save_chart": "儲存圖表",
|
||||
"save_chart_dialog_title": "儲存圖表",
|
||||
"select_data_source": "Select a data source",
|
||||
@@ -1795,7 +1801,8 @@
|
||||
"start_date": "開始日期",
|
||||
"time_dimension": "時間維度",
|
||||
"time_dimension_title": "新增基於時間的分組",
|
||||
"time_dimension_toggle_description": "監控隨時間變化的趨勢。"
|
||||
"time_dimension_toggle_description": "監控隨時間變化的趨勢。",
|
||||
"update_chart": "更新圖表"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "新增 {count} 個圖表",
|
||||
@@ -1806,6 +1813,7 @@
|
||||
"create_dashboard": "建立儀表板",
|
||||
"create_dashboard_description": "請輸入新儀表板的名稱。",
|
||||
"create_failed": "建立儀表板失敗",
|
||||
"create_new_chart": "建立新圖表",
|
||||
"create_success": "儀表板建立成功!",
|
||||
"dashboard": "儀表板",
|
||||
"dashboard_delete_confirmation": "確定要刪除此儀表板嗎?此操作無法復原。",
|
||||
@@ -1820,12 +1828,14 @@
|
||||
"duplicate_failed": "複製儀表板失敗",
|
||||
"duplicate_success": "儀表板複製成功!",
|
||||
"failed_to_load_chart_data": "載入圖表資料失敗",
|
||||
"no_charts_available_description": "目前沒有可以新增到此儀表板的圖表。可能是尚未建立任何圖表,或所有現有圖表都已新增。請前往圖表頁面建立新的圖表。",
|
||||
"no_charts_to_add_message": "沒有可新增到此儀表板的圖表。",
|
||||
"no_dashboards_found": "找不到儀表板。",
|
||||
"no_data_message": "無資料。目前沒有可顯示的資訊。請新增圖表來建立你的儀表板。",
|
||||
"please_enter_name": "請輸入儀表板名稱"
|
||||
}
|
||||
},
|
||||
"manage_feedback_sources": "Manage feedback sources",
|
||||
"no_feedback_records_message": "您沒有可供報告的回饋記錄。設定回饋來源以將資料輸入系統。",
|
||||
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
|
||||
"setup_feedback_source": "設定反饋源"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "新增 API 金鑰",
|
||||
@@ -2548,10 +2558,14 @@
|
||||
"nav_label": "意見回饋目錄",
|
||||
"no_access": "您沒有權限管理意見回饋記錄目錄。",
|
||||
"no_connectors": "此目錄尚未連結任何連接器。",
|
||||
"pause_connectors_confirmation_description": "暫停這些連接器後,將不會再新增新紀錄。",
|
||||
"pause_connectors_confirmation_title": "暫停已連結的連接器?",
|
||||
"select_workspaces_placeholder": "選擇工作區...",
|
||||
"show_archived": "顯示已封存",
|
||||
"title": "意見回饋記錄目錄",
|
||||
"unarchive": "取消封存"
|
||||
"unarchive": "取消封存",
|
||||
"unarchive_workspace_conflict": "無法取消封存此目錄,因為一個或多個已指派工作區已封存。",
|
||||
"workspace_access": "工作區存取權限"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "資料增強與分析(AI)",
|
||||
@@ -3613,16 +3627,21 @@
|
||||
"team_settings_description": "查看哪些團隊可以存取此工作區。"
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_record": "新增回饋記錄",
|
||||
"add_feedback_record_description": "手動建立回饋記錄。",
|
||||
"add_feedback_source": "新增回饋來源",
|
||||
"add_source": "新增來源",
|
||||
"allowed_values": "允許的值:{values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_manage_api_keys": "Manage API keys",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "自動生成",
|
||||
"change_file": "更換檔案",
|
||||
"click_load_sample_csv": "點擊「載入範例 CSV」以查看欄位",
|
||||
"click_to_upload": "點擊以上傳",
|
||||
"collected_at": "收集時間",
|
||||
"configure_import": "設定匯入",
|
||||
"configure_mapping": "設定對應關係",
|
||||
"connection": "連線",
|
||||
"connector_created_successfully": "連接器建立成功",
|
||||
"connector_deleted_successfully": "連接器刪除成功",
|
||||
"connector_duplicated_successfully": "連接器複製成功",
|
||||
@@ -3641,9 +3660,12 @@
|
||||
"csv_import_duplicate_warning": "匯入已經匯入過的資料,可能會產生重複紀錄。",
|
||||
"csv_inconsistent_columns": "第 {row} 列的欄位數不一致。所有列必須有相同的標題。",
|
||||
"csv_max_records": "最多允許 {max} 筆紀錄。",
|
||||
"custom_source_type": "自訂來源類型",
|
||||
"custom_source_type_placeholder": "輸入自訂來源類型",
|
||||
"default_connector_name_csv": "CSV 匯入",
|
||||
"default_connector_name_formbricks": "Formbricks 問卷連線",
|
||||
"deselect_all": "取消全選",
|
||||
"discard_feedback_record_changes_description": "如果關閉此抽屜,您的變更將會遺失。",
|
||||
"discard_feedback_record_changes_title": "放棄未儲存的變更?",
|
||||
"drop_a_field_here": "請將欄位拖曳到這裡",
|
||||
"drop_field_or": "拖曳欄位或",
|
||||
"edit_csv_mapping": "編輯 CSV 對應",
|
||||
@@ -3653,47 +3675,64 @@
|
||||
"enum": "enum",
|
||||
"failed_to_load_feedback_records": "載入回饋紀錄失敗",
|
||||
"feedback_date": "目前日期",
|
||||
"feedback_record_created_successfully": "回饋記錄創建成功",
|
||||
"feedback_record_details": "反饋記錄詳情",
|
||||
"feedback_record_details_description": "查看並更新回饋記錄欄位。",
|
||||
"feedback_record_directory": "意見回饋記錄目錄",
|
||||
"feedback_record_fields": "回饋紀錄欄位",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"feedback_record_updated_successfully": "回饋記錄更新成功",
|
||||
"feedback_record_value_required": "所選欄位類型需要一個值",
|
||||
"feedback_records": "回饋紀錄",
|
||||
"feedback_records_refreshed": "回饋紀錄已更新",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
|
||||
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
|
||||
"field_group_id": "字段組 ID",
|
||||
"field_group_label": "字段組標籤",
|
||||
"field_id": "欄位ID",
|
||||
"field_label": "欄位標籤",
|
||||
"field_type": "欄位類型",
|
||||
"formbricks_surveys": "Formbricks 問卷",
|
||||
"frd_cannot_be_changed": "意見回饋目錄在建立後無法變更。",
|
||||
"go_to_feedback_record_directories": "前往目錄設定",
|
||||
"historical_import_complete": "匯入完成:{successes} 筆成功,{failures} 筆失敗,{skipped} 筆略過(無資料)",
|
||||
"import_csv_data": "匯入 CSV 資料",
|
||||
"import_feedback": "匯入回饋",
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "匯入 {count} 筆資料",
|
||||
"import_via_source_name": "透過“{sourceName}”導入",
|
||||
"importing_data": "正在匯入資料…",
|
||||
"importing_historical_data": "正在匯入歷史資料…",
|
||||
"invalid_enum_values": "對應到 {field} 欄位的值無效",
|
||||
"invalid_values_found": "發現:{values}(列:{rows}){extra}",
|
||||
"load_sample_csv": "載入範例 CSV",
|
||||
"n_supported_questions": "{count} 個支援的問題",
|
||||
"manage_directories": "Manage directories",
|
||||
"manage_feedback_sources": "管理回饋來源",
|
||||
"metadata": "元數據",
|
||||
"metadata_key": "元資料鍵",
|
||||
"metadata_read_only_entries": "唯讀元資料值(非字串)",
|
||||
"metadata_value": "元資料值",
|
||||
"missing_feedback_source_title": "Missing feedback source?",
|
||||
"no_feedback_record_directory_available": "此工作區尚未指派意見回饋記錄目錄。請先建立或指派一個目錄。",
|
||||
"no_feedback_records": "目前尚無回饋紀錄。當你的連接器開始傳送資料時,紀錄會顯示在這裡。",
|
||||
"no_source_fields_loaded": "尚未載入來源欄位",
|
||||
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
|
||||
"no_surveys_found": "此環境中找不到問卷",
|
||||
"optional": "選填",
|
||||
"or_drag_and_drop": "或拖曳檔案",
|
||||
"question_selected": "已選擇 <strong>{count}</strong> 題。每份這些題目的回應都會建立一筆新的意見紀錄。",
|
||||
"question_type_not_supported": "不支援此題型",
|
||||
"questions_selected": "已選擇 <strong>{count}</strong> 題。每份這些題目的回應都會建立一筆新的意見紀錄。",
|
||||
"records_will_go_to": "記錄將傳送至",
|
||||
"refresh_feedback_records": "重新整理回饋紀錄",
|
||||
"refreshing_feedback_records": "正在更新回饋紀錄…",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "必填",
|
||||
"save_changes": "儲存變更",
|
||||
"select_a_survey_to_see_questions": "請選擇問卷以查看其問題",
|
||||
"select_a_value": "請選擇一個值...",
|
||||
"select_all": "全選",
|
||||
"select_feedback_record_directory": "選擇目錄",
|
||||
"select_feedback_record_source_type": "選擇來源類型",
|
||||
"select_questions": "選擇問題",
|
||||
"select_source_type_description": "請選擇你想要連接的回饋來源類型。",
|
||||
"select_source_type_prompt": "請選擇你想要連接的回饋來源類型:",
|
||||
"select_survey": "選擇問卷",
|
||||
"select_survey_and_questions": "選擇問卷與問題",
|
||||
"select_survey_questions_description": "請選擇哪些問卷問題要建立 FeedbackRecords。",
|
||||
@@ -3703,27 +3742,31 @@
|
||||
"showing_rows": "顯示 {count} 筆資料中的 3 筆",
|
||||
"source": "來源",
|
||||
"source_connect_csv_description": "從 CSV 檔案匯入回饋",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "連接來自你 Formbricks 問卷的回饋",
|
||||
"source_fields": "來源欄位",
|
||||
"source_id": "來源ID",
|
||||
"source_name": "來源名稱",
|
||||
"source_type": "來源類型",
|
||||
"source_type_cannot_be_changed": "來源類型無法變更",
|
||||
"sources": "來源",
|
||||
"status_active": "進行中",
|
||||
"status_completed": "已完成",
|
||||
"status_draft": "草稿",
|
||||
"status_error": "錯誤",
|
||||
"status_live_sync": "Live sync",
|
||||
"status_paused": "已暫停",
|
||||
"status_ready": "Ready",
|
||||
"submission_id": "提交ID",
|
||||
"survey_has_no_questions": "此問卷沒有任何題目",
|
||||
"survey_import_line": "{surveyName}:{responseCount} 份回應 × {questionCount} 題 = {total} 筆意見紀錄",
|
||||
"total_feedback_records": "總計:{surveyCount} 份問卷中已選擇 {checked} / {total} 筆意見紀錄",
|
||||
"topics_and_subtopics": "主題與子主題",
|
||||
"unify_feedback": "整合回饋",
|
||||
"update_mapping_description": "更新此來源的對應設定。",
|
||||
"updated_at": "更新時間",
|
||||
"upload_csv_data_description": "上傳 CSV 檔案以匯入回饋資料。",
|
||||
"upload_csv_file": "上傳 CSV 檔案",
|
||||
"user_identifier": "使用者",
|
||||
"value": "值"
|
||||
"value": "值",
|
||||
"value_boolean": "值(布林值)",
|
||||
"value_date": "值(日期)",
|
||||
"value_number": "值(數量)",
|
||||
"value_text": "值(文字)"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
@@ -31,6 +31,7 @@ interface AddToDashboardDialogProps {
|
||||
onDashboardSelect: (id: string) => void;
|
||||
onConfirm: () => void;
|
||||
isSaving: boolean;
|
||||
showChartNameField?: boolean;
|
||||
}
|
||||
|
||||
export function AddToDashboardDialog({
|
||||
@@ -43,6 +44,7 @@ export function AddToDashboardDialog({
|
||||
onDashboardSelect,
|
||||
onConfirm,
|
||||
isSaving,
|
||||
showChartNameField = true,
|
||||
}: Readonly<AddToDashboardDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -57,17 +59,19 @@ export function AddToDashboardDialog({
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="chart-name"
|
||||
className="mt-2"
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
{showChartNameField && (
|
||||
<div>
|
||||
<Label htmlFor="chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="chart-name"
|
||||
className="mt-2"
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="dashboard-select">{t("workspace.analysis.charts.dashboard")}</Label>
|
||||
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
|
||||
@@ -103,7 +107,10 @@ export function AddToDashboardDialog({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} loading={isSaving} disabled={!selectedDashboardId || !chartName.trim()}>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
loading={isSaving}
|
||||
disabled={!selectedDashboardId || (showChartNameField && !chartName.trim())}>
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -31,6 +31,7 @@ interface AdvancedChartBuilderProps {
|
||||
hidePreview?: boolean;
|
||||
onChartGenerated?: (data: AnalyticsResponse) => void;
|
||||
feedbackRecordDirectoryId: string | null;
|
||||
runQueryCtaLabel?: string;
|
||||
}
|
||||
|
||||
const ACTION = {
|
||||
@@ -84,6 +85,7 @@ export function AdvancedChartBuilder({
|
||||
hidePreview = false,
|
||||
onChartGenerated,
|
||||
feedbackRecordDirectoryId,
|
||||
runQueryCtaLabel,
|
||||
}: Readonly<AdvancedChartBuilderProps>) {
|
||||
const { t } = useTranslation();
|
||||
const parsedInitial = initialQuery ? parseQueryToState(initialQuery) : null;
|
||||
@@ -151,11 +153,7 @@ export function AdvancedChartBuilder({
|
||||
return (
|
||||
<div className={hidePreview ? "space-y-2" : "grid gap-4 lg:grid-cols-2"}>
|
||||
<div className="mx-1 space-y-2">
|
||||
{!hidePreview && (
|
||||
<>
|
||||
<ChartTypeSelector selectedChartType={chartType} onChartTypeSelect={() => {}} />
|
||||
</>
|
||||
)}
|
||||
{!hidePreview && <ChartTypeSelector selectedChartType={chartType} onChartTypeSelect={() => {}} />}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col gap-3 overflow-hidden rounded-lg border bg-slate-50 p-4">
|
||||
<MeasuresPanel
|
||||
@@ -249,7 +247,11 @@ export function AdvancedChartBuilder({
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleRunQuery} disabled={isLoading || !hasConfigChanged}>
|
||||
{isLoading ? <LoadingSpinner /> : t("workspace.analysis.charts.create_chart")}
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
(runQueryCtaLabel ?? t("workspace.analysis.charts.create_chart"))
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,25 +7,31 @@ import { DialogFooter } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartDialogFooterProps {
|
||||
onSaveClick: () => void;
|
||||
onAddToDashboardClick: () => void;
|
||||
onAddToDashboardClick?: () => void;
|
||||
isSaving: boolean;
|
||||
saveLabel?: string;
|
||||
showAddToDashboard?: boolean;
|
||||
}
|
||||
|
||||
export function ChartDialogFooter({
|
||||
onSaveClick,
|
||||
onAddToDashboardClick,
|
||||
isSaving,
|
||||
saveLabel,
|
||||
showAddToDashboard = true,
|
||||
}: Readonly<ChartDialogFooterProps>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
{showAddToDashboard && onAddToDashboardClick && (
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSaveClick} disabled={isSaving}>
|
||||
<SaveIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.save_chart")}
|
||||
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { CopyIcon, MoreVertical, PlusIcon, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteChartAction, duplicateChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
@@ -31,6 +33,36 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||
const [isAddingToDashboard, setIsAddingToDashboard] = useState(false);
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!isAddToDashboardDialogOpen) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void getDashboardsAction({ workspaceId }).then((result) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.data) {
|
||||
setDashboards(result.data.map((dashboard) => ({ id: dashboard.id, name: dashboard.name })));
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAddToDashboardDialogOpen, workspaceId]);
|
||||
|
||||
const handleDeleteChart = async () => {
|
||||
setIsDeleting(true);
|
||||
@@ -70,6 +102,37 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddChartToDashboard = async () => {
|
||||
if (!selectedDashboardId) {
|
||||
toast.error(t("workspace.analysis.charts.please_select_dashboard"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddingToDashboard(true);
|
||||
|
||||
try {
|
||||
const result = await addChartToDashboardAction({
|
||||
workspaceId,
|
||||
chartId: chart.id,
|
||||
dashboardId: selectedDashboardId,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(
|
||||
getFormattedErrorMessage(result) || t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.analysis.charts.chart_added_to_dashboard"));
|
||||
setIsAddToDashboardDialogOpen(false);
|
||||
setSelectedDashboardId(undefined);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setIsAddingToDashboard(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={`chart-${chart.id}-actions`} data-testid="chart-dropdown-menu">
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
@@ -102,6 +165,15 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
{t("common.duplicate")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<PlusIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
setIsDropDownOpen(false);
|
||||
setIsAddToDashboardDialogOpen(true);
|
||||
}}>
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<TrashIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
@@ -123,6 +195,23 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
text={t("workspace.analysis.charts.delete_chart_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsAddToDashboardDialogOpen(open);
|
||||
if (!open) {
|
||||
setSelectedDashboardId(undefined);
|
||||
}
|
||||
}}
|
||||
chartName={chart.name}
|
||||
onChartNameChange={() => {}}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onConfirm={handleAddChartToDashboard}
|
||||
isSaving={isAddingToDashboard}
|
||||
showChartNameField={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { use } from "react";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
|
||||
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
|
||||
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
|
||||
import { hasFeedbackRecordsInDirectories } from "@/modules/ee/analysis/lib/feedback-records";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
@@ -35,22 +38,38 @@ interface ChartsListPageProps {
|
||||
export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPageProps>) {
|
||||
const t = await getTranslate();
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
const chartsPromise = getChartsWithCreator(workspaceId);
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
const [directories, connectors] = await Promise.all([
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(workspaceId),
|
||||
getConnectorsWithMappings(workspaceId),
|
||||
]);
|
||||
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
|
||||
directories.map((directory) => directory.id)
|
||||
);
|
||||
const chartsPromise = hasFeedbackRecords ? getChartsWithCreator(workspaceId) : null;
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.analysis")}
|
||||
workspaceId={workspaceId}
|
||||
cta={
|
||||
isReadOnly ? undefined : <CreateChartButton workspaceId={workspaceId} directories={directories} />
|
||||
isReadOnly ? undefined : (
|
||||
<CreateChartButton
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
buttonProps={{ disabled: !hasFeedbackRecords }}
|
||||
/>
|
||||
)
|
||||
}>
|
||||
<ChartsListContent
|
||||
chartsPromise={chartsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
/>
|
||||
{hasFeedbackRecords && chartsPromise ? (
|
||||
<ChartsListContent
|
||||
chartsPromise={chartsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
/>
|
||||
) : (
|
||||
<NoFeedbackRecordsState workspaceId={workspaceId} hasFeedbackSources={connectors.length > 0} />
|
||||
)}
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,28 +4,43 @@ import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Button, type ButtonProps } from "@/modules/ui/components/button";
|
||||
|
||||
interface CreateChartButtonProps {
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
autoAddToDashboardId?: string;
|
||||
label?: string;
|
||||
onSuccess?: () => void;
|
||||
showIcon?: boolean;
|
||||
buttonProps?: Omit<ButtonProps, "onClick" | "children">;
|
||||
}
|
||||
|
||||
export function CreateChartButton({ workspaceId, directories }: Readonly<CreateChartButtonProps>) {
|
||||
export function CreateChartButton({
|
||||
workspaceId,
|
||||
directories,
|
||||
autoAddToDashboardId,
|
||||
label,
|
||||
onSuccess,
|
||||
showIcon = true,
|
||||
buttonProps,
|
||||
}: Readonly<CreateChartButtonProps>) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setIsDialogOpen(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.create_chart")}
|
||||
<Button size="sm" onClick={() => setIsDialogOpen(true)} {...buttonProps}>
|
||||
{showIcon && <PlusIcon className="mr-2 h-4 w-4" />}
|
||||
{label ?? t("workspace.analysis.charts.create_chart")}
|
||||
</Button>
|
||||
<CreateChartDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
workspaceId={workspaceId}
|
||||
autoAddToDashboardId={autoAddToDashboardId}
|
||||
directories={directories}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
|
||||
import { EditChartView } from "@/modules/ee/analysis/charts/components/edit-chart-view";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface CreateChartDialogProps {
|
||||
@@ -9,6 +8,7 @@ export interface CreateChartDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId?: string;
|
||||
autoAddToDashboardId?: string;
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
@@ -19,29 +19,19 @@ export function CreateChartDialog({
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
autoAddToDashboardId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<CreateChartDialogProps>) {
|
||||
if (chartId) {
|
||||
return (
|
||||
<EditChartView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
workspaceId={workspaceId}
|
||||
chartId={chartId}
|
||||
initialChart={initialChart}
|
||||
onSuccess={onSuccess}
|
||||
directories={directories}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateChartView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
workspaceId={workspaceId}
|
||||
chartId={chartId}
|
||||
initialChart={initialChart}
|
||||
autoAddToDashboardId={autoAddToDashboardId}
|
||||
onSuccess={onSuccess}
|
||||
directories={directories}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
|
||||
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
|
||||
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
|
||||
import { SaveChartDialog } from "@/modules/ee/analysis/charts/components/save-chart-dialog";
|
||||
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
|
||||
import { FrdPicker } from "@/modules/ee/feedback-record-directory/components/frd-picker";
|
||||
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
@@ -19,11 +22,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface CreateChartViewProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId?: string;
|
||||
initialChart?: TChartWithCreator;
|
||||
autoAddToDashboardId?: string;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
@@ -32,32 +40,39 @@ export function CreateChartView({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
initialChart,
|
||||
autoAddToDashboardId,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<CreateChartViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
const isEditing = !!chartId;
|
||||
|
||||
const {
|
||||
chartData,
|
||||
initialQuery,
|
||||
isLoadingChart,
|
||||
chartLoadError,
|
||||
chartName,
|
||||
setChartName,
|
||||
selectedChartType,
|
||||
handleChartTypeChange,
|
||||
handleChartGenerated,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
handleAddToDashboard,
|
||||
handleSaveChart,
|
||||
isSaving,
|
||||
isSaveDialogOpen,
|
||||
setIsSaveDialogOpen,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
selectedDirectoryId,
|
||||
setSelectedDirectoryId,
|
||||
handleClose,
|
||||
} = useChartDialog({ open, onOpenChange, workspaceId, onSuccess, directories });
|
||||
} = useChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
initialChart,
|
||||
autoAddToDashboardId,
|
||||
onSuccess,
|
||||
directories,
|
||||
});
|
||||
|
||||
const chartPreviewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -67,96 +82,139 @@ export function CreateChartView({
|
||||
}
|
||||
}, [chartData]);
|
||||
|
||||
if (isLoadingChart && isEditing && !initialChart) {
|
||||
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
if (isEditing && !isLoadingChart && !chartData && !initialChart && chartLoadError) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("common.error")}</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<p className="text-sm text-red-600">{chartLoadError}</p>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const chartType = selectedChartType ?? (isEditing ? DEFAULT_CHART_TYPE : undefined);
|
||||
const hasSelectedDirectory = !!selectedDirectoryId;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide" disableCloseOnOutsideClick>
|
||||
<DialogContent
|
||||
className="max-h-[90vh] overflow-y-auto"
|
||||
width="wide"
|
||||
disableCloseOnOutsideClick={!isEditing}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.analysis.charts.create_chart")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.analysis.charts.create_chart_description")}</DialogDescription>
|
||||
<DialogTitle>
|
||||
{isEditing
|
||||
? t("workspace.analysis.charts.edit_chart_title")
|
||||
: t("workspace.analysis.charts.create_chart")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? t("workspace.analysis.charts.edit_chart_description")
|
||||
: t("workspace.analysis.charts.create_chart_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="grid gap-4">
|
||||
<FrdPicker
|
||||
directories={directories}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onChange={setSelectedDirectoryId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
|
||||
{hasSelectedDirectory && (
|
||||
{hasSelectedDirectory ? (
|
||||
<>
|
||||
<AIQuerySection
|
||||
workspaceId={workspaceId}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-white px-2 text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.OR")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="create-chart-name"
|
||||
value={chartName}
|
||||
onChange={(event) => setChartName(event.target.value)}
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ManualChartBuilder
|
||||
selectedChartType={selectedChartType}
|
||||
onChartTypeSelect={handleChartTypeChange}
|
||||
/>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<AIQuerySection
|
||||
workspaceId={workspaceId}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
/>
|
||||
|
||||
{selectedChartType && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-white px-2 text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.OR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
|
||||
|
||||
{chartType && (
|
||||
<AdvancedChartBuilder
|
||||
workspaceId={workspaceId}
|
||||
chartType={selectedChartType}
|
||||
initialQuery={chartData?.query}
|
||||
chartType={chartType}
|
||||
initialQuery={chartData?.query ?? initialQuery}
|
||||
hidePreview={true}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
runQueryCtaLabel={
|
||||
chartData
|
||||
? t("workspace.analysis.charts.update_chart")
|
||||
: t("workspace.analysis.charts.preview_chart")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{chartData && (
|
||||
{(isEditing || chartData) && (
|
||||
<div ref={chartPreviewRef}>
|
||||
<ChartPreview chartData={chartData} />
|
||||
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.analysis.charts.no_data_source_available")}</p>
|
||||
<Link
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.analysis.charts.go_to_feedback_record_directories")}
|
||||
</Link>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
{chartData && (
|
||||
<>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={() => setIsSaveDialogOpen(true)}
|
||||
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<SaveChartDialog
|
||||
open={isSaveDialogOpen}
|
||||
onOpenChange={setIsSaveDialogOpen}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
onSave={handleSaveChart}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={setIsAddToDashboardDialogOpen}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onConfirm={handleAddToDashboard}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={handleSaveChart}
|
||||
isSaving={isSaving}
|
||||
showAddToDashboard={false}
|
||||
saveLabel={
|
||||
autoAddToDashboardId
|
||||
? t("workspace.analysis.charts.save_and_add_to_dashboard")
|
||||
: t("workspace.analysis.charts.save_chart")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
|
||||
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
|
||||
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
|
||||
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
|
||||
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface EditChartViewProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId: string;
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function EditChartView({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<EditChartViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
chartData,
|
||||
initialQuery,
|
||||
isLoadingChart,
|
||||
chartLoadError,
|
||||
chartName,
|
||||
setChartName,
|
||||
selectedChartType,
|
||||
handleChartTypeChange,
|
||||
handleChartGenerated,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
handleAddToDashboard,
|
||||
handleSaveChart,
|
||||
isSaving,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
selectedDirectoryId,
|
||||
handleClose,
|
||||
} = useChartDialog({ open, onOpenChange, workspaceId, chartId, initialChart, onSuccess, directories });
|
||||
|
||||
if (isLoadingChart && !initialChart) {
|
||||
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
if (!isLoadingChart && !chartData && !initialChart && chartLoadError) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("common.error")}</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<p className="text-sm text-red-600">{chartLoadError}</p>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const chartType = selectedChartType ?? DEFAULT_CHART_TYPE;
|
||||
const directoryName = directories.find((d) => d.id === selectedDirectoryId)?.name;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.analysis.charts.edit_chart_title")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.analysis.charts.edit_chart_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="grid gap-4 px-1">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="edit-chart-name" className="text-sm">
|
||||
{t("workspace.analysis.charts.chart_name")}
|
||||
</label>
|
||||
<Input
|
||||
id="edit-chart-name"
|
||||
value={chartName}
|
||||
onChange={(e) => setChartName(e.target.value)}
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{directoryName && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("workspace.analysis.charts.data_source")}</Label>
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
{directoryName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
|
||||
</div>
|
||||
<AdvancedChartBuilder
|
||||
workspaceId={workspaceId}
|
||||
chartType={chartType}
|
||||
initialQuery={chartData?.query ?? initialQuery}
|
||||
hidePreview={true}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
/>
|
||||
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
|
||||
</div>
|
||||
</DialogBody>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={handleSaveChart}
|
||||
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={setIsAddToDashboardDialogOpen}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onConfirm={handleAddToDashboard}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export interface UseChartDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId?: string;
|
||||
autoAddToDashboardId?: string;
|
||||
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
@@ -37,6 +38,7 @@ export function useChartDialog({
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
autoAddToDashboardId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
@@ -45,7 +47,6 @@ export function useChartDialog({
|
||||
const router = useRouter();
|
||||
const [selectedChartType, setSelectedChartType] = useState<TChartType | undefined>();
|
||||
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
|
||||
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||
const [chartName, setChartName] = useState("");
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||
@@ -54,9 +55,7 @@ export function useChartDialog({
|
||||
const [isLoadingChart, setIsLoadingChart] = useState(false);
|
||||
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
|
||||
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(
|
||||
directories?.length === 1 ? directories[0].id : null
|
||||
);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories?.[0]?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -85,7 +84,7 @@ export function useChartDialog({
|
||||
setChartName("");
|
||||
setSelectedChartType(undefined);
|
||||
setCurrentChartId(undefined);
|
||||
setSelectedDirectoryId(directories?.length === 1 ? directories[0].id : null);
|
||||
setSelectedDirectoryId(directories?.[0]?.id ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,11 +158,6 @@ export function useChartDialog({
|
||||
|
||||
const handleChartGenerated = (data: AnalyticsResponse) => {
|
||||
setChartData(data);
|
||||
if (!currentChartId) {
|
||||
setChartName(
|
||||
data.chartType ? `${t("workspace.analysis.charts.chart")} ${new Date().toLocaleString()}` : ""
|
||||
);
|
||||
}
|
||||
setSelectedChartType(data.chartType);
|
||||
};
|
||||
|
||||
@@ -180,6 +174,8 @@ export function useChartDialog({
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let savedChartId = currentChartId;
|
||||
|
||||
if (currentChartId) {
|
||||
const result = await updateChartAction({
|
||||
workspaceId,
|
||||
@@ -218,11 +214,32 @@ export function useChartDialog({
|
||||
}
|
||||
|
||||
setCurrentChartId(result.data.id);
|
||||
savedChartId = result.data.id;
|
||||
toast.success(t("workspace.analysis.charts.chart_saved_successfully"));
|
||||
}
|
||||
|
||||
setIsSaveDialogOpen(false);
|
||||
if (autoAddToDashboardId && savedChartId) {
|
||||
const addResult = await addChartToDashboardAction({
|
||||
workspaceId,
|
||||
chartId: savedChartId,
|
||||
dashboardId: autoAddToDashboardId,
|
||||
});
|
||||
|
||||
if (!addResult?.data) {
|
||||
toast.error(
|
||||
getFormattedErrorMessage(addResult) ||
|
||||
t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.analysis.charts.chart_added_to_dashboard"));
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
if (autoAddToDashboardId) {
|
||||
router.push(`/workspaces/${workspaceId}/dashboards/${autoAddToDashboardId}`);
|
||||
}
|
||||
router.refresh();
|
||||
onSuccess?.();
|
||||
} catch (error: unknown) {
|
||||
@@ -328,7 +345,7 @@ export function useChartDialog({
|
||||
setSelectedChartType(undefined);
|
||||
setCurrentChartId(undefined);
|
||||
setChartLoadError(null);
|
||||
setSelectedDirectoryId(directories?.length === 1 ? directories[0].id : null);
|
||||
setSelectedDirectoryId(directories?.[0]?.id ?? null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
@@ -349,8 +366,6 @@ export function useChartDialog({
|
||||
setSelectedChartType,
|
||||
currentChartId,
|
||||
setCurrentChartId,
|
||||
isSaveDialogOpen,
|
||||
setIsSaveDialogOpen,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
dashboards,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MessageSquareDashedIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface NoFeedbackRecordsStateProps {
|
||||
workspaceId: string;
|
||||
hasFeedbackSources?: boolean;
|
||||
}
|
||||
|
||||
export const NoFeedbackRecordsState = async ({
|
||||
workspaceId,
|
||||
hasFeedbackSources = false,
|
||||
}: Readonly<NoFeedbackRecordsStateProps>) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 text-center">
|
||||
<MessageSquareDashedIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="text-balance text-sm text-slate-600">
|
||||
{hasFeedbackSources
|
||||
? t("workspace.analysis.no_feedback_records_with_sources_message")
|
||||
: t("workspace.analysis.no_feedback_records_message")}
|
||||
</p>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
|
||||
{hasFeedbackSources
|
||||
? t("workspace.analysis.manage_feedback_sources")
|
||||
: t("workspace.analysis.setup_feedback_source")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { getChartsAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
|
||||
import { addChartToDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
|
||||
interface AddExistingChartsDialogProps {
|
||||
@@ -25,6 +27,7 @@ interface AddExistingChartsDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
dashboardId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
existingChartIds: string[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
@@ -39,39 +42,45 @@ export function AddExistingChartsDialog({
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
dashboardId,
|
||||
directories,
|
||||
existingChartIds,
|
||||
onSuccess,
|
||||
}: Readonly<AddExistingChartsDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [chartOptions, setChartOptions] = useState<ChartOption[]>([]);
|
||||
const [selectedChartIds, setSelectedChartIds] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const existingChartIdsRef = useRef(existingChartIds);
|
||||
existingChartIdsRef.current = existingChartIds;
|
||||
|
||||
const loadCharts = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setSelectedChartIds([]);
|
||||
try {
|
||||
const result = await getChartsAction({ workspaceId });
|
||||
if (result?.data) {
|
||||
const availableCharts = result.data.filter(
|
||||
(chart) => !existingChartIdsRef.current.includes(chart.id)
|
||||
);
|
||||
setChartOptions(availableCharts.map((chart) => ({ value: chart.id, label: chart.name })));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("workspace.analysis.dashboards.charts_load_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [workspaceId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const loadCharts = async () => {
|
||||
setIsLoading(true);
|
||||
setSelectedChartIds([]);
|
||||
try {
|
||||
const result = await getChartsAction({ workspaceId });
|
||||
if (result?.data) {
|
||||
const availableCharts = result.data.filter((chart) => !existingChartIds.includes(chart.id));
|
||||
setChartOptions(availableCharts.map((chart) => ({ value: chart.id, label: chart.name })));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("workspace.analysis.dashboards.charts_load_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCharts();
|
||||
}, [open, workspaceId, existingChartIds, t]);
|
||||
}, [open, loadCharts]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (selectedChartIds.length === 0) return;
|
||||
@@ -127,15 +136,8 @@ export function AddExistingChartsDialog({
|
||||
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{chartOptions.length === 0 && (
|
||||
<Alert variant="info" className="mb-4">
|
||||
<AlertTitle>{t("workspace.analysis.dashboards.no_charts_to_add_message")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("workspace.analysis.dashboards.no_charts_available_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("common.add_chart")}</Label>
|
||||
<MultiSelect
|
||||
options={chartOptions}
|
||||
value={selectedChartIds}
|
||||
@@ -143,18 +145,35 @@ export function AddExistingChartsDialog({
|
||||
placeholder={t("common.search_charts")}
|
||||
disabled={chartOptions.length === 0}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleAdd} loading={isAdding} disabled={selectedChartIds.length === 0 || isAdding}>
|
||||
{selectedChartIds.length > 0
|
||||
? t("workspace.analysis.dashboards.add_count_charts", { count: selectedChartIds.length })
|
||||
: t("common.add")}
|
||||
</Button>
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<CreateChartButton
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
autoAddToDashboardId={dashboardId}
|
||||
label={t("workspace.analysis.dashboards.create_new_chart")}
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
onSuccess();
|
||||
}}
|
||||
buttonProps={{ variant: "secondary", size: "default", disabled: isAdding }}
|
||||
/>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
loading={isAdding}
|
||||
disabled={selectedChartIds.length === 0 || isAdding}>
|
||||
{selectedChartIds.length > 0
|
||||
? t("workspace.analysis.dashboards.add_count_charts", { count: selectedChartIds.length })
|
||||
: t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -12,9 +12,13 @@ import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface CreateDashboardButtonProps {
|
||||
workspaceId: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const CreateDashboardButton = ({ workspaceId }: Readonly<CreateDashboardButtonProps>) => {
|
||||
export const CreateDashboardButton = ({
|
||||
workspaceId,
|
||||
disabled = false,
|
||||
}: Readonly<CreateDashboardButtonProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
@@ -59,7 +63,7 @@ export const CreateDashboardButton = ({ workspaceId }: Readonly<CreateDashboardB
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={() => handleOpenChange(true)}>
|
||||
<Button size="sm" onClick={() => handleOpenChange(true)} disabled={disabled}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.dashboards.create_dashboard")}
|
||||
</Button>
|
||||
|
||||
@@ -8,12 +8,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import { AddExistingChartsDialog } from "@/modules/ee/analysis/dashboards/components/add-existing-charts-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
|
||||
interface DashboardControlBarProps {
|
||||
workspaceId: string;
|
||||
dashboardId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
existingChartIds: string[];
|
||||
isEditing: boolean;
|
||||
isSaving: boolean;
|
||||
@@ -28,6 +30,7 @@ interface DashboardControlBarProps {
|
||||
export const DashboardControlBar = ({
|
||||
workspaceId,
|
||||
dashboardId,
|
||||
directories,
|
||||
existingChartIds,
|
||||
isEditing,
|
||||
isSaving,
|
||||
@@ -82,12 +85,6 @@ export const DashboardControlBar = ({
|
||||
];
|
||||
|
||||
const viewModeActions = [
|
||||
{
|
||||
icon: PlusIcon,
|
||||
tooltip: t("common.add_charts"),
|
||||
onClick: () => setIsAddExistingDialogOpen(true),
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
{
|
||||
icon: RefreshCwIcon,
|
||||
tooltip: t("common.refresh"),
|
||||
@@ -110,7 +107,19 @@ export const DashboardControlBar = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconBar actions={isEditing ? editModeActions : viewModeActions} />
|
||||
{isEditing ? (
|
||||
<IconBar actions={editModeActions} />
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{!isReadOnly && (
|
||||
<Button onClick={() => setIsAddExistingDialogOpen(true)}>
|
||||
<PlusIcon />
|
||||
{t("common.add_charts")}
|
||||
</Button>
|
||||
)}
|
||||
<IconBar actions={viewModeActions} />
|
||||
</div>
|
||||
)}
|
||||
<DeleteDialog
|
||||
deleteWhat={t("workspace.analysis.dashboards.dashboard")}
|
||||
open={isDeleteDialogOpen}
|
||||
@@ -124,6 +133,7 @@ export const DashboardControlBar = ({
|
||||
onOpenChange={setIsAddExistingDialogOpen}
|
||||
workspaceId={workspaceId}
|
||||
dashboardId={dashboardId}
|
||||
directories={directories}
|
||||
existingChartIds={existingChartIds}
|
||||
onSuccess={() => {
|
||||
setIsAddExistingDialogOpen(false);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import "react-resizable/css/styles.css";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
|
||||
import { DashboardControlBar } from "@/modules/ee/analysis/dashboards/components/dashboard-control-bar";
|
||||
import { DashboardPageHeader } from "@/modules/ee/analysis/dashboards/components/dashboard-page-header";
|
||||
import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/dashboard-widget";
|
||||
@@ -27,6 +28,7 @@ interface DashboardDetailClientProps {
|
||||
workspaceId: string;
|
||||
dashboard: TDashboardDetail;
|
||||
widgetDataPromises: Map<string, Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>>;
|
||||
directories: { id: string; name: string }[];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
@@ -114,17 +116,26 @@ const MemoizedWidgetItem = memo(function WidgetItem({
|
||||
widget,
|
||||
isEditing,
|
||||
dataPromise,
|
||||
onEdit,
|
||||
onResize,
|
||||
onRemove,
|
||||
}: Readonly<{
|
||||
widget: TDashboardWidget;
|
||||
isEditing: boolean;
|
||||
dataPromise?: Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>;
|
||||
onEdit?: () => void;
|
||||
onResize?: () => void;
|
||||
onRemove?: () => void;
|
||||
}>) {
|
||||
const title = widget.chart.name;
|
||||
const title = widget.chart?.name ?? "";
|
||||
|
||||
return (
|
||||
<DashboardWidget title={title} isEditing={isEditing} onRemove={onRemove}>
|
||||
<DashboardWidget
|
||||
title={title}
|
||||
isEditing={isEditing}
|
||||
onEdit={onEdit}
|
||||
onResize={onResize}
|
||||
onRemove={onRemove}>
|
||||
<MemoizedWidgetContent widget={widget} dataPromise={dataPromise} />
|
||||
</DashboardWidget>
|
||||
);
|
||||
@@ -134,6 +145,7 @@ export function DashboardDetailClient({
|
||||
workspaceId,
|
||||
dashboard,
|
||||
widgetDataPromises,
|
||||
directories,
|
||||
isReadOnly,
|
||||
}: Readonly<DashboardDetailClientProps>) {
|
||||
const router = useRouter();
|
||||
@@ -142,6 +154,7 @@ export function DashboardDetailClient({
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editingChartId, setEditingChartId] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [name, setName] = useState(dashboard.name);
|
||||
@@ -171,6 +184,32 @@ export function DashboardDetailClient({
|
||||
[dashboard.widgets]
|
||||
);
|
||||
|
||||
const handleEnterEditMode = useCallback(() => {
|
||||
if (isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftWidgets((current) => current ?? dashboard.widgets);
|
||||
setIsEditing(true);
|
||||
}, [dashboard.widgets, isEditing]);
|
||||
|
||||
const handleEditChart = useCallback((chartId: string) => {
|
||||
setEditingChartId(chartId);
|
||||
}, []);
|
||||
|
||||
const handleRemoveWidgetFromMenu = useCallback(
|
||||
(widgetId: string) => {
|
||||
if (!isEditing) {
|
||||
setDraftWidgets((current) => (current ?? dashboard.widgets).filter((w) => w.id !== widgetId));
|
||||
setIsEditing(true);
|
||||
return;
|
||||
}
|
||||
|
||||
handleRemoveWidget(widgetId);
|
||||
},
|
||||
[dashboard.widgets, handleRemoveWidget, isEditing]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setName(dashboard.name);
|
||||
setDraftWidgets(null);
|
||||
@@ -248,16 +287,14 @@ export function DashboardDetailClient({
|
||||
<DashboardControlBar
|
||||
workspaceId={workspaceId}
|
||||
dashboardId={dashboard.id}
|
||||
directories={directories}
|
||||
existingChartIds={widgets.map((w) => w.chartId)}
|
||||
isEditing={isEditing}
|
||||
isSaving={isSaving}
|
||||
hasChanges={hasChanges}
|
||||
isReadOnly={isReadOnly}
|
||||
onRefresh={() => router.refresh()}
|
||||
onEditToggle={() => {
|
||||
setDraftWidgets(dashboard.widgets);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
onEditToggle={handleEnterEditMode}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
@@ -296,7 +333,9 @@ export function DashboardDetailClient({
|
||||
widget={widget}
|
||||
isEditing={isEditing}
|
||||
dataPromise={widgetDataPromises.get(widget.id)}
|
||||
onRemove={isEditing ? () => handleRemoveWidget(widget.id) : undefined}
|
||||
onEdit={isReadOnly ? undefined : () => handleEditChart(widget.chartId)}
|
||||
onResize={isReadOnly ? undefined : handleEnterEditMode}
|
||||
onRemove={isReadOnly ? undefined : () => handleRemoveWidgetFromMenu(widget.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -305,6 +344,23 @@ export function DashboardDetailClient({
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{!isReadOnly && (
|
||||
<CreateChartDialog
|
||||
open={editingChartId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingChartId(null);
|
||||
}
|
||||
}}
|
||||
workspaceId={workspaceId}
|
||||
chartId={editingChartId ?? undefined}
|
||||
onSuccess={() => {
|
||||
setEditingChartId(null);
|
||||
router.refresh();
|
||||
}}
|
||||
directories={directories}
|
||||
/>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MoreVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { Maximize2Icon, MoreVerticalIcon, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -15,12 +15,22 @@ interface DashboardWidgetProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
isEditing?: boolean;
|
||||
onEdit?: () => void;
|
||||
onResize?: () => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function DashboardWidget({ title, children, isEditing, onRemove }: Readonly<DashboardWidgetProps>) {
|
||||
export function DashboardWidget({
|
||||
title,
|
||||
children,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onResize,
|
||||
onRemove,
|
||||
}: Readonly<DashboardWidgetProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const hasMenuActions = Boolean(onEdit || onResize || onRemove);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -34,7 +44,7 @@ export function DashboardWidget({ title, children, isEditing, onRemove }: Readon
|
||||
isEditing && "rgl-drag-handle cursor-grab active:cursor-grabbing"
|
||||
)}>
|
||||
<h3 className="flex-1 truncate text-sm font-semibold text-gray-800">{title}</h3>
|
||||
{onRemove && (
|
||||
{hasMenuActions && (
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@@ -47,15 +57,37 @@ export function DashboardWidget({ title, children, isEditing, onRemove }: Readon
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onRemove();
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600">
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.remove")}
|
||||
</DropdownMenuItem>
|
||||
{onEdit && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onEdit();
|
||||
}}>
|
||||
<SquarePenIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onResize && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onResize();
|
||||
}}>
|
||||
<Maximize2Icon className="mr-2 h-4 w-4" />
|
||||
{t("common.resize")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onRemove && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onRemove();
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600">
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.remove")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
|
||||
import { injectTenantFilter } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { DashboardDetailClient } from "../components/dashboard-detail-client";
|
||||
import { getDashboard } from "../lib/dashboards";
|
||||
@@ -16,13 +17,14 @@ interface WidgetQueryResult {
|
||||
async function executeWidgetQuery(
|
||||
query: TChartQuery,
|
||||
feedbackRecordDirectoryId: string
|
||||
): Promise<WidgetQueryResult | null> {
|
||||
): Promise<WidgetQueryResult | { error: string }> {
|
||||
try {
|
||||
const scopedQuery = injectTenantFilter(query, feedbackRecordDirectoryId);
|
||||
const data = await executeQuery(scopedQuery as Record<string, unknown>);
|
||||
return { data: Array.isArray(data) ? data : [], query };
|
||||
} catch {
|
||||
return null;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load chart data";
|
||||
return { error: message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +35,7 @@ export async function DashboardDetailPage({
|
||||
}>) {
|
||||
const { workspaceId, dashboardId } = await params;
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
|
||||
let dashboard;
|
||||
try {
|
||||
@@ -44,27 +47,23 @@ export async function DashboardDetailPage({
|
||||
throw error;
|
||||
}
|
||||
|
||||
const widgetDataPromises = new Map<string, Promise<WidgetQueryResult>>();
|
||||
const widgetDataPromises = new Map<string, Promise<WidgetQueryResult | { error: string }>>();
|
||||
const widgetsWithCharts = dashboard.widgets.filter(
|
||||
(w): w is typeof w & { chart: NonNullable<typeof w.chart> } => !!w.chart
|
||||
);
|
||||
const queryPromises = widgetsWithCharts.map((widget) => ({
|
||||
widgetId: widget.id,
|
||||
promise: executeWidgetQuery(widget.chart.query, widget.chart.feedbackRecordDirectoryId),
|
||||
}));
|
||||
const results = await Promise.all(queryPromises.map((q) => q.promise));
|
||||
queryPromises.forEach(({ widgetId }, i: number) => {
|
||||
const result = results[i];
|
||||
if (result) {
|
||||
widgetDataPromises.set(widgetId, Promise.resolve(result));
|
||||
}
|
||||
});
|
||||
for (const widget of widgetsWithCharts) {
|
||||
widgetDataPromises.set(
|
||||
widget.id,
|
||||
executeWidgetQuery(widget.chart.query, widget.chart.feedbackRecordDirectoryId)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardDetailClient
|
||||
workspaceId={workspaceId}
|
||||
dashboard={dashboard}
|
||||
widgetDataPromises={widgetDataPromises}
|
||||
directories={directories}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { use } from "react";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
|
||||
import { hasWorkspaceFeedbackRecords } from "@/modules/ee/analysis/lib/feedback-records";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { TDashboardWithCount } from "../../types/analysis";
|
||||
import { CreateDashboardButton } from "../components/create-dashboard-button";
|
||||
@@ -31,18 +34,30 @@ export const DashboardsListPage = async ({ workspaceId }: Readonly<DashboardsLis
|
||||
const t = await getTranslate();
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
|
||||
const dashboardsPromise = getDashboards(workspaceId);
|
||||
const [hasFeedbackRecords, connectors] = await Promise.all([
|
||||
hasWorkspaceFeedbackRecords(workspaceId),
|
||||
getConnectorsWithMappings(workspaceId),
|
||||
]);
|
||||
const dashboardsPromise = hasFeedbackRecords ? getDashboards(workspaceId) : null;
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.analysis")}
|
||||
workspaceId={workspaceId}
|
||||
cta={isReadOnly ? undefined : <CreateDashboardButton workspaceId={workspaceId} />}>
|
||||
<DashboardsListContent
|
||||
dashboardsPromise={dashboardsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
cta={
|
||||
isReadOnly ? undefined : (
|
||||
<CreateDashboardButton workspaceId={workspaceId} disabled={!hasFeedbackRecords} />
|
||||
)
|
||||
}>
|
||||
{hasFeedbackRecords && dashboardsPromise ? (
|
||||
<DashboardsListContent
|
||||
dashboardsPromise={dashboardsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
) : (
|
||||
<NoFeedbackRecordsState workspaceId={workspaceId} hasFeedbackSources={connectors.length > 0} />
|
||||
)}
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"server-only";
|
||||
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
|
||||
export const hasFeedbackRecordsInDirectories = async (directoryIds: string[]): Promise<boolean> => {
|
||||
if (directoryIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
directoryIds.map((directoryId) => listFeedbackRecords({ tenant_id: directoryId, limit: 1 }))
|
||||
);
|
||||
|
||||
const hasRecords = results.some((result) => (result.data?.data?.length ?? 0) > 0);
|
||||
if (hasRecords) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasErrors = results.some((result) => Boolean(result.error));
|
||||
|
||||
// Do not lock creation flows when record availability is unknown.
|
||||
return hasErrors;
|
||||
};
|
||||
|
||||
export const hasWorkspaceFeedbackRecords = async (workspaceId: string): Promise<boolean> => {
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
|
||||
return hasFeedbackRecordsInDirectories(directories.map((directory) => directory.id));
|
||||
};
|
||||
@@ -74,6 +74,7 @@ export const getFeedbackRecordDirectoryDetailsAction = authenticatedActionClient
|
||||
const ZUpdateFeedbackRecordDirectoryAction = z.object({
|
||||
directoryId: ZId,
|
||||
data: ZFeedbackRecordDirectoryUpdateInput,
|
||||
pauseConnectorsInRemovedWorkspaces: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const updateFeedbackRecordDirectoryAction = authenticatedActionClient
|
||||
@@ -99,7 +100,10 @@ export const updateFeedbackRecordDirectoryAction = authenticatedActionClient
|
||||
const result = await updateFeedbackRecordDirectory(
|
||||
parsedInput.directoryId,
|
||||
organizationId,
|
||||
parsedInput.data
|
||||
parsedInput.data,
|
||||
{
|
||||
pauseConnectorsInRemovedWorkspaces: parsedInput.pauseConnectorsInRemovedWorkspaces,
|
||||
}
|
||||
);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
|
||||
|
||||
+147
-27
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -17,6 +18,7 @@ import { ArchiveFeedbackRecordDirectory } from "@/modules/ee/feedback-record-dir
|
||||
import {
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TFeedbackRecordDirectoryUpdateInput,
|
||||
TWorkspaceFeedbackRecordDirectoryAccess,
|
||||
ZFeedbackRecordDirectoryUpdateInput,
|
||||
getTranslatedFeedbackRecordDirectoryError,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
@@ -43,6 +45,7 @@ interface FeedbackRecordDirectorySettingsModalProps {
|
||||
directory?: TFeedbackRecordDirectoryDetails;
|
||||
organizationId: string;
|
||||
orgWorkspaces: TOrganizationWorkspace[];
|
||||
workspaceAccessByWorkspace: TWorkspaceFeedbackRecordDirectoryAccess[];
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
|
||||
@@ -52,24 +55,47 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
directory,
|
||||
organizationId,
|
||||
orgWorkspaces,
|
||||
workspaceAccessByWorkspace,
|
||||
membershipRole,
|
||||
}: FeedbackRecordDirectorySettingsModalProps) => {
|
||||
}: Readonly<FeedbackRecordDirectorySettingsModalProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
const router = useRouter();
|
||||
const isEdit = !!directory;
|
||||
|
||||
const [confirmPauseDialogOpen, setConfirmPauseDialogOpen] = useState(false);
|
||||
const [pendingSubmitData, setPendingSubmitData] = useState<TFeedbackRecordDirectoryUpdateInput | null>(
|
||||
null
|
||||
);
|
||||
const [connectorsToPauseCount, setConnectorsToPauseCount] = useState(0);
|
||||
|
||||
const workspaceAccessMap = useMemo(
|
||||
() => new Map(workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])),
|
||||
[workspaceAccessByWorkspace]
|
||||
);
|
||||
|
||||
const workspaceOptions = useMemo(
|
||||
() =>
|
||||
orgWorkspaces
|
||||
.map((p) => ({ value: p.id, label: p.name }))
|
||||
.map((workspace) => {
|
||||
const assignment = workspaceAccessMap.get(workspace.id);
|
||||
const isAssignedToDifferentDirectory = Boolean(
|
||||
assignment && assignment.feedbackRecordDirectoryId !== directory?.id
|
||||
);
|
||||
|
||||
return {
|
||||
value: workspace.id,
|
||||
label: workspace.name,
|
||||
disabled: isAssignedToDifferentDirectory,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })),
|
||||
[orgWorkspaces]
|
||||
[orgWorkspaces, workspaceAccessMap, directory?.id]
|
||||
);
|
||||
|
||||
const initialWorkspaceIds = useMemo(
|
||||
() => directory?.workspaces.map((p) => p.workspaceId) ?? [],
|
||||
() => directory?.workspaces.map((workspace) => workspace.workspaceId) ?? [],
|
||||
[directory?.workspaces]
|
||||
);
|
||||
|
||||
@@ -91,21 +117,29 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
} = form;
|
||||
|
||||
const closeModal = () => {
|
||||
setConfirmPauseDialogOpen(false);
|
||||
setPendingSubmitData(null);
|
||||
setConnectorsToPauseCount(0);
|
||||
reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmitForm: SubmitHandler<TFeedbackRecordDirectoryUpdateInput> = async (data) => {
|
||||
const response = isEdit
|
||||
? await updateFeedbackRecordDirectoryAction({
|
||||
directoryId: directory.id,
|
||||
data: { name: data.name, workspaceIds: data.workspaceIds },
|
||||
})
|
||||
: await createFeedbackRecordDirectoryAction({
|
||||
organizationId,
|
||||
name: data.name ?? "",
|
||||
workspaceIds: data.workspaceIds,
|
||||
});
|
||||
const submitDirectory = async (
|
||||
data: TFeedbackRecordDirectoryUpdateInput,
|
||||
pauseConnectorsInRemovedWorkspaces: boolean
|
||||
) => {
|
||||
const response =
|
||||
isEdit && directory
|
||||
? await updateFeedbackRecordDirectoryAction({
|
||||
directoryId: directory.id,
|
||||
data: { name: data.name, workspaceIds: data.workspaceIds },
|
||||
pauseConnectorsInRemovedWorkspaces,
|
||||
})
|
||||
: await createFeedbackRecordDirectoryAction({
|
||||
organizationId,
|
||||
name: data.name ?? "",
|
||||
workspaceIds: data.workspaceIds,
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
toast.success(
|
||||
@@ -115,12 +149,54 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
);
|
||||
closeModal();
|
||||
router.refresh();
|
||||
return true;
|
||||
} else {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmPauseAndSubmit = async () => {
|
||||
if (!pendingSubmitData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasSuccessful = await submitDirectory(pendingSubmitData, true);
|
||||
if (wasSuccessful) {
|
||||
setConfirmPauseDialogOpen(false);
|
||||
setPendingSubmitData(null);
|
||||
setConnectorsToPauseCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitForm: SubmitHandler<TFeedbackRecordDirectoryUpdateInput> = async (data) => {
|
||||
if (!isEdit || !directory) {
|
||||
await submitDirectory(data, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedWorkspaceIds = data.workspaceIds ?? [];
|
||||
const removedWorkspaceIds = initialWorkspaceIds.filter(
|
||||
(workspaceId) => !updatedWorkspaceIds.includes(workspaceId)
|
||||
);
|
||||
|
||||
if (removedWorkspaceIds.length > 0) {
|
||||
const affectedConnectors = directory.connectors.filter((connector) =>
|
||||
removedWorkspaceIds.includes(connector.workspaceId)
|
||||
);
|
||||
|
||||
if (affectedConnectors.length > 0) {
|
||||
setPendingSubmitData(data);
|
||||
setConnectorsToPauseCount(affectedConnectors.length);
|
||||
setConfirmPauseDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await submitDirectory(data, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(newOpen) => (newOpen ? setOpen(true) : closeModal())}>
|
||||
<DialogContent>
|
||||
@@ -157,21 +233,17 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
disabled={!isOwnerOrManager}
|
||||
/>
|
||||
</FormControl>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
{error?.message && (
|
||||
<FormError className="text-left">
|
||||
{getTranslatedFeedbackRecordDirectoryError(error.message, t)}
|
||||
</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isEdit && (
|
||||
<IdBadge
|
||||
id={directory.id}
|
||||
label={t("workspace.settings.feedback_record_directories.directory_id")}
|
||||
variant="column"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("common.workspaces")}</FormLabel>
|
||||
<FormLabel>{t("workspace.settings.feedback_record_directories.workspace_access")}</FormLabel>
|
||||
<Muted className="block text-slate-500">
|
||||
{t("workspace.settings.feedback_record_directories.assign_workspaces_description")}
|
||||
</Muted>
|
||||
@@ -213,7 +285,7 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
</div>
|
||||
<a
|
||||
className="text-xs font-medium text-slate-700 hover:text-slate-900 hover:underline"
|
||||
href={`/workspaces/${c.workspaceId}/unify/sources`}>
|
||||
href={`/workspaces/${c.workspaceId}/feedback-sources`}>
|
||||
{t("common.view")}
|
||||
</a>
|
||||
</li>
|
||||
@@ -222,6 +294,14 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEdit && (
|
||||
<IdBadge
|
||||
id={directory.id}
|
||||
label={t("workspace.settings.feedback_record_directories.directory_id")}
|
||||
variant="column"
|
||||
/>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{isEdit && (
|
||||
@@ -243,6 +323,46 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
|
||||
{confirmPauseDialogOpen && (
|
||||
<Dialog open={confirmPauseDialogOpen} onOpenChange={setConfirmPauseDialogOpen}>
|
||||
<DialogContent width="narrow" hideCloseButton={true} disableCloseOnOutsideClick={true}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
<DialogTitle>
|
||||
{t("workspace.settings.feedback_record_directories.pause_connectors_confirmation_title")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<p>
|
||||
{t(
|
||||
"workspace.settings.feedback_record_directories.pause_connectors_confirmation_description",
|
||||
{
|
||||
count: connectorsToPauseCount,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setConfirmPauseDialogOpen(false);
|
||||
setPendingSubmitData(null);
|
||||
setConnectorsToPauseCount(0);
|
||||
}}
|
||||
disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmPauseAndSubmit} loading={isSubmitting}>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
+27
-1
@@ -15,6 +15,7 @@ import { FeedbackRecordDirectorySettingsModal } from "@/modules/ee/feedback-reco
|
||||
import {
|
||||
TFeedbackRecordDirectory,
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TWorkspaceFeedbackRecordDirectoryAccess,
|
||||
getTranslatedFeedbackRecordDirectoryError,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
import { TOrganizationWorkspace } from "@/modules/ee/teams/team-list/types/workspace";
|
||||
@@ -27,6 +28,7 @@ interface FeedbackRecordDirectoryTableProps {
|
||||
directories: TFeedbackRecordDirectory[];
|
||||
organizationId: string;
|
||||
orgWorkspaces: TOrganizationWorkspace[];
|
||||
workspaceAccessByWorkspace: TWorkspaceFeedbackRecordDirectoryAccess[];
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
|
||||
@@ -34,8 +36,9 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
directories,
|
||||
organizationId,
|
||||
orgWorkspaces,
|
||||
workspaceAccessByWorkspace,
|
||||
membershipRole,
|
||||
}: FeedbackRecordDirectoryTableProps) => {
|
||||
}: Readonly<FeedbackRecordDirectoryTableProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openSettingsModal, setOpenSettingsModal] = useState(false);
|
||||
@@ -67,6 +70,27 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
const handleUnarchiveDirectory = async (directoryId: string) => {
|
||||
setLoadingDirectoryId(directoryId);
|
||||
try {
|
||||
const directoryDetailsResponse = await getFeedbackRecordDirectoryDetailsAction({ directoryId });
|
||||
if (!directoryDetailsResponse?.data) {
|
||||
const errorCode = getFormattedErrorMessage(directoryDetailsResponse);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceAccessMap = new Map(
|
||||
workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])
|
||||
);
|
||||
|
||||
const hasConflicts = directoryDetailsResponse.data.workspaces.some((workspace) => {
|
||||
const assignment = workspaceAccessMap.get(workspace.workspaceId);
|
||||
return assignment && assignment.feedbackRecordDirectoryId !== directoryId;
|
||||
});
|
||||
|
||||
if (hasConflicts) {
|
||||
toast.error(t("workspace.settings.feedback_record_directories.unarchive_workspace_conflict"));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await updateFeedbackRecordDirectoryAction({
|
||||
directoryId,
|
||||
data: { isArchived: false },
|
||||
@@ -166,6 +190,7 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
setOpen={setOpenCreateModal}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
workspaceAccessByWorkspace={workspaceAccessByWorkspace}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
)}
|
||||
@@ -177,6 +202,7 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
directory={selectedDirectory}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
workspaceAccessByWorkspace={workspaceAccessByWorkspace}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
)}
|
||||
|
||||
+7
-2
@@ -2,7 +2,10 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { FeedbackRecordDirectoryTable } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-table";
|
||||
import { getFeedbackRecordDirectories } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import {
|
||||
getFeedbackRecordDirectories,
|
||||
getWorkspaceFeedbackRecordDirectoryAccess,
|
||||
} from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspacesByOrganizationId } from "@/modules/ee/teams/team-list/lib/workspace";
|
||||
|
||||
interface FeedbackRecordDirectoryViewProps {
|
||||
@@ -16,9 +19,10 @@ export const FeedbackRecordDirectoryView = async ({
|
||||
}: FeedbackRecordDirectoryViewProps) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const [directories, orgWorkspaces] = await Promise.all([
|
||||
const [directories, orgWorkspaces, workspaceAccessByWorkspace] = await Promise.all([
|
||||
getFeedbackRecordDirectories(organizationId),
|
||||
getWorkspacesByOrganizationId(organizationId),
|
||||
getWorkspaceFeedbackRecordDirectoryAccess(organizationId),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -29,6 +33,7 @@ export const FeedbackRecordDirectoryView = async ({
|
||||
directories={directories}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
workspaceAccessByWorkspace={workspaceAccessByWorkspace}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
+111
-2
@@ -8,6 +8,7 @@ import {
|
||||
getFeedbackRecordDirectoriesByWorkspaceId,
|
||||
getFeedbackRecordDirectoryDetails,
|
||||
getOrganizationIdFromDirectoryId,
|
||||
getWorkspaceFeedbackRecordDirectoryAccess,
|
||||
updateFeedbackRecordDirectory,
|
||||
} from "./feedback-record-directory";
|
||||
|
||||
@@ -33,6 +34,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
connector: {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -147,7 +149,7 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "My Connector",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: mockWorkspaceId1,
|
||||
workspace: { name: "Workspace A" },
|
||||
},
|
||||
@@ -161,7 +163,7 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "My Connector",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: mockWorkspaceId1,
|
||||
workspaceName: "Workspace A",
|
||||
},
|
||||
@@ -345,6 +347,34 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("pauses connectors in removed workspaces when requested", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(
|
||||
mockDirectoryDetailsDbRow as any
|
||||
);
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
|
||||
|
||||
const result = await updateFeedbackRecordDirectory(
|
||||
mockDirectoryId,
|
||||
mockOrganizationId,
|
||||
{
|
||||
workspaceIds: [mockWorkspaceId1],
|
||||
},
|
||||
{ pauseConnectorsInRemovedWorkspaces: true }
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.connector.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
feedbackRecordDirectoryId: mockDirectoryId,
|
||||
workspaceId: { in: [mockWorkspaceId2] },
|
||||
},
|
||||
data: {
|
||||
status: "paused",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when directory does not exist (P2025)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
@@ -446,6 +476,85 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorkspaceFeedbackRecordDirectoryAccess", () => {
|
||||
test("returns one active assignment per workspace with directory details", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
workspaceId: mockWorkspaceId1,
|
||||
feedbackRecordDirectory: { id: mockDirectoryId, name: "Directory A" },
|
||||
},
|
||||
{
|
||||
workspaceId: mockWorkspaceId1,
|
||||
feedbackRecordDirectory: { id: "clj28r6va000409j3ep7h8xy2", name: "Directory B" },
|
||||
},
|
||||
{
|
||||
workspaceId: mockWorkspaceId2,
|
||||
feedbackRecordDirectory: { id: "clj28r6va000409j3ep7h8xy3", name: "Directory C" },
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
workspaceId: mockWorkspaceId1,
|
||||
feedbackRecordDirectoryId: mockDirectoryId,
|
||||
feedbackRecordDirectoryName: "Directory A",
|
||||
},
|
||||
{
|
||||
workspaceId: mockWorkspaceId2,
|
||||
feedbackRecordDirectoryId: "clj28r6va000409j3ep7h8xy3",
|
||||
feedbackRecordDirectoryName: "Directory C",
|
||||
},
|
||||
]);
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
feedbackRecordDirectory: {
|
||||
organizationId: mockOrganizationId,
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
feedbackRecordDirectory: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ workspaceId: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no active access assignments exist", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2010",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("re-throws unexpected errors", async () => {
|
||||
const error = new Error("Unexpected");
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrganizationIdFromDirectoryId", () => {
|
||||
test("returns organization ID for a valid directory", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TFeedbackRecordDirectory,
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TFeedbackRecordDirectoryUpdateInput,
|
||||
TWorkspaceFeedbackRecordDirectoryAccess,
|
||||
ZFeedbackRecordDirectoryUpdateInput,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
|
||||
@@ -99,6 +100,55 @@ export const getFeedbackRecordDirectoriesByWorkspaceId = reactCache(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Lists active feedback directory access assignments by workspace for an organization.
|
||||
* Each workspace appears once with the first active directory assignment found.
|
||||
*/
|
||||
export const getWorkspaceFeedbackRecordDirectoryAccess = reactCache(
|
||||
async (organizationId: string): Promise<TWorkspaceFeedbackRecordDirectoryAccess[]> => {
|
||||
validateInputs([organizationId, ZId]);
|
||||
try {
|
||||
const rows = await prisma.feedbackRecordDirectoryWorkspace.findMany({
|
||||
where: {
|
||||
feedbackRecordDirectory: {
|
||||
organizationId,
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
feedbackRecordDirectory: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ workspaceId: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
const accessByWorkspaceId = new Map<string, TWorkspaceFeedbackRecordDirectoryAccess>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!accessByWorkspaceId.has(row.workspaceId)) {
|
||||
accessByWorkspaceId.set(row.workspaceId, {
|
||||
workspaceId: row.workspaceId,
|
||||
feedbackRecordDirectoryId: row.feedbackRecordDirectory.id,
|
||||
feedbackRecordDirectoryName: row.feedbackRecordDirectory.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(accessByWorkspaceId.values());
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getFeedbackRecordDirectoryDetails = reactCache(
|
||||
async (directoryId: string): Promise<TFeedbackRecordDirectoryDetails | null> => {
|
||||
validateInputs([directoryId, ZId]);
|
||||
@@ -238,7 +288,10 @@ const buildWorkspaceAssignmentPayload = async (
|
||||
workspaceIds: string[],
|
||||
organizationId: string,
|
||||
currentWorkspaceIds: string[]
|
||||
): Promise<Prisma.FeedbackRecordDirectoryWorkspaceUpdateManyWithoutFeedbackRecordDirectoryNestedInput> => {
|
||||
): Promise<{
|
||||
payload: Prisma.FeedbackRecordDirectoryWorkspaceUpdateManyWithoutFeedbackRecordDirectoryNestedInput;
|
||||
deletedWorkspaceIds: string[];
|
||||
}> => {
|
||||
if (workspaceIds.length > 0) {
|
||||
const orgWorkspacesCount = await prismaClient.workspace.count({
|
||||
where: {
|
||||
@@ -254,22 +307,94 @@ const buildWorkspaceAssignmentPayload = async (
|
||||
const deletedWorkspaceIds = currentWorkspaceIds.filter((id) => !workspaceIds.includes(id));
|
||||
|
||||
return {
|
||||
deleteMany: {
|
||||
workspaceId: { in: deletedWorkspaceIds },
|
||||
},
|
||||
upsert: workspaceIds.map((workspaceId) => ({
|
||||
where: {
|
||||
feedbackRecordDirectoryId_workspaceId: {
|
||||
feedbackRecordDirectoryId: directoryId,
|
||||
workspaceId,
|
||||
},
|
||||
payload: {
|
||||
deleteMany: {
|
||||
workspaceId: { in: deletedWorkspaceIds },
|
||||
},
|
||||
update: {},
|
||||
create: { workspaceId },
|
||||
})),
|
||||
upsert: workspaceIds.map((workspaceId) => ({
|
||||
where: {
|
||||
feedbackRecordDirectoryId_workspaceId: {
|
||||
feedbackRecordDirectoryId: directoryId,
|
||||
workspaceId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: { workspaceId },
|
||||
})),
|
||||
},
|
||||
deletedWorkspaceIds,
|
||||
};
|
||||
};
|
||||
|
||||
interface UpdateFeedbackRecordDirectoryOptions {
|
||||
pauseConnectorsInRemovedWorkspaces?: boolean;
|
||||
}
|
||||
|
||||
const getArchiveUpdate = async (
|
||||
directoryId: string,
|
||||
isArchived: boolean | undefined
|
||||
): Promise<Pick<Prisma.FeedbackRecordDirectoryUpdateInput, "isArchived">> => {
|
||||
if (isArchived === true) {
|
||||
const connectorCount = await prisma.connector.count({
|
||||
where: { feedbackRecordDirectoryId: directoryId },
|
||||
});
|
||||
if (connectorCount > 0) {
|
||||
throw new InvalidInputError("DIRECTORY_HAS_CONNECTORS");
|
||||
}
|
||||
return { isArchived: true };
|
||||
}
|
||||
|
||||
if (isArchived === false) {
|
||||
return { isArchived: false };
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const getWorkspaceAssignmentUpdate = async (
|
||||
directoryId: string,
|
||||
organizationId: string,
|
||||
workspaceIds: string[] | undefined
|
||||
): Promise<{
|
||||
workspaces?: Prisma.FeedbackRecordDirectoryWorkspaceUpdateManyWithoutFeedbackRecordDirectoryNestedInput;
|
||||
removedWorkspaceIds: string[];
|
||||
}> => {
|
||||
if (workspaceIds === undefined) {
|
||||
return { removedWorkspaceIds: [] };
|
||||
}
|
||||
|
||||
const currentDetails = await getFeedbackRecordDirectoryDetails(directoryId);
|
||||
const currentWorkspaceIds = currentDetails?.workspaces.map((workspace) => workspace.workspaceId) ?? [];
|
||||
const assignmentPayload = await buildWorkspaceAssignmentPayload(
|
||||
prisma,
|
||||
directoryId,
|
||||
workspaceIds,
|
||||
organizationId,
|
||||
currentWorkspaceIds
|
||||
);
|
||||
|
||||
return {
|
||||
workspaces: assignmentPayload.payload,
|
||||
removedWorkspaceIds: assignmentPayload.deletedWorkspaceIds,
|
||||
};
|
||||
};
|
||||
|
||||
const pauseConnectorsInWorkspaces = async (directoryId: string, workspaceIds: string[]): Promise<void> => {
|
||||
if (workspaceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.connector.updateMany({
|
||||
where: {
|
||||
feedbackRecordDirectoryId: directoryId,
|
||||
workspaceId: { in: workspaceIds },
|
||||
},
|
||||
data: {
|
||||
status: "paused",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a feedback record directory. Supports partial updates for name, workspace
|
||||
* assignments, and archive status.
|
||||
@@ -291,49 +416,36 @@ const buildWorkspaceAssignmentPayload = async (
|
||||
export const updateFeedbackRecordDirectory = async (
|
||||
directoryId: string,
|
||||
organizationId: string,
|
||||
data: TFeedbackRecordDirectoryUpdateInput
|
||||
data: TFeedbackRecordDirectoryUpdateInput,
|
||||
options?: UpdateFeedbackRecordDirectoryOptions
|
||||
): Promise<boolean> => {
|
||||
validateInputs([directoryId, ZId], [organizationId, ZId], [data, ZFeedbackRecordDirectoryUpdateInput]);
|
||||
|
||||
try {
|
||||
const { name, workspaceIds, isArchived } = data;
|
||||
|
||||
const payload: Prisma.FeedbackRecordDirectoryUpdateInput = {};
|
||||
const archiveUpdate = await getArchiveUpdate(directoryId, isArchived);
|
||||
const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate(
|
||||
directoryId,
|
||||
organizationId,
|
||||
workspaceIds
|
||||
);
|
||||
|
||||
if (name !== undefined) {
|
||||
payload.name = name;
|
||||
}
|
||||
|
||||
if (isArchived === true) {
|
||||
const connectorCount = await prisma.connector.count({
|
||||
where: { feedbackRecordDirectoryId: directoryId },
|
||||
});
|
||||
if (connectorCount > 0) {
|
||||
throw new InvalidInputError("DIRECTORY_HAS_CONNECTORS");
|
||||
}
|
||||
payload.isArchived = true;
|
||||
} else if (isArchived === false) {
|
||||
payload.isArchived = false;
|
||||
}
|
||||
|
||||
if (workspaceIds !== undefined) {
|
||||
const currentDetails = await getFeedbackRecordDirectoryDetails(directoryId);
|
||||
const currentWorkspaceIds = currentDetails?.workspaces.map((p) => p.workspaceId) ?? [];
|
||||
|
||||
payload.workspaces = await buildWorkspaceAssignmentPayload(
|
||||
prisma,
|
||||
directoryId,
|
||||
workspaceIds,
|
||||
organizationId,
|
||||
currentWorkspaceIds
|
||||
);
|
||||
}
|
||||
const payload: Prisma.FeedbackRecordDirectoryUpdateInput = {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...archiveUpdate,
|
||||
...(workspaceAssignmentUpdate.workspaces ? { workspaces: workspaceAssignmentUpdate.workspaces } : {}),
|
||||
};
|
||||
|
||||
await prisma.feedbackRecordDirectory.update({
|
||||
where: { id: directoryId },
|
||||
data: payload,
|
||||
});
|
||||
|
||||
if (options?.pauseConnectorsInRemovedWorkspaces) {
|
||||
await pauseConnectorsInWorkspaces(directoryId, workspaceAssignmentUpdate.removedWorkspaceIds);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -35,6 +35,12 @@ export const ZFeedbackRecordDirectoryDetails = z.object({
|
||||
|
||||
export type TFeedbackRecordDirectoryDetails = z.infer<typeof ZFeedbackRecordDirectoryDetails>;
|
||||
|
||||
export interface TWorkspaceFeedbackRecordDirectoryAccess {
|
||||
workspaceId: string;
|
||||
feedbackRecordDirectoryId: string;
|
||||
feedbackRecordDirectoryName: string;
|
||||
}
|
||||
|
||||
export const ZFeedbackRecordDirectoryCreateInput = z.object({
|
||||
name: z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED"),
|
||||
workspaceIds: z.array(ZId).optional(),
|
||||
|
||||
@@ -3,7 +3,10 @@ export {
|
||||
createFeedbackRecord,
|
||||
createFeedbackRecordsBatch,
|
||||
listFeedbackRecords,
|
||||
retrieveFeedbackRecord,
|
||||
updateFeedbackRecord,
|
||||
type CreateFeedbackRecordResult,
|
||||
type HubFeedbackRecordResult,
|
||||
type ListFeedbackRecordsResult,
|
||||
} from "./service";
|
||||
export type {
|
||||
@@ -11,4 +14,5 @@ export type {
|
||||
FeedbackRecordData,
|
||||
FeedbackRecordListParams,
|
||||
FeedbackRecordListResponse,
|
||||
FeedbackRecordUpdateParams,
|
||||
} from "./types";
|
||||
|
||||
@@ -15,7 +15,7 @@ const { getHubClient } = await import("./hub-client");
|
||||
const sampleInput: FeedbackRecordCreateParams = {
|
||||
field_id: "el-1",
|
||||
field_type: "rating",
|
||||
source_type: "formbricks",
|
||||
source_type: "formbricks_survey",
|
||||
source_id: "survey-1",
|
||||
source_name: "Test Survey",
|
||||
field_label: "Question?",
|
||||
|
||||
@@ -7,12 +7,16 @@ import type {
|
||||
FeedbackRecordData,
|
||||
FeedbackRecordListParams,
|
||||
FeedbackRecordListResponse,
|
||||
FeedbackRecordUpdateParams,
|
||||
} from "./types";
|
||||
|
||||
export type CreateFeedbackRecordResult = {
|
||||
type HubError = { status: number; message: string; detail: string };
|
||||
|
||||
export type HubFeedbackRecordResult = {
|
||||
data: FeedbackRecordData | null;
|
||||
error: { status: number; message: string; detail: string } | null;
|
||||
error: HubError | null;
|
||||
};
|
||||
export type CreateFeedbackRecordResult = HubFeedbackRecordResult;
|
||||
|
||||
const NO_CONFIG_ERROR = {
|
||||
status: 0,
|
||||
@@ -20,7 +24,7 @@ const NO_CONFIG_ERROR = {
|
||||
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
} as const;
|
||||
|
||||
const createResultFromError = (err: unknown): CreateFeedbackRecordResult => {
|
||||
const createResultFromError = (err: unknown): HubFeedbackRecordResult => {
|
||||
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
@@ -32,7 +36,7 @@ const createResultFromError = (err: unknown): CreateFeedbackRecordResult => {
|
||||
*/
|
||||
export const createFeedbackRecord = async (
|
||||
input: FeedbackRecordCreateParams
|
||||
): Promise<CreateFeedbackRecordResult> => {
|
||||
): Promise<HubFeedbackRecordResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
@@ -46,9 +50,48 @@ export const createFeedbackRecord = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve a single feedback record from the Hub by id.
|
||||
*/
|
||||
export const retrieveFeedbackRecord = async (id: string): Promise<HubFeedbackRecordResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await client.feedbackRecords.retrieve(id);
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, id }, "Hub: retrieveFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a single feedback record in the Hub by id.
|
||||
*/
|
||||
export const updateFeedbackRecord = async (
|
||||
id: string,
|
||||
input: FeedbackRecordUpdateParams
|
||||
): Promise<HubFeedbackRecordResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await client.feedbackRecords.update(id, input);
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, id }, "Hub: updateFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
}
|
||||
};
|
||||
|
||||
export type ListFeedbackRecordsResult = {
|
||||
data: FeedbackRecordListResponse | null;
|
||||
error: { status: number; message: string; detail: string } | null;
|
||||
error: HubError | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -78,7 +121,7 @@ export const listFeedbackRecords = async (
|
||||
*/
|
||||
export const createFeedbackRecordsBatch = async (
|
||||
inputs: FeedbackRecordCreateParams[]
|
||||
): Promise<{ results: CreateFeedbackRecordResult[] }> => {
|
||||
): Promise<{ results: HubFeedbackRecordResult[] }> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return {
|
||||
@@ -90,7 +133,7 @@ export const createFeedbackRecordsBatch = async (
|
||||
inputs.map(async (input) => {
|
||||
try {
|
||||
const data = await client.feedbackRecords.create(input);
|
||||
return { data, error: null as CreateFeedbackRecordResult["error"] };
|
||||
return { data, error: null as HubFeedbackRecordResult["error"] };
|
||||
} catch (err) {
|
||||
logger.warn({ err, fieldId: input.field_id }, "Hub: createFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
|
||||
@@ -4,3 +4,4 @@ export type FeedbackRecordCreateParams = FormbricksHub.FeedbackRecordCreateParam
|
||||
export type FeedbackRecordData = FormbricksHub.FeedbackRecordData;
|
||||
export type FeedbackRecordListParams = FormbricksHub.FeedbackRecordListParams;
|
||||
export type FeedbackRecordListResponse = FormbricksHub.FeedbackRecordListResponse;
|
||||
export type FeedbackRecordUpdateParams = FormbricksHub.FeedbackRecordUpdateParams;
|
||||
|
||||
@@ -44,7 +44,7 @@ const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4 bg-w
|
||||
default:
|
||||
"py-3 px-4 text-sm grid grid-cols-[2fr_auto] grid-rows-[auto_auto] gap-y-0.5 gap-x-3 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
|
||||
small:
|
||||
"px-4 py-2 text-xs flex items-center gap-2 [&>svg]:flex-shrink-0 [&_button]:bg-transparent [&_button:hover]:bg-transparent [&>svg~*]:pl-0",
|
||||
"px-4 py-2 text-xs flex items-center gap-2 [&>svg]:flex-shrink-0 [&_button]:bg-transparent [&_button:hover]:bg-transparent [&_a]:bg-transparent [&_a:hover]:bg-transparent [&>svg~*]:pl-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -94,8 +94,8 @@ const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<H
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"col-start-1 row-start-1 font-medium tracking-tight",
|
||||
size === "small" ? "flex-shrink truncate" : "col-start-1 row-start-1",
|
||||
"col-start-1 row-start-1 tracking-tight",
|
||||
size === "small" ? "flex-shrink truncate font-normal" : "col-start-1 row-start-1 font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
@@ -133,6 +133,7 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
// Determine button styling based on alert context
|
||||
const buttonVariant = variant ?? (alertSize === "small" ? "link" : "secondary");
|
||||
const buttonSize = size ?? (alertSize === "small" ? "sm" : "default");
|
||||
const isSmallLinkButton = alertSize === "small" && buttonVariant === "link";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -142,7 +143,16 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
? "-my-2 -mr-4 ml-auto flex-shrink-0"
|
||||
: "col-start-2 row-span-2 row-start-1 flex items-center justify-center"
|
||||
)}>
|
||||
<Button ref={ref} variant={buttonVariant} size={buttonSize} className={className} {...props}>
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={buttonVariant}
|
||||
size={buttonSize}
|
||||
className={cn(
|
||||
isSmallLinkButton &&
|
||||
"bg-transparent font-normal underline-offset-4 hover:bg-transparent hover:underline",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { cn } from "@/modules/ui/lib/utils";
|
||||
interface TOption<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface MultiSelectProps<T extends string, K extends TOption<T>["value"][]> {
|
||||
@@ -225,17 +226,18 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
||||
{selectableOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
disabled={option.disabled}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (disabled) return;
|
||||
if (disabled || option.disabled) return;
|
||||
isUserInitiatedRef.current = true; // Mark as user-initiated
|
||||
setSelected((prev) => [...prev, option]);
|
||||
setInputValue("");
|
||||
}}
|
||||
className="cursor-pointer">
|
||||
className={option.disabled ? "cursor-not-allowed" : "cursor-pointer"}>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components
|
||||
|
||||
interface TSecondaryNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
label: React.ReactNode;
|
||||
href?: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
hidden?: boolean;
|
||||
|
||||
@@ -31,11 +31,22 @@ import {
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
import { getTeamsByOrganizationIdAction } from "@/modules/workspaces/settings/actions";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import {
|
||||
getFeedbackRecordDirectoriesByOrganizationIdAction,
|
||||
getTeamsByOrganizationIdAction,
|
||||
} from "@/modules/workspaces/settings/actions";
|
||||
|
||||
const ZCreateWorkspaceForm = z.object({
|
||||
name: ZWorkspace.shape.name,
|
||||
teamIds: z.array(z.string()).optional(),
|
||||
feedbackRecordDirectoryId: z.string().optional(),
|
||||
});
|
||||
|
||||
type TCreateWorkspaceForm = z.infer<typeof ZCreateWorkspaceForm>;
|
||||
@@ -57,27 +68,51 @@ export const CreateWorkspaceModal = ({
|
||||
const router = useRouter();
|
||||
|
||||
const [organizationTeams, setOrganizationTeams] = useState<TOrganizationTeam[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrganizationTeams = async () => {
|
||||
const response = await getTeamsByOrganizationIdAction({ organizationId });
|
||||
if (response?.data) {
|
||||
setOrganizationTeams(response.data);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
fetchOrganizationTeams();
|
||||
}, [organizationId]);
|
||||
const [feedbackDirectories, setFeedbackDirectories] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
const form = useForm<TCreateWorkspaceForm>({
|
||||
resolver: zodResolver(ZCreateWorkspaceForm),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
teamIds: [],
|
||||
feedbackRecordDirectoryId: undefined,
|
||||
},
|
||||
});
|
||||
const { getValues, setValue } = form;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchModalData = async () => {
|
||||
const [teamsResponse, directoriesResponse] = await Promise.all([
|
||||
getTeamsByOrganizationIdAction({ organizationId }),
|
||||
getFeedbackRecordDirectoriesByOrganizationIdAction({ organizationId }),
|
||||
]);
|
||||
|
||||
if (teamsResponse?.data) {
|
||||
setOrganizationTeams(teamsResponse.data);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(teamsResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
if (directoriesResponse?.data) {
|
||||
setFeedbackDirectories(directoriesResponse.data);
|
||||
const selectedFeedbackDirectory = getValues("feedbackRecordDirectoryId");
|
||||
const isSelectedDirectoryAvailable = directoriesResponse.data.some(
|
||||
(directory) => directory.id === selectedFeedbackDirectory
|
||||
);
|
||||
|
||||
if (directoriesResponse.data.length === 0) {
|
||||
setValue("feedbackRecordDirectoryId", undefined);
|
||||
} else if (!selectedFeedbackDirectory || !isSelectedDirectoryAvailable) {
|
||||
setValue("feedbackRecordDirectoryId", directoriesResponse.data[0].id);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(directoriesResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
fetchModalData();
|
||||
}, [organizationId, getValues, setValue]);
|
||||
|
||||
const { isSubmitting } = form.formState;
|
||||
|
||||
@@ -92,6 +127,7 @@ export const CreateWorkspaceModal = ({
|
||||
data: {
|
||||
name: data.name,
|
||||
teamIds: data.teamIds || [],
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -138,6 +174,40 @@ export const CreateWorkspaceModal = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="feedbackRecordDirectoryId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
disabled={feedbackDirectories.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
feedbackDirectories.length > 0
|
||||
? t("workspace.unify.select_feedback_record_directory")
|
||||
: t("workspace.unify.no_feedback_record_directory_available")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{feedbackDirectories.map((directory) => (
|
||||
<SelectItem key={directory.id} value={directory.id}>
|
||||
{directory.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAccessControlAllowed && organizationTeams.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { getWorkspace } from "@/lib/workspace/service";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getFeedbackRecordDirectories } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { updateWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||
|
||||
@@ -96,3 +97,25 @@ export const getTeamsByOrganizationIdAction = authenticatedActionClient
|
||||
const teams = await getTeamsByOrganizationId(parsedInput.organizationId);
|
||||
return teams;
|
||||
});
|
||||
|
||||
const ZGetFeedbackRecordDirectoriesByOrganizationIdAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const getFeedbackRecordDirectoriesByOrganizationIdAction = authenticatedActionClient
|
||||
.inputSchema(ZGetFeedbackRecordDirectoriesByOrganizationIdAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const directories = await getFeedbackRecordDirectories(parsedInput.organizationId);
|
||||
return directories.filter((directory) => !directory.isArchived).map(({ id, name }) => ({ id, name }));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { BlocksIcon, BrushIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react";
|
||||
import {
|
||||
BlocksIcon,
|
||||
BrushIcon,
|
||||
LanguagesIcon,
|
||||
ListChecksIcon,
|
||||
ShapesIcon,
|
||||
TagIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
@@ -39,6 +47,13 @@ export const WorkspaceConfigNavigation = ({ activeId, loading }: WorkspaceConfig
|
||||
href: `${workspaceBasePath}/app-connection`,
|
||||
current: pathname?.includes("/app-connection"),
|
||||
},
|
||||
{
|
||||
id: "feedback-sources",
|
||||
label: t("workspace.unify.feedback_sources"),
|
||||
icon: <ShapesIcon className="h-5 w-5" />,
|
||||
href: `${workspaceBasePath}/feedback-sources`,
|
||||
current: pathname?.includes("/feedback-sources"),
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
feedbackRecordDirectory: {
|
||||
upsert: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
feedbackRecordDirectoryWorkspace: {
|
||||
count: vi.fn(),
|
||||
@@ -136,6 +137,34 @@ describe("workspace lib", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("creates workspace and links selected feedback directory when provided", async () => {
|
||||
const createdWorkspace = { ...baseWorkspace, id: "p-selected" };
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findFirst).mockResolvedValueOnce({
|
||||
id: "frd-selected",
|
||||
} as any);
|
||||
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.create).mockResolvedValueOnce({} as any);
|
||||
|
||||
const result = await createWorkspace("org1", {
|
||||
name: "Workspace with Selected Directory",
|
||||
feedbackRecordDirectoryId: "frd-selected",
|
||||
});
|
||||
|
||||
expect(result).toEqual(createdWorkspace);
|
||||
expect(prisma.feedbackRecordDirectory.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "frd-selected",
|
||||
organizationId: "org1",
|
||||
isArchived: false,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.create).toHaveBeenCalledWith({
|
||||
data: { feedbackRecordDirectoryId: "frd-selected", workspaceId: "p-selected" },
|
||||
});
|
||||
expect(prisma.feedbackRecordDirectory.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips FRD link when default FRD already has links", async () => {
|
||||
const createdWorkspace = { ...baseWorkspace, id: "p4" };
|
||||
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
|
||||
@@ -147,6 +176,19 @@ describe("workspace lib", () => {
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when selected feedback directory is invalid", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findFirst).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
createWorkspace("org1", {
|
||||
name: "Workspace with Invalid Directory",
|
||||
feedbackRecordDirectoryId: "frd-missing",
|
||||
})
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
|
||||
expect(prisma.workspace.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws ValidationError if name is missing", async () => {
|
||||
await expect(createWorkspace("org1", {})).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
@@ -29,6 +29,14 @@ const selectWorkspace = {
|
||||
customHeadScripts: true,
|
||||
};
|
||||
|
||||
type TCreateWorkspaceInput = Partial<TWorkspaceUpdateInput> & {
|
||||
feedbackRecordDirectoryId?: string;
|
||||
};
|
||||
|
||||
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.partial().extend({
|
||||
feedbackRecordDirectoryId: ZId.optional(),
|
||||
});
|
||||
|
||||
export const updateWorkspace = async (
|
||||
workspaceId: string,
|
||||
inputWorkspace: TWorkspaceUpdateInput
|
||||
@@ -56,17 +64,32 @@ export const updateWorkspace = async (
|
||||
|
||||
export const createWorkspace = async (
|
||||
organizationId: string,
|
||||
workspaceInput: Partial<TWorkspaceUpdateInput>
|
||||
workspaceInput: TCreateWorkspaceInput
|
||||
): Promise<TWorkspace> => {
|
||||
validateInputs([organizationId, ZString], [workspaceInput, ZWorkspaceUpdateInput.partial()]);
|
||||
validateInputs([organizationId, ZString], [workspaceInput, ZCreateWorkspaceInput]);
|
||||
|
||||
if (!workspaceInput.name) {
|
||||
throw new ValidationError("Workspace Name is required");
|
||||
}
|
||||
|
||||
const { teamIds, ...data } = workspaceInput;
|
||||
const { teamIds, feedbackRecordDirectoryId, ...data } = workspaceInput;
|
||||
|
||||
try {
|
||||
if (feedbackRecordDirectoryId) {
|
||||
const feedbackDirectory = await prisma.feedbackRecordDirectory.findFirst({
|
||||
where: {
|
||||
id: feedbackRecordDirectoryId,
|
||||
organizationId,
|
||||
isArchived: false,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!feedbackDirectory) {
|
||||
throw new InvalidInputError("FEEDBACK_RECORD_DIRECTORY_NOT_FOUND");
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = await prisma.workspace.create({
|
||||
data: {
|
||||
config: {
|
||||
@@ -89,6 +112,17 @@ export const createWorkspace = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (feedbackRecordDirectoryId) {
|
||||
await prisma.feedbackRecordDirectoryWorkspace.create({
|
||||
data: {
|
||||
feedbackRecordDirectoryId,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
// Ensure default FRD exists + link to first workspace atomically
|
||||
const defaultFrd = await prisma.feedbackRecordDirectory.upsert({
|
||||
where: {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { ConnectorsSection } from "@/app/(app)/workspaces/[workspaceId]/unify/sources/components/connectors-page-client";
|
||||
import { transformToUnifySurvey } from "@/app/(app)/workspaces/[workspaceId]/unify/sources/lib";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
export const WorkspaceSourcesPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
|
||||
await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
|
||||
if (!hasAccess) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const [connectors, surveys, directories] = await Promise.all([
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
getSurveys(params.workspaceId),
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
]);
|
||||
|
||||
const unifySurveys = surveys.map(transformToUnifySurvey);
|
||||
|
||||
return (
|
||||
<ConnectorsSection
|
||||
workspaceId={params.workspaceId}
|
||||
initialConnectors={connectors}
|
||||
initialSurveys={unifySurveys}
|
||||
directories={directories}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."ConnectorType" RENAME VALUE 'formbricks' TO 'formbricks_survey';
|
||||
@@ -1170,7 +1170,7 @@ model DashboardWidget {
|
||||
}
|
||||
|
||||
enum ConnectorType {
|
||||
formbricks
|
||||
formbricks_survey
|
||||
csv
|
||||
}
|
||||
|
||||
@@ -1197,7 +1197,7 @@ enum HubFieldType {
|
||||
///
|
||||
/// @property id - Unique identifier for the connector
|
||||
/// @property name - Display name for the connector
|
||||
/// @property type - Type of connector (formbricks, webhook, csv, email, slack)
|
||||
/// @property type - Type of connector (formbricks_survey, webhook, csv, email, slack)
|
||||
/// @property status - Current state of the connector (active, paused)
|
||||
/// @property environment - The environment this connector belongs to
|
||||
/// @property config - Type-specific configuration (e.g., webhook secret, S3 config)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
import { TSurveyElementTypeEnum } from "./surveys/constants";
|
||||
|
||||
// Connector type enum
|
||||
export const ZConnectorType = z.enum(["formbricks", "csv"]);
|
||||
export const ZConnectorType = z.enum(["formbricks_survey", "csv"]);
|
||||
export type TConnectorType = z.infer<typeof ZConnectorType>;
|
||||
|
||||
// Connector status enum
|
||||
@@ -139,6 +139,8 @@ export const UNSUPPORTED_CONNECTOR_ELEMENT_TYPES: readonly TSurveyElementTypeEnu
|
||||
export const ELEMENT_TYPE_TO_HUB_FIELD_TYPE: Record<string, THubFieldType> = {
|
||||
openText: "text",
|
||||
nps: "nps",
|
||||
csat: "csat",
|
||||
ces: "ces",
|
||||
rating: "rating",
|
||||
multipleChoiceSingle: "categorical",
|
||||
multipleChoiceMulti: "categorical",
|
||||
|
||||
Reference in New Issue
Block a user