mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 11:29:31 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d44e18a61 |
@@ -278,23 +278,5 @@ REDIS_URL=redis://localhost:6379
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
|
||||
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
|
||||
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
|
||||
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
|
||||
# CUBEJS_API_SECRET=
|
||||
# URL where the Cube.js instance is running
|
||||
# CUBEJS_API_URL=http://localhost:4000
|
||||
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
|
||||
# CUBEJS_API_TOKEN=
|
||||
#
|
||||
# Cube connects to the Hub DB. With docker-compose.dev.yml defaults, use the local postgres service.
|
||||
# CUBEJS_DB_HOST=postgres
|
||||
# CUBEJS_DB_PORT=5432
|
||||
# CUBEJS_DB_NAME=postgres
|
||||
# CUBEJS_DB_USER=postgres
|
||||
# CUBEJS_DB_PASS=postgres
|
||||
#
|
||||
# Alternative (external Hub/Postgres on the hub network): formbricks_hub_postgres, db: hub, user/pass: formbricks/formbricks_dev
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGO_API_KEY=your_api_key_here
|
||||
|
||||
@@ -32,7 +32,6 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
|
||||
|
||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||
We are using SonarQube to identify code smells and security hotspots.
|
||||
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
|
||||
|
||||
## Architecture & Patterns
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
|
||||
|
||||
const ChartsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const { workspaceId } = await props.params;
|
||||
return <ChartsListPage workspaceId={workspaceId} />;
|
||||
};
|
||||
|
||||
export default ChartsPage;
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
|
||||
|
||||
const Page = (props: { params: Promise<{ workspaceId: string; dashboardId: string }> }) => {
|
||||
return <DashboardDetailPage params={props.params} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
|
||||
|
||||
const DashboardsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const { workspaceId } = await props.params;
|
||||
return <DashboardsListPage workspaceId={workspaceId} />;
|
||||
};
|
||||
|
||||
export default DashboardsPage;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { AnalysisListLoading } from "@/modules/ee/analysis/loading";
|
||||
|
||||
export default AnalysisListLoading;
|
||||
@@ -1 +0,0 @@
|
||||
export { WorkspaceSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
|
||||
@@ -23,13 +23,9 @@ 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: ZCreateWorkspaceInput,
|
||||
data: ZWorkspaceUpdateInput,
|
||||
});
|
||||
|
||||
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
|
||||
@@ -44,7 +40,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZCreateWorkspaceInput,
|
||||
schema: ZWorkspaceUpdateInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BarChart3Icon,
|
||||
Building2Icon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
@@ -10,12 +9,12 @@ import {
|
||||
Loader2,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
MessageSquareTextIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
SettingsIcon,
|
||||
Shapes,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
@@ -146,77 +145,50 @@ export const MainNavigation = ({
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const mainNavigationSections = useMemo(
|
||||
const mainNavigation = useMemo(
|
||||
() => [
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
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"),
|
||||
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,
|
||||
},
|
||||
],
|
||||
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,
|
||||
},
|
||||
],
|
||||
[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"),
|
||||
@@ -275,11 +247,6 @@ 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"),
|
||||
@@ -331,6 +298,12 @@ export const MainNavigation = ({
|
||||
href: `/workspaces/${workspace.id}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("workspace.settings.feedback_record_directories.title"),
|
||||
href: `/workspaces/${workspace.id}/settings/feedback-record-directories`,
|
||||
hidden: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
@@ -561,50 +534,23 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
|
||||
{/* Main Nav Switch */}
|
||||
<ul className="space-y-2">
|
||||
{mainNavigationSections.map((section) => (
|
||||
<li key={section.id}>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<p className="px-4 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
{section.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul>
|
||||
{section.items.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
|
||||
<NavigationLink
|
||||
href={configurationNavigationItem.href}
|
||||
isActive={configurationNavigationItem.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={configurationNavigationItem.disabled}
|
||||
disabledMessage={
|
||||
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
|
||||
}
|
||||
linkText={configurationNavigationItem.name}>
|
||||
<configurationNavigationItem.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
</li>
|
||||
<ul>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ export const OrganizationBreadcrumb = ({
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("workspace.settings.feedback_record_directories.nav_label"),
|
||||
label: t("workspace.settings.feedback_record_directories.title"),
|
||||
href: `${workspaceBasePath}/settings/feedback-record-directories`,
|
||||
hidden: isMember,
|
||||
},
|
||||
|
||||
@@ -118,11 +118,6 @@ 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,7 +21,6 @@ export const SettingsCard = ({
|
||||
beta,
|
||||
className,
|
||||
buttonInfo,
|
||||
cta,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -31,7 +30,6 @@ export const SettingsCard = ({
|
||||
beta?: boolean;
|
||||
className?: string;
|
||||
buttonInfo?: ButtonInfo;
|
||||
cta?: React.ReactNode;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -54,12 +52,11 @@ export const SettingsCard = ({
|
||||
{description}
|
||||
</Small>
|
||||
</div>
|
||||
{cta ??
|
||||
(buttonInfo && (
|
||||
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
|
||||
{buttonInfo?.text}
|
||||
</Button>
|
||||
))}
|
||||
{buttonInfo && (
|
||||
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
|
||||
{buttonInfo?.text}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -287,7 +287,7 @@ export const ElementFilterComboBox = ({
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none animate-in">
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
|
||||
<CommandList className="max-h-52">
|
||||
<CommandInput
|
||||
value={searchQuery}
|
||||
|
||||
+1
-1
@@ -232,7 +232,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none animate-in">
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
|
||||
<CommandList className="max-h-[600px]">
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
|
||||
+2
-12
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface UnifyConfigNavigationProps {
|
||||
@@ -18,24 +17,15 @@ export const UnifyConfigNavigation = ({
|
||||
const { t } = useTranslation();
|
||||
const baseHref = `/workspaces/${workspaceId}/unify`;
|
||||
|
||||
const activeId = activeIdProp ?? "feedback-records";
|
||||
const activeId = activeIdProp ?? "sources";
|
||||
|
||||
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} />;
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
"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
@@ -1,988 +0,0 @@
|
||||
"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[];
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
initialNextCursor?: string;
|
||||
}
|
||||
|
||||
export function FeedbackRecordsPageClient({
|
||||
workspaceId,
|
||||
directories,
|
||||
initialFrdId,
|
||||
initialRecords,
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsPageClientProps>) {
|
||||
initialNextCursor,
|
||||
}: FeedbackRecordsPageClientProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
|
||||
<PageHeader pageTitle={t("workspace.unify.unify_feedback")}>
|
||||
<UnifyConfigNavigation workspaceId={workspaceId} activeId="feedback-records" />
|
||||
</PageHeader>
|
||||
|
||||
<FeedbackRecordsTable
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
initialFrdId={initialFrdId}
|
||||
initialRecords={initialRecords}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
initialNextCursor={initialNextCursor}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
+145
-231
@@ -3,16 +3,13 @@
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
HashIcon,
|
||||
MessageSquareTextIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ToggleLeftIcon,
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
|
||||
@@ -21,16 +18,15 @@ 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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
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;
|
||||
|
||||
@@ -54,18 +50,6 @@ 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) + "…";
|
||||
@@ -73,76 +57,87 @@ function truncate(str: string, maxLen: number): string {
|
||||
|
||||
interface FeedbackRecordsTableProps {
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
initialFrdId: string | null;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
initialNextCursor?: string;
|
||||
}
|
||||
|
||||
export const FeedbackRecordsTable = ({
|
||||
workspaceId,
|
||||
directories,
|
||||
initialFrdId,
|
||||
initialRecords,
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsTableProps>) => {
|
||||
initialNextCursor,
|
||||
}: FeedbackRecordsTableProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const [selectedFrdId, setSelectedFrdId] = useState<string | null>(initialFrdId);
|
||||
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(initialNextCursor);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit");
|
||||
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const directories = useMemo(
|
||||
() =>
|
||||
Object.entries(frdMap)
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[frdMap]
|
||||
const fetchRecords = useCallback(
|
||||
async (frdId: string, cursor: string | undefined, append: boolean) => {
|
||||
const setLoading = append ? setIsLoadingMore : setIsRefreshing;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await listFeedbackRecordsAction({
|
||||
workspaceId,
|
||||
frdId,
|
||||
limit: RECORDS_PER_PAGE,
|
||||
cursor,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
setError(getFormattedErrorMessage(result) ?? t("workspace.unify.failed_to_load_feedback_records"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = result.data;
|
||||
setRecords((prev) => (append ? [...prev, ...response.data] : response.data));
|
||||
setNextCursor(response.next_cursor);
|
||||
setLoading(false);
|
||||
},
|
||||
[workspaceId, t]
|
||||
);
|
||||
const feedbackDirectoryName = useMemo(() => {
|
||||
const directoryNames = Array.from(
|
||||
new Set(
|
||||
records
|
||||
.map((record) => frdMap[record.tenant_id])
|
||||
.filter((directoryName): directoryName is string => Boolean(directoryName))
|
||||
)
|
||||
);
|
||||
|
||||
if (directoryNames.length > 0) {
|
||||
return directoryNames.join(", ");
|
||||
}
|
||||
const handleFrdChange = (frdId: string) => {
|
||||
setSelectedFrdId(frdId);
|
||||
fetchRecords(frdId, undefined, false);
|
||||
};
|
||||
|
||||
return directories[0]?.name ?? "—";
|
||||
}, [directories, frdMap, records]);
|
||||
const handleLoadMore = () => {
|
||||
if (!selectedFrdId) return;
|
||||
fetchRecords(selectedFrdId, nextCursor, true);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
|
||||
if (!selectedFrdId || isRefreshing) return;
|
||||
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
|
||||
|
||||
const result = await listFeedbackRecordsAction({
|
||||
workspaceId,
|
||||
limit: RECORDS_PER_PAGE,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result) ?? t("workspace.unify.failed_to_load_feedback_records"), {
|
||||
id: toastId,
|
||||
});
|
||||
setIsRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecords(result.data.data);
|
||||
setIsRefreshing(false);
|
||||
await fetchRecords(selectedFrdId, undefined, 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">
|
||||
@@ -157,195 +152,114 @@ export const FeedbackRecordsTable = ({
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = records.length === 0 && !isRefreshing;
|
||||
|
||||
const openEditDrawer = (recordId: string) => {
|
||||
setDrawerMode("edit");
|
||||
setDrawerRecordId(recordId);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
setDrawerMode("create");
|
||||
setDrawerRecordId(undefined);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const hasCsvSources = csvSources.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{isEmpty ? (
|
||||
<span />
|
||||
<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>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.showing_count_loaded", {
|
||||
count: records.length,
|
||||
directoryName: feedbackDirectoryName,
|
||||
})}
|
||||
</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 className="flex items-center gap-2">
|
||||
{canWrite &&
|
||||
(hasCsvSources ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="secondary">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={openCreateDrawer}>
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{csvSources.map((source) => (
|
||||
<DropdownMenuItem
|
||||
key={source.id}
|
||||
onClick={() => {
|
||||
setCsvImportSource(source);
|
||||
}}>
|
||||
{t("workspace.unify.import_via_source_name", { sourceName: source.name })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={openCreateDrawer}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
</Button>
|
||||
))}
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
|
||||
{t("workspace.unify.manage_feedback_sources")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t("workspace.unify.refresh_feedback_records")}>
|
||||
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{records.map((record) => (
|
||||
<FeedbackRecordRow
|
||||
key={record.id}
|
||||
record={record}
|
||||
workspaceId={workspaceId}
|
||||
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
|
||||
t={t}
|
||||
onClick={() => openEditDrawer(record.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!isEmpty && (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.showing_count_loaded", { count: records.length })}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t("workspace.unify.refresh_feedback_records")}>
|
||||
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FeedbackRecordFormDrawer
|
||||
mode={drawerMode}
|
||||
open={isDrawerOpen}
|
||||
onOpenChange={setIsDrawerOpen}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
canWrite={canWrite}
|
||||
recordId={drawerMode === "edit" ? drawerRecordId : undefined}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{records.map((record) => (
|
||||
<FeedbackRecordRow key={record.id} record={record} locale={locale} t={t} />
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{csvImportSource && (
|
||||
<CsvImportModal
|
||||
open={csvImportSource !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCsvImportSource(null);
|
||||
}
|
||||
}}
|
||||
connectorId={csvImportSource.id}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="secondary" size="sm" onClick={handleLoadMore} loading={isLoadingMore}>
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeedbackRecordRow = ({
|
||||
record,
|
||||
workspaceId,
|
||||
locale,
|
||||
t,
|
||||
onClick,
|
||||
}: {
|
||||
record: FeedbackRecordData;
|
||||
workspaceId: string;
|
||||
locale: string;
|
||||
t: TFunction;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const value = formatValue(record, t, locale);
|
||||
const isLongValue = value.length > 60;
|
||||
const isFormbricksSurveySource =
|
||||
(record.source_type === "formbricks" || record.source_type === "formbricks_survey") && !!record.source_id;
|
||||
const surveySummaryHref = `/workspaces/${workspaceId}/surveys/${record.source_id}/summary`;
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="cursor-pointer text-sm text-slate-700 transition-colors hover:bg-slate-50"
|
||||
onClick={onClick}>
|
||||
<tr className="text-sm text-slate-700 transition-colors hover:bg-slate-50">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
|
||||
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<Badge text={formatSourceType(record.source_type, t)} type="gray" size="tiny" />
|
||||
<Badge text={record.source_type} type="gray" size="tiny" />
|
||||
</td>
|
||||
<td className="max-w-[150px] truncate px-4 py-3" title={record.source_name ?? undefined}>
|
||||
{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>
|
||||
)}
|
||||
{record.source_name ?? "—"}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate px-4 py-3" title={record.field_label ?? undefined}>
|
||||
{record.field_label ?? record.field_id}
|
||||
|
||||
@@ -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 = 50;
|
||||
const INITIAL_PAGE_SIZE = 10;
|
||||
|
||||
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,40 +22,31 @@ export default async function UnifyFeedbackRecordsPage(
|
||||
}
|
||||
|
||||
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
|
||||
const canWrite = isOwner || isManager || hasReadWriteAccess || hasManageAccess;
|
||||
if (!hasAccess) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const [frds, connectors] = await Promise.all([
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
]);
|
||||
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId);
|
||||
|
||||
const results = await Promise.all(
|
||||
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
|
||||
);
|
||||
// Preload first FRD's records server-side for fast initial render
|
||||
const initialFrdId = frds[0]?.id;
|
||||
let initialRecords: FeedbackRecordListResponse | null = null;
|
||||
|
||||
// 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 }));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FeedbackRecordsPageClient
|
||||
workspaceId={params.workspaceId}
|
||||
initialRecords={merged}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
directories={frds}
|
||||
initialFrdId={initialFrdId ?? null}
|
||||
initialRecords={initialRecords?.data ?? []}
|
||||
initialNextCursor={initialRecords?.next_cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/feedback-records`);
|
||||
redirect(`/workspaces/${params.workspaceId}/unify/sources`);
|
||||
}
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
"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
@@ -1,21 +0,0 @@
|
||||
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,7 +2,6 @@
|
||||
|
||||
import {
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
FileSpreadsheetIcon,
|
||||
MoreVertical,
|
||||
PauseIcon,
|
||||
@@ -10,7 +9,6 @@ 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";
|
||||
@@ -41,15 +39,12 @@ 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);
|
||||
@@ -94,25 +89,6 @@ 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"
|
||||
|
||||
+18
-44
@@ -1,82 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { TConnectorType } from "@formbricks/types/connector";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { TConnectorOptionId, getConnectorOptions } from "../utils";
|
||||
import { getConnectorOptions } from "../utils";
|
||||
|
||||
interface ConnectorTypeSelectorProps {
|
||||
selectedType: TConnectorOptionId | null;
|
||||
onSelectType: (type: TConnectorOptionId) => void;
|
||||
selectedType: TConnectorType | null;
|
||||
onSelectType: (type: TConnectorType) => void;
|
||||
}
|
||||
|
||||
const getOptionClassName = (
|
||||
selectedType: TConnectorOptionId | null,
|
||||
optionId: TConnectorOptionId,
|
||||
disabled: boolean
|
||||
): string => {
|
||||
if (selectedType === optionId) {
|
||||
return "border-brand-dark bg-slate-50";
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60";
|
||||
}
|
||||
|
||||
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
|
||||
};
|
||||
|
||||
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
|
||||
export function ConnectorTypeSelector({ selectedType, onSelectType }: 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)}
|
||||
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
|
||||
)}`}>
|
||||
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"
|
||||
}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
|
||||
<span className="font-medium text-slate-900">{option.name}</span>
|
||||
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`ml-3 h-4 w-4 rounded-full border-2 ${
|
||||
className={`ml-4 h-5 w-5 rounded-full border-2 ${
|
||||
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
|
||||
}`}>
|
||||
{selectedType === option.id && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-white" />
|
||||
<div className="h-2 w-2 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>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-34
@@ -1,12 +1,10 @@
|
||||
"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,
|
||||
@@ -14,10 +12,9 @@ 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 { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
|
||||
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||
import { TFieldMapping, TUnifySurvey } from "../types";
|
||||
import { ConnectorsTable } from "./connectors-table";
|
||||
import { CreateConnectorModal } from "./create-connector-modal";
|
||||
@@ -36,21 +33,12 @@ export function ConnectorsSection({
|
||||
initialConnectors,
|
||||
initialSurveys,
|
||||
directories,
|
||||
}: Readonly<ConnectorsSectionProps>) {
|
||||
}: 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;
|
||||
@@ -67,9 +55,9 @@ export function ConnectorsSection({
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
},
|
||||
formbricksMappings:
|
||||
data.type === "formbricks_survey" && data.surveyMappings?.length ? data.surveyMappings : undefined,
|
||||
data.type === "formbricks" && data.surveyMappings?.length ? data.surveyMappings : undefined,
|
||||
fieldMappings:
|
||||
data.type !== "formbricks_survey" && data.fieldMappings?.length
|
||||
data.type !== "formbricks" && data.fieldMappings?.length
|
||||
? data.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId || "",
|
||||
targetFieldId: m.targetFieldId as THubTargetField,
|
||||
@@ -166,13 +154,8 @@ export function ConnectorsSection({
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<WorkspaceConfigNavigation activeId="feedback-sources" />
|
||||
</PageHeader>
|
||||
|
||||
<SettingsCard
|
||||
title={t("workspace.unify.feedback_sources")}
|
||||
description={t("workspace.unify.feedback_sources_settings_description")}
|
||||
<PageHeader
|
||||
pageTitle={t("workspace.unify.unify_feedback")}
|
||||
cta={
|
||||
<CreateConnectorModal
|
||||
open={isCreateModalOpen}
|
||||
@@ -183,6 +166,10 @@ export function ConnectorsSection({
|
||||
directories={directories}
|
||||
/>
|
||||
}>
|
||||
<UnifyConfigNavigation workspaceId={workspaceId} activeId="sources" />
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<ConnectorsTable
|
||||
connectors={initialConnectors}
|
||||
onConnectorClick={setEditingConnector}
|
||||
@@ -192,17 +179,7 @@ export function ConnectorsSection({
|
||||
onDelete={handleDeleteConnector}
|
||||
isLoading={false}
|
||||
/>
|
||||
{directories.length > 0 && (
|
||||
<Alert size="small" className="mt-4">
|
||||
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</div>
|
||||
|
||||
<EditConnectorModal
|
||||
connector={editingConnector}
|
||||
@@ -210,6 +187,7 @@ export function ConnectorsSection({
|
||||
onOpenChange={(open) => !open && setEditingConnector(null)}
|
||||
onUpdateConnector={handleUpdateConnector}
|
||||
surveys={initialSurveys}
|
||||
directories={directories}
|
||||
onOpenCsvImport={() => {
|
||||
if (editingConnector) {
|
||||
setCsvImportConnector(editingConnector);
|
||||
|
||||
+35
-23
@@ -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,6 +39,17 @@ 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",
|
||||
@@ -52,24 +63,13 @@ export function ConnectorsTableDataRow({
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: Readonly<ConnectorsTableDataRowProps>) {
|
||||
}: ConnectorsTableDataRowProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const handleRowClick = () => {
|
||||
if (connector.type === "csv" && onCsvImport) {
|
||||
onCsvImport();
|
||||
return;
|
||||
}
|
||||
|
||||
onEdit();
|
||||
};
|
||||
|
||||
const getStatusLabel = (s: TConnectorStatus, connectorType: TConnectorType) => {
|
||||
const getStatusLabel = (s: TConnectorStatus) => {
|
||||
switch (s) {
|
||||
case "active":
|
||||
if (connectorType === "csv") {
|
||||
return t("workspace.unify.status_ready");
|
||||
}
|
||||
return t("workspace.unify.status_live_sync");
|
||||
return t("workspace.unify.status_active");
|
||||
case "paused":
|
||||
return t("workspace.unify.status_paused");
|
||||
case "error":
|
||||
@@ -77,32 +77,44 @@ 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={handleRowClick}
|
||||
onClick={onEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleRowClick();
|
||||
onEdit();
|
||||
}
|
||||
}}>
|
||||
<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 className="col-span-1 flex items-center gap-2 pl-4" title={getConnectorTypeLabel(connector.type)}>
|
||||
{getConnectorIcon(connector.type)}
|
||||
</div>
|
||||
<div className="col-span-5 flex items-center">
|
||||
<div className="col-span-3 flex items-center">
|
||||
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
|
||||
</div>
|
||||
<div className="col-span-1 hidden items-center justify-center sm:flex">
|
||||
<Badge
|
||||
text={getStatusLabel(connector.status, connector.type)}
|
||||
text={getStatusLabel(connector.status)}
|
||||
type={STATUS_BADGE_TYPE[connector.status]}
|
||||
size="tiny"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||
{getRelativeTime(connector.createdAt, i18n.language)}
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||
{getRelativeTime(connector.updatedAt, i18n.language)}
|
||||
</div>
|
||||
|
||||
+3
-2
@@ -23,15 +23,16 @@ export function ConnectorsTable({
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
}: Readonly<ConnectorsTableProps>) {
|
||||
}: ConnectorsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">{t("common.type")}</div>
|
||||
<div className="col-span-5">{t("common.name")}</div>
|
||||
<div className="col-span-3">{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" />
|
||||
|
||||
+314
-298
@@ -1,13 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2Icon, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
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,
|
||||
@@ -25,15 +21,8 @@ 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,
|
||||
@@ -41,18 +30,17 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { TCreateConnectorStep, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
|
||||
import {
|
||||
TConnectorOptionId,
|
||||
TEnumValidationError,
|
||||
parseCSVColumnsToFields,
|
||||
validateEnumMappings,
|
||||
} from "../utils";
|
||||
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
TCreateConnectorStep,
|
||||
TFieldMapping,
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
} from "../types";
|
||||
import { TEnumValidationError, parseCSVColumnsToFields, validateEnumMappings } from "../utils";
|
||||
import { ConnectorTypeSelector } from "./connector-type-selector";
|
||||
import { CsvConnectorUI } from "./csv-connector-ui";
|
||||
import { FormbricksQuestionList } from "./formbricks-question-list";
|
||||
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
|
||||
interface CreateConnectorModalProps {
|
||||
open: boolean;
|
||||
@@ -71,47 +59,101 @@ interface CreateConnectorModalProps {
|
||||
|
||||
const getDialogTitle = (
|
||||
step: TCreateConnectorStep,
|
||||
type: TConnectorOptionId | null,
|
||||
type: TConnectorType | null,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (step === "selectType") return t("workspace.unify.add_feedback_source");
|
||||
if (type === "formbricks_survey") return t("workspace.unify.select_survey_and_questions");
|
||||
if (type === "formbricks") return t("workspace.unify.select_survey_and_questions");
|
||||
if (type === "csv") return t("workspace.unify.import_csv_data");
|
||||
return t("workspace.unify.configure_mapping");
|
||||
};
|
||||
|
||||
const getDialogDescription = (
|
||||
step: TCreateConnectorStep,
|
||||
type: TConnectorOptionId | null,
|
||||
type: TConnectorType | null,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (step === "selectType") return t("workspace.unify.select_source_type_description");
|
||||
if (type === "formbricks_survey") return t("workspace.unify.select_survey_questions_description");
|
||||
if (type === "formbricks") return t("workspace.unify.select_survey_questions_description");
|
||||
if (type === "csv") return t("workspace.unify.upload_csv_data_description");
|
||||
return t("workspace.unify.configure_mapping");
|
||||
};
|
||||
|
||||
const getNextStepButtonLabel = (type: TConnectorOptionId | null, t: (key: string) => string): string => {
|
||||
if (type === "formbricks_survey") return t("workspace.unify.select_questions");
|
||||
const getNextStepButtonLabel = (type: TConnectorType | null, t: (key: string) => string): string => {
|
||||
if (type === "formbricks") return t("workspace.unify.select_questions");
|
||||
if (type === "csv") return t("workspace.unify.configure_import");
|
||||
if (type === "api_ingestion") return t("workspace.unify.api_ingestion_manage_api_keys");
|
||||
if (type === "feedback_record_mcp") return t("common.learn_more");
|
||||
return t("workspace.unify.create_mapping");
|
||||
};
|
||||
|
||||
const ZFormbricksConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
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;
|
||||
};
|
||||
|
||||
type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
|
||||
interface AggregateImportSectionProps {
|
||||
surveyEntries: {
|
||||
surveyId: string;
|
||||
surveyName: string;
|
||||
responseCount: number;
|
||||
elementCount: number;
|
||||
importHistorical: boolean;
|
||||
}[];
|
||||
onImportHistoricalChange: (surveyId: string, checked: boolean) => void;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
const getSelectableQuestionIds = (survey: TUnifySurvey): string[] =>
|
||||
survey.elements
|
||||
.filter((element) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(element.type))
|
||||
.map((element) => element.id);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export const CreateConnectorModal = ({
|
||||
open,
|
||||
@@ -122,53 +164,34 @@ export const CreateConnectorModal = ({
|
||||
directories,
|
||||
}: CreateConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const defaultConnectorName = useMemo<Record<TConnectorType, string>>(
|
||||
() => ({
|
||||
formbricks_survey: t("workspace.unify.default_connector_name_formbricks"),
|
||||
csv: t("workspace.unify.default_connector_name_csv"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formbricksForm = useForm<TFormbricksConnectorForm>({
|
||||
resolver: zodResolver(ZFormbricksConnectorForm),
|
||||
defaultValues: {
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const defaultConnectorName: Record<TConnectorType, string> = {
|
||||
formbricks: t("workspace.unify.default_connector_name_formbricks"),
|
||||
csv: t("workspace.unify.default_connector_name_csv"),
|
||||
};
|
||||
const [currentStep, setCurrentStep] = useState<TCreateConnectorStep>("selectType");
|
||||
const [selectedType, setSelectedType] = useState<TConnectorOptionId | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<TConnectorType | null>(null);
|
||||
const [connectorName, setConnectorName] = useState("");
|
||||
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 [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
|
||||
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
|
||||
|
||||
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[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 [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(
|
||||
directories.length === 1 ? directories[0].id : null
|
||||
);
|
||||
|
||||
const selectedSurveyResponseCount =
|
||||
selectedSurveyId && responseCountBySurvey[selectedSurveyId] !== undefined
|
||||
? responseCountBySurvey[selectedSurveyId]
|
||||
: null;
|
||||
|
||||
const fetchResponseCount = useCallback(
|
||||
async (surveyId: string) => {
|
||||
if (responseCountBySurvey[surveyId] !== undefined) return;
|
||||
@@ -181,50 +204,30 @@ export const CreateConnectorModal = ({
|
||||
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: null }));
|
||||
}
|
||||
},
|
||||
[responseCountBySurvey, workspaceId]
|
||||
[workspaceId, responseCountBySurvey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSurveyId && currentStep === "mapping" && selectedType === "formbricks_survey") {
|
||||
if (selectedSurveyId && selectedType === "formbricks") {
|
||||
fetchResponseCount(selectedSurveyId);
|
||||
}
|
||||
}, [currentStep, fetchResponseCount, selectedSurveyId, selectedType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep !== "mapping" || selectedType !== "formbricks_survey" || !selectedSurveyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const survey = surveys.find((item) => item.id === selectedSurveyId);
|
||||
const supportedElementIds = survey ? getSelectableQuestionIds(survey) : [];
|
||||
|
||||
formbricksForm.setValue("selectedQuestionIds", supportedElementIds, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
formbricksForm.setValue("importHistorical", true, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}, [currentStep, formbricksForm, selectedSurveyId, selectedType, surveys]);
|
||||
}, [selectedSurveyId, selectedType, fetchResponseCount]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCurrentStep("selectType");
|
||||
setSelectedType(null);
|
||||
formbricksForm.reset({
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
setConnectorName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setCsvParsedData([]);
|
||||
setEnumValidationErrors([]);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
setResponseCountBySurvey({});
|
||||
setCsvConnectorName("");
|
||||
setImportHistoricalBySurvey({});
|
||||
setIsImporting(false);
|
||||
setIsCreating(false);
|
||||
setSelectedDirectoryId(directories[0]?.id ?? null);
|
||||
setSelectedDirectoryId(directories.length === 1 ? directories[0].id : null);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
@@ -236,63 +239,103 @@ export const CreateConnectorModal = ({
|
||||
const handleNextStep = () => {
|
||||
if (currentStep !== "selectType" || !selectedType) return;
|
||||
|
||||
if (selectedType === "api_ingestion") {
|
||||
handleOpenChange(false);
|
||||
router.push(`/workspaces/${workspaceId}/settings/api-keys`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedType === "feedback_record_mcp") {
|
||||
window.open("https://formbricks.com/docs", "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedType === "formbricks_survey") {
|
||||
formbricksForm.reset({
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedType === "csv") {
|
||||
setCsvConnectorName(defaultConnectorName.csv);
|
||||
}
|
||||
|
||||
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),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselectAllElements = () => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[selectedSurveyId]: [],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep === "mapping") {
|
||||
setCurrentStep("selectType");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setEnumValidationErrors([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHistoricalImport = async (connectorId: string, surveyId: string) => {
|
||||
const responseCount = responseCountBySurvey[surveyId] ?? 0;
|
||||
if (responseCount <= 0) return;
|
||||
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;
|
||||
|
||||
setIsImporting(true);
|
||||
const importResult = await importHistoricalResponsesAction({
|
||||
connectorId,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
});
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
setIsImporting(false);
|
||||
|
||||
if (importResult?.data) {
|
||||
if (totalSuccesses > 0 || totalFailures > 0) {
|
||||
toast.success(
|
||||
t("workspace.unify.historical_import_complete", {
|
||||
successes: importResult.data.successes,
|
||||
failures: importResult.data.failures,
|
||||
skipped: importResult.data.skipped,
|
||||
successes: totalSuccesses,
|
||||
failures: totalFailures,
|
||||
skipped: totalSkipped,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(importResult));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -318,41 +361,10 @@ export const CreateConnectorModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
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 handleCreate = async () => {
|
||||
if (!selectedType || !connectorName.trim() || !selectedDirectoryId) return;
|
||||
|
||||
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) {
|
||||
if (selectedType === "csv" && csvParsedData.length > 0) {
|
||||
const errors = validateEnumMappings(mappings, csvParsedData);
|
||||
if (errors.length > 0) {
|
||||
setEnumValidationErrors(errors);
|
||||
@@ -363,14 +375,21 @@ export const CreateConnectorModal = ({
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
const surveyMappings = getSurveyMappings();
|
||||
|
||||
const connectorId = await onCreateConnector({
|
||||
name: csvConnectorName.trim(),
|
||||
type: "csv",
|
||||
name: connectorName.trim(),
|
||||
type: selectedType,
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
surveyMappings: selectedType === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
|
||||
fieldMappings: selectedType !== "formbricks" && mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
|
||||
if (connectorId && csvParsedData.length > 0) {
|
||||
if (connectorId && selectedType === "formbricks") {
|
||||
await handleHistoricalImports(connectorId);
|
||||
}
|
||||
|
||||
if (connectorId && selectedType === "csv" && csvParsedData.length > 0) {
|
||||
await handleCsvImport(connectorId);
|
||||
}
|
||||
|
||||
@@ -379,8 +398,14 @@ 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") {
|
||||
@@ -419,118 +444,86 @@ export const CreateConnectorModal = ({
|
||||
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
{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")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{directories.length === 0 && (
|
||||
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
|
||||
)}
|
||||
<FrdPicker
|
||||
directories={directories}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onChange={setSelectedDirectoryId}
|
||||
workspaceId={workspaceId}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("workspace.unify.select_survey")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((survey) => (
|
||||
<SelectItem key={survey.id} value={survey.id}>
|
||||
{survey.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
{(() => {
|
||||
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);
|
||||
|
||||
{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>
|
||||
)}
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<AggregateImportSection
|
||||
surveyEntries={entries}
|
||||
onImportHistoricalChange={(surveyId, checked) => {
|
||||
setImportHistoricalBySurvey((prev) => ({ ...prev, [surveyId]: checked }));
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "csv" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="connectorName" className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.source_name")}
|
||||
</label>
|
||||
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
|
||||
<Input
|
||||
id="connectorName"
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
value={connectorName}
|
||||
onChange={(e) => setConnectorName(e.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{directories.length === 0 && (
|
||||
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
|
||||
)}
|
||||
<FrdPicker
|
||||
directories={directories}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onChange={setSelectedDirectoryId}
|
||||
workspaceId={workspaceId}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<CsvConnectorUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
@@ -589,20 +582,13 @@ export const CreateConnectorModal = ({
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={
|
||||
selectedType === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleCreateFormbricksConnector)()
|
||||
: handleCreateCsvConnector
|
||||
}
|
||||
onClick={handleCreate}
|
||||
disabled={
|
||||
isCreating ||
|
||||
isImporting ||
|
||||
!connectorName.trim() ||
|
||||
!selectedDirectoryId ||
|
||||
(selectedType === "formbricks_survey"
|
||||
? !isConnectorNameValid(formbricksValues.sourceName ?? "") ||
|
||||
!formbricksValues.surveyId ||
|
||||
!formbricksValues.selectedQuestionIds?.length
|
||||
: !isConnectorNameValid(csvConnectorName) || !isCsvValid || !areCsvRequiredFieldsMapped)
|
||||
getCreateDisabled(selectedType, !!isFormbricksValid, isCsvValid, allRequiredMapped)
|
||||
}>
|
||||
{isCreating && <Loader2Icon className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("workspace.unify.setup_connection")}
|
||||
@@ -615,22 +601,52 @@ export const CreateConnectorModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface NoFeedbackRecordDirectoryAlertProps {
|
||||
interface FrdPickerProps {
|
||||
directories: { id: string; name: string }[];
|
||||
selectedDirectoryId: string | null;
|
||||
onChange: (id: string) => void;
|
||||
workspaceId: string;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const NoFeedbackRecordDirectoryAlert = ({ workspaceId, t }: NoFeedbackRecordDirectoryAlertProps) => {
|
||||
return (
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
|
||||
<a
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.go_to_feedback_record_directories")}
|
||||
</a>
|
||||
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>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
+165
-235
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,27 +13,17 @@ 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,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
SAMPLE_CSV_COLUMNS,
|
||||
TFieldMapping,
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
} from "../types";
|
||||
import { parseCSVColumnsToFields } from "../utils";
|
||||
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
|
||||
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
|
||||
import { FormbricksQuestionList } from "./formbricks-question-list";
|
||||
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
|
||||
interface EditConnectorModalProps {
|
||||
@@ -50,17 +38,42 @@ interface EditConnectorModalProps {
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<void>;
|
||||
surveys: TUnifySurvey[];
|
||||
directories: { id: string; name: string }[];
|
||||
onOpenCsvImport?: () => void;
|
||||
}
|
||||
|
||||
const ZFormbricksEditConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
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" />;
|
||||
}
|
||||
};
|
||||
|
||||
type TFormbricksEditConnectorForm = z.infer<typeof ZFormbricksEditConnectorForm>;
|
||||
const getConnectorTypeLabelKey = (type: TConnectorType): string => {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "workspace.unify.formbricks_surveys";
|
||||
case "csv":
|
||||
return "workspace.unify.csv_import";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const groupMappingsBySurvey = (
|
||||
mappings: { surveyId: string; elementId: string }[]
|
||||
): Record<string, string[]> => {
|
||||
const grouped: Record<string, string[]> = {};
|
||||
for (const m of mappings) {
|
||||
if (!grouped[m.surveyId]) grouped[m.surveyId] = [];
|
||||
grouped[m.surveyId].push(m.elementId);
|
||||
}
|
||||
return grouped;
|
||||
};
|
||||
|
||||
export const EditConnectorModal = ({
|
||||
connector,
|
||||
@@ -68,52 +81,35 @@ export const EditConnectorModal = ({
|
||||
onOpenChange,
|
||||
onUpdateConnector,
|
||||
surveys,
|
||||
directories,
|
||||
onOpenCsvImport,
|
||||
}: EditConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
const [connectorName, setConnectorName] = useState("");
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const formbricksForm = useForm<TFormbricksEditConnectorForm>({
|
||||
resolver: zodResolver(ZFormbricksEditConnectorForm),
|
||||
defaultValues: {
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
|
||||
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
|
||||
|
||||
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 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))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (connector) {
|
||||
if (connector.type === "formbricks_survey") {
|
||||
const mappedSurveyId = connector.formbricksMappings[0]?.surveyId ?? "";
|
||||
const mappedQuestionIds = connector.formbricksMappings
|
||||
.filter((mapping) => mapping.surveyId === mappedSurveyId)
|
||||
.map((mapping) => mapping.elementId);
|
||||
setConnectorName(connector.name);
|
||||
|
||||
formbricksForm.reset({
|
||||
sourceName: connector.name,
|
||||
surveyId: mappedSurveyId,
|
||||
selectedQuestionIds: mappedQuestionIds,
|
||||
importHistorical: true,
|
||||
});
|
||||
setCsvConnectorName("");
|
||||
if (connector.type === "formbricks") {
|
||||
const fbMappings = connector.formbricksMappings;
|
||||
setSelectedSurveyId(fbMappings.length > 0 ? fbMappings[0].surveyId : null);
|
||||
setElementIdsBySurvey(groupMappingsBySurvey(fbMappings));
|
||||
setSourceFields([]);
|
||||
setMappings([]);
|
||||
} else if (connector.type === "csv") {
|
||||
setCsvConnectorName(connector.name);
|
||||
const columnsFromMappings = [
|
||||
...new Set(connector.fieldMappings.map((m) => m.sourceFieldId).filter(Boolean)),
|
||||
];
|
||||
@@ -129,37 +125,23 @@ export const EditConnectorModal = ({
|
||||
staticValue: m.staticValue ?? undefined,
|
||||
}))
|
||||
);
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
} else {
|
||||
setCsvConnectorName("");
|
||||
setSourceFields([]);
|
||||
setMappings([]);
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
}
|
||||
}
|
||||
}, [connector, formbricksForm]);
|
||||
}, [connector]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCsvConnectorName("");
|
||||
setConnectorName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
@@ -169,64 +151,76 @@ export const EditConnectorModal = ({
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const handleUpdateFormbricksConnector = async (values: TFormbricksEditConnectorForm) => {
|
||||
if (connector?.type !== "formbricks_survey") return;
|
||||
setIsUpdating(true);
|
||||
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 }));
|
||||
|
||||
await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: values.sourceName.trim(),
|
||||
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
|
||||
fieldMappings: undefined,
|
||||
name: connectorName.trim(),
|
||||
surveyMappings:
|
||||
connector.type === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
|
||||
fieldMappings: connector.type !== "formbricks" && mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const handleUpdateCsvConnector = async () => {
|
||||
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: csvConnectorName.trim(),
|
||||
surveyMappings: undefined,
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
const assignedDirectoryName =
|
||||
directories.find((d) => d.id === connector?.feedbackRecordDirectoryId)?.name ??
|
||||
connector?.feedbackRecordDirectoryId ??
|
||||
"—";
|
||||
|
||||
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(() => {
|
||||
const saveChangesDisbaled = useMemo(() => {
|
||||
if (!connector) return true;
|
||||
if (isUpdating) return true;
|
||||
if (!connectorName.trim()) return true;
|
||||
|
||||
if (connector.type === "formbricks_survey") {
|
||||
return (
|
||||
!isConnectorNameValid(formbricksValues.sourceName ?? "") ||
|
||||
!formbricksValues.surveyId ||
|
||||
!formbricksValues.selectedQuestionIds?.length
|
||||
);
|
||||
if (connector.type === "formbricks") {
|
||||
return !Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
|
||||
}
|
||||
|
||||
if (connector.type === "csv") {
|
||||
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredFieldsMapped(mappings);
|
||||
return !allRequiredMapped;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [connector, csvConnectorName, formbricksValues, isUpdating, mappings]);
|
||||
}, [allRequiredMapped, connector, connectorName, elementIdsBySurvey]);
|
||||
|
||||
if (!connector) return null;
|
||||
|
||||
@@ -239,111 +233,53 @@ export const EditConnectorModal = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{connector.type === "formbricks_survey" ? (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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="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="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="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>
|
||||
) : (
|
||||
<>
|
||||
<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 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>
|
||||
|
||||
@@ -358,13 +294,7 @@ export const EditConnectorModal = ({
|
||||
{t("workspace.unify.import_feedback")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
<Button onClick={handleUpdate} disabled={saveChangesDisbaled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
"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
@@ -0,0 +1,233 @@
|
||||
"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,6 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
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";
|
||||
|
||||
export default async function UnifySourcesPage(props: { params: Promise<{ workspaceId: string }> }) {
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
redirect(`/workspaces/${params.workspaceId}/feedback-sources`);
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@ import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } from ".
|
||||
const mockT = (key: string) => key;
|
||||
|
||||
describe("getConnectorOptions", () => {
|
||||
test("returns formbricks, csv, api ingestion, and mcp options", () => {
|
||||
test("returns formbricks and csv options", () => {
|
||||
const options = getConnectorOptions(mockT as never);
|
||||
expect(options).toHaveLength(4);
|
||||
expect(options[0].id).toBe("formbricks_survey");
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options[0].id).toBe("formbricks");
|
||||
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", () => {
|
||||
@@ -25,10 +23,6 @@ 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,11 +1,9 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { TConnectorType, THubFieldType } from "@formbricks/types/connector";
|
||||
import { THubFieldType } from "@formbricks/types/connector";
|
||||
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
|
||||
|
||||
export type TConnectorOptionId = TConnectorType | "api_ingestion" | "feedback_record_mcp";
|
||||
|
||||
export interface TConnectorOption {
|
||||
id: TConnectorOptionId;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
disabled: boolean;
|
||||
@@ -14,7 +12,7 @@ export interface TConnectorOption {
|
||||
|
||||
export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
|
||||
{
|
||||
id: "formbricks_survey",
|
||||
id: "formbricks",
|
||||
name: t("workspace.unify.formbricks_surveys"),
|
||||
description: t("workspace.unify.source_connect_formbricks_description"),
|
||||
disabled: false,
|
||||
@@ -25,18 +23,6 @@ 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[] => {
|
||||
|
||||
@@ -108,7 +108,7 @@ const resolveFormbricksMappingsInput = async (
|
||||
const allMappings = await Promise.all(
|
||||
entries.map(({ surveyId, elementIds }) => resolveSurveyMappings(surveyId, elementIds))
|
||||
);
|
||||
return { type: "formbricks_survey", mappings: allMappings.flat() };
|
||||
return { type: "formbricks", mappings: allMappings.flat() };
|
||||
};
|
||||
|
||||
const ZFormbricksSurveyMapping = z.object({
|
||||
@@ -124,7 +124,7 @@ const ZCreateConnectorWithMappingsAction = z
|
||||
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.connectorInput.type === "formbricks_survey") {
|
||||
if (data.connectorInput.type === "formbricks") {
|
||||
if (!data.formbricksMappings?.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
@@ -298,9 +298,9 @@ export const duplicateConnectorAction = authenticatedActionClient
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
if (source.type === "formbricks_survey" && source.formbricksMappings.length > 0) {
|
||||
if (source.type === "formbricks" && source.formbricksMappings.length > 0) {
|
||||
mappingsInput = {
|
||||
type: "formbricks_survey",
|
||||
type: "formbricks",
|
||||
mappings: source.formbricksMappings.map((m) => ({
|
||||
surveyId: m.surveyId,
|
||||
elementId: m.elementId,
|
||||
@@ -467,6 +467,7 @@ export const importCsvDataAction = authenticatedActionClient
|
||||
|
||||
const ZListFeedbackRecordsAction = z.object({
|
||||
workspaceId: ZId,
|
||||
frdId: ZId,
|
||||
limit: z.number().min(1).max(1000).optional(),
|
||||
cursor: z.string().optional(),
|
||||
sourceType: z.string().optional(),
|
||||
@@ -504,38 +505,28 @@ export const listFeedbackRecordsAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
// tenant_id = FRD id. Fan out across all FRDs assigned to this workspace, merge + sort desc.
|
||||
// Verify FRD belongs to workspace's accessible FRDs
|
||||
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(parsedInput.workspaceId);
|
||||
if (frds.length === 0) {
|
||||
return { data: [], limit: parsedInput.limit ?? 50 };
|
||||
if (!frds.some((f) => f.id === parsedInput.frdId)) {
|
||||
throw new Error("Feedback record directory not accessible");
|
||||
}
|
||||
|
||||
const perFrdLimit = parsedInput.limit ?? 50;
|
||||
const baseParams = {
|
||||
limit: perFrdLimit,
|
||||
...(parsedInput.sourceType ? { source_type: parsedInput.sourceType } : {}),
|
||||
...(parsedInput.fieldType ? { field_type: parsedInput.fieldType } : {}),
|
||||
...(parsedInput.since ? { since: parsedInput.since } : {}),
|
||||
...(parsedInput.until ? { until: parsedInput.until } : {}),
|
||||
const params: FeedbackRecordListParams = {
|
||||
tenant_id: parsedInput.frdId,
|
||||
limit: parsedInput.limit ?? 50,
|
||||
};
|
||||
if (parsedInput.cursor) params.cursor = parsedInput.cursor;
|
||||
if (parsedInput.sourceType) params.source_type = parsedInput.sourceType;
|
||||
if (parsedInput.fieldType) params.field_type = parsedInput.fieldType;
|
||||
if (parsedInput.since) params.since = parsedInput.since;
|
||||
if (parsedInput.until) params.until = parsedInput.until;
|
||||
|
||||
const results = await Promise.all(
|
||||
frds.map((frd) =>
|
||||
listFeedbackRecords({ ...baseParams, tenant_id: frd.id } as FeedbackRecordListParams)
|
||||
)
|
||||
);
|
||||
|
||||
const errored = results.find((r) => r.error);
|
||||
if (errored?.error) {
|
||||
logger.warn({ error: errored.error }, "Failed to list feedback records");
|
||||
throw new Error(errored.error.message);
|
||||
const result = await listFeedbackRecords(params);
|
||||
if (result.error || !result.data) {
|
||||
logger.warn({ error: result.error }, "Failed to list feedback records");
|
||||
throw new Error(result.error?.message ?? "Failed to load feedback records");
|
||||
}
|
||||
|
||||
const merged = results
|
||||
.flatMap((r) => r.data?.data ?? [])
|
||||
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
|
||||
.slice(0, perFrdLimit);
|
||||
|
||||
return { data: merged, limit: perFrdLimit };
|
||||
return result.data;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("importCsvData", () => {
|
||||
});
|
||||
|
||||
test("throws InvalidInputError for non-csv connector", async () => {
|
||||
const connector = makeConnector({ type: "formbricks_survey" });
|
||||
const connector = makeConnector({ type: "formbricks" });
|
||||
await expect(importCsvData(connector, [])).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const mockConnector: TConnectorWithMappings = {
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
name: "Test Connector",
|
||||
type: "formbricks_survey",
|
||||
type: "formbricks",
|
||||
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_survey") {
|
||||
if (connector.type !== "formbricks") {
|
||||
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_survey",
|
||||
type: "formbricks",
|
||||
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_survey",
|
||||
source_type: "formbricks",
|
||||
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_survey" as const,
|
||||
type: "formbricks" 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_survey",
|
||||
type: "formbricks",
|
||||
status: "active",
|
||||
formbricksMappings: { some: { surveyId: SURVEY_ID } },
|
||||
},
|
||||
@@ -303,18 +303,13 @@ describe("createConnectorWithMappings", () => {
|
||||
|
||||
const result = await createConnectorWithMappings(ENV_ID, {
|
||||
name: "New",
|
||||
type: "formbricks_survey",
|
||||
type: "formbricks",
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
});
|
||||
|
||||
expect(tx.connector.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
name: "New",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: ENV_ID,
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
},
|
||||
data: { name: "New", type: "formbricks", workspaceId: ENV_ID, feedbackRecordDirectoryId: FRD_ID },
|
||||
})
|
||||
);
|
||||
expect(tx.connectorFormbricksMapping.create).not.toHaveBeenCalled();
|
||||
@@ -330,9 +325,9 @@ describe("createConnectorWithMappings", () => {
|
||||
|
||||
await createConnectorWithMappings(
|
||||
ENV_ID,
|
||||
{ name: "FB", type: "formbricks_survey", feedbackRecordDirectoryId: FRD_ID },
|
||||
{ name: "FB", type: "formbricks", feedbackRecordDirectoryId: FRD_ID },
|
||||
{
|
||||
type: "formbricks_survey",
|
||||
type: "formbricks",
|
||||
mappings: [
|
||||
{ surveyId: SURVEY_ID, elementId: "el-1", hubFieldType: "text" },
|
||||
{ surveyId: SURVEY_ID, elementId: "el-2", hubFieldType: "nps" },
|
||||
@@ -397,7 +392,7 @@ describe("createConnectorWithMappings", () => {
|
||||
await expect(
|
||||
createConnectorWithMappings(ENV_ID, {
|
||||
name: "Dup",
|
||||
type: "formbricks_survey",
|
||||
type: "formbricks",
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
})
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
@@ -475,7 +470,7 @@ describe("updateConnectorWithMappings", () => {
|
||||
ENV_ID,
|
||||
{ name: "Updated" },
|
||||
{
|
||||
type: "formbricks_survey",
|
||||
type: "formbricks",
|
||||
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_survey",
|
||||
type: "formbricks",
|
||||
status: "active",
|
||||
formbricksMappings: {
|
||||
some: {
|
||||
@@ -213,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
|
||||
// -- Composite functions --
|
||||
|
||||
export type TFormbricksMappingsInput = {
|
||||
type: "formbricks_survey";
|
||||
type: "formbricks";
|
||||
mappings: TConnectorFormbricksMappingCreateInput[];
|
||||
};
|
||||
|
||||
@@ -243,7 +243,7 @@ export const createConnectorWithMappings = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (mappingsInput?.type === "formbricks_survey") {
|
||||
if (mappingsInput?.type === "formbricks") {
|
||||
await Promise.all(
|
||||
mappingsInput.mappings.map((mapping) =>
|
||||
tx.connectorFormbricksMapping.create({
|
||||
@@ -311,7 +311,7 @@ export const updateConnectorWithMappings = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (mappingsInput?.type === "formbricks_survey") {
|
||||
if (mappingsInput?.type === "formbricks") {
|
||||
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_survey",
|
||||
source_type: "formbricks",
|
||||
field_id: "el-text",
|
||||
field_type: "text",
|
||||
field_label: "How can we improve?",
|
||||
@@ -185,24 +185,6 @@ 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,23 +14,6 @@ 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];
|
||||
@@ -116,8 +99,9 @@ export function transformResponseToFeedbackRecords(
|
||||
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
|
||||
|
||||
const feedbackRecord = {
|
||||
collected_at: getCollectedAt(response),
|
||||
source_type: "formbricks_survey",
|
||||
collected_at:
|
||||
response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt),
|
||||
source_type: "formbricks",
|
||||
submission_id: response.id,
|
||||
tenant_id: tenantId,
|
||||
field_id: mapping.elementId,
|
||||
|
||||
@@ -22,9 +22,6 @@ export type AuditLoggingCtx = {
|
||||
quotaId?: string;
|
||||
teamId?: string;
|
||||
integrationId?: string;
|
||||
chartId?: string;
|
||||
dashboardId?: string;
|
||||
dashboardWidgetId?: string;
|
||||
feedbackRecordDirectoryId?: string;
|
||||
};
|
||||
|
||||
|
||||
+20
-209
@@ -125,8 +125,6 @@
|
||||
"activity": "Aktivität",
|
||||
"add": "Hinzufügen",
|
||||
"add_action": "Aktion 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",
|
||||
"add_logo": "Logo hinzufügen",
|
||||
"add_member": "Mitglied hinzufügen",
|
||||
@@ -135,11 +133,10 @@
|
||||
"add_workspace": "Workspace hinzufügen",
|
||||
"all": "Alle",
|
||||
"all_questions": "Alle Fragen",
|
||||
"allow": "erlauben",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
|
||||
"analysis": "Analyse",
|
||||
"and": "und",
|
||||
"allow": "Erlauben",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage durch Klicken außerhalb zu verlassen",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ein unbekannter Fehler ist beim Löschen von {type}s aufgetreten",
|
||||
"and": "Und",
|
||||
"anonymous": "Anonym",
|
||||
"api_keys": "API-Schlüssel",
|
||||
"app": "App",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "Zentriertes Modal",
|
||||
"change_organization": "Organisation wechseln",
|
||||
"change_workspace": "Workspace wechseln",
|
||||
"chart": "Diagramm",
|
||||
"charts": "Diagramme",
|
||||
"choices": "Entscheidungen",
|
||||
"choose_organization": "Organisation auswählen",
|
||||
"choose_workspace": "Projekt auswählen",
|
||||
@@ -190,7 +185,7 @@
|
||||
"count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}",
|
||||
"count_questions": "{count, plural, one {{count} Frage} other {{count} Fragen}}",
|
||||
"count_responses": "{count, plural, one {{count} Antwort} other {{count} Antworten}}",
|
||||
"count_selections": "{count, plural, one {{count} Auswahl} other {{count} Auswahlen}}",
|
||||
"count_selections": "{count, plural, one {{count} Auswahl} other {{count} Auswahlmöglichkeiten}}",
|
||||
"create": "Erstellen",
|
||||
"create_new_organization": "Neue Organisation erstellen",
|
||||
"create_segment": "Segment erstellen",
|
||||
@@ -199,10 +194,8 @@
|
||||
"created": "Erstellt",
|
||||
"created_at": "Erstellt am",
|
||||
"created_by": "Erstellt von",
|
||||
"customer_success": "Kundenerfolg",
|
||||
"dark_overlay": "Dunkle Überlagerung",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "Dashboards",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Dunkles Overlay",
|
||||
"date": "Datum",
|
||||
"days": "Tage",
|
||||
"default": "Standard",
|
||||
@@ -251,12 +244,11 @@
|
||||
"gathering_responses": "Sammle Antworten",
|
||||
"general": "Allgemein",
|
||||
"generate": "Generieren",
|
||||
"go_back": "Geh zurück",
|
||||
"go_to_dashboard": "Zum Dashboard gehen",
|
||||
"hidden": "Versteckt",
|
||||
"hidden_field": "Verstecktes Feld",
|
||||
"hidden_fields": "Versteckte Felder",
|
||||
"hide": "Ausblenden",
|
||||
"go_back": "Zurück",
|
||||
"go_to_dashboard": "Zum Dashboard",
|
||||
"hidden": "Verborgen",
|
||||
"hidden_field": "Verborgenes Feld",
|
||||
"hidden_fields": "Verborgene Felder",
|
||||
"hide_column": "Spalte ausblenden",
|
||||
"id": "ID",
|
||||
"image": "Bild",
|
||||
@@ -303,10 +295,9 @@
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße super aus!",
|
||||
"mobile_overlay_title": "Hoppla, kleiner Bildschirm entdeckt!",
|
||||
"months": "Monate",
|
||||
"more_options": "Weitere Optionen",
|
||||
"move_down": "Nach unten bewegen",
|
||||
"move_up": "Nach oben bewegen",
|
||||
"multiple_languages": "Mehrsprachigkeit",
|
||||
"move_down": "Nach unten verschieben",
|
||||
"move_up": "Nach oben verschieben",
|
||||
"multiple_languages": "Mehrere Sprachen",
|
||||
"my_product": "mein Produkt",
|
||||
"name": "Name",
|
||||
"new": "Neu",
|
||||
@@ -315,9 +306,8 @@
|
||||
"no": "Nein",
|
||||
"no_actions_found": "Keine Aktionen gefunden",
|
||||
"no_background_image_found": "Kein Hintergrundbild gefunden.",
|
||||
"no_changes": "Keine Änderungen",
|
||||
"no_code": "No Code",
|
||||
"no_files_uploaded": "Keine Dateien hochgeladen",
|
||||
"no_code": "Kein Code",
|
||||
"no_files_uploaded": "Es wurden keine Dateien hochgeladen",
|
||||
"no_overlay": "Kein Overlay",
|
||||
"no_quotas_found": "Keine Kontingente gefunden",
|
||||
"no_result_found": "Kein Ergebnis gefunden",
|
||||
@@ -336,10 +326,9 @@
|
||||
"offline_you_are_offline": "Du bist offline. Deine Antwort ist in deinem Browser gespeichert und wird gesichert, sobald du wieder online bist.",
|
||||
"on": "An",
|
||||
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
|
||||
"open_options": "Optionen öffnen",
|
||||
"option_id": "Option-ID",
|
||||
"option_ids": "Option-IDs",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Inhaber und Manager können diese Aktion durchführen.",
|
||||
"option_id": "Options-ID",
|
||||
"option_ids": "Options-IDs",
|
||||
"optional": "Optional",
|
||||
"or": "oder",
|
||||
"organization": "Organisation",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "Begrenze die Anzahl der Antworten, die du von Teilnehmenden erhältst, die bestimmte Kriterien erfüllen.",
|
||||
"read_docs": "Dokumentation lesen",
|
||||
"recipients": "Empfänger",
|
||||
"refresh": "Aktualisieren",
|
||||
"remove": "Entfernen",
|
||||
"remove_from_team": "Aus Team entfernen",
|
||||
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "Änderungen speichern",
|
||||
"saving": "Speichert",
|
||||
"search": "Suchen",
|
||||
"search_charts": "Diagramme durchsuchen...",
|
||||
"security": "Sicherheit",
|
||||
"segment": "Segment",
|
||||
"segments": "Segmente",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Deine Umfrage würde auf dieser URL angezeigt werden.",
|
||||
"your_survey_would_not_be_shown": "Deine Umfrage würde nicht angezeigt werden."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "ODER",
|
||||
"add_chart_to_dashboard": "Diagramm zum Dashboard hinzufügen",
|
||||
"add_chart_to_dashboard_description": "Wähle ein Dashboard aus, um dieses Diagramm hinzuzufügen. Das Diagramm wird automatisch gespeichert.",
|
||||
"add_filter": "Filter hinzufügen",
|
||||
"add_to_dashboard": "Zum Dashboard hinzufügen",
|
||||
"advanced_chart_builder_config_prompt": "Konfiguriere dein Diagramm und klicke auf \"Abfrage ausführen\", um eine Vorschau zu sehen",
|
||||
"ai_query_placeholder": "z.B. Wie viele Nutzer haben sich letzte Woche angemeldet?",
|
||||
"ai_query_section_description": "Beschreibe, was du sehen möchtest, und lass die KI das Diagramm erstellen.",
|
||||
"ai_query_section_title": "Frag deine Daten",
|
||||
"and_filter_logic": "UND",
|
||||
"apply_changes": "Änderungen übernehmen",
|
||||
"chart": "Diagramm",
|
||||
"chart_added_to_dashboard": "Diagramm zum Dashboard hinzugefügt!",
|
||||
"chart_builder_choose_chart_type": "Diagrammtyp auswählen",
|
||||
"chart_data": "Diagrammdaten",
|
||||
"chart_data_tab": "Daten",
|
||||
"chart_deleted_successfully": "Diagramm erfolgreich gelöscht",
|
||||
"chart_deletion_error": "Diagramm konnte nicht gelöscht werden",
|
||||
"chart_duplicated_successfully": "Diagramm erfolgreich dupliziert",
|
||||
"chart_duplication_error": "Diagramm konnte nicht dupliziert werden",
|
||||
"chart_name": "Diagrammname",
|
||||
"chart_name_placeholder": "Diagrammname",
|
||||
"chart_preview": "Diagrammvorschau",
|
||||
"chart_render_error": "Beim Rendern dieses Diagramms ist etwas schiefgelaufen.",
|
||||
"chart_saved_successfully": "Diagramm erfolgreich gespeichert!",
|
||||
"chart_type_area": "Flächendiagramm",
|
||||
"chart_type_bar": "Balkendiagramm",
|
||||
"chart_type_big_number": "Große Zahl",
|
||||
"chart_type_line": "Liniendiagramm",
|
||||
"chart_type_not_supported": "Diagrammtyp \"{{chartType}}\" wird noch nicht unterstützt",
|
||||
"chart_type_pie": "Kreisdiagramm",
|
||||
"chart_updated_successfully": "Diagramm erfolgreich aktualisiert!",
|
||||
"configure_description": "Ändere den Diagrammtyp und andere Einstellungen für diese Visualisierung.",
|
||||
"configure_title": "Diagramm konfigurieren",
|
||||
"configure_type_label": "Diagrammtyp",
|
||||
"contains": "enthält",
|
||||
"create_chart": "Diagramm erstellen",
|
||||
"create_chart_description": "Nutze KI, um ein Diagramm zu generieren, oder erstelle es manuell.",
|
||||
"create_chart_with_ai": "Diagramm mit KI erstellen",
|
||||
"custom_range": "Benutzerdefinierter Bereich",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_select_placeholder": "Wähle ein Dashboard aus",
|
||||
"data_label": "Daten",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Letzte 30 Tage",
|
||||
"date_preset_last_7_days": "Letzte 7 Tage",
|
||||
"date_preset_last_month": "Letzter Monat",
|
||||
"date_preset_this_month": "Dieser Monat",
|
||||
"date_preset_this_quarter": "Dieses Quartal",
|
||||
"date_preset_this_year": "Dieses Jahr",
|
||||
"date_preset_today": "Heute",
|
||||
"date_preset_yesterday": "Gestern",
|
||||
"date_range": "Datumsbereich",
|
||||
"delete_chart_confirmation": "Bist du sicher, dass du dieses Diagramm löschen möchtest?",
|
||||
"dimensions": "Dimensionen",
|
||||
"dimensions_toggle_description": "Gruppiere Daten nach Stimmung, Fragetyp und anderen Dimensionen.",
|
||||
"edit_chart_description": "Sieh dir deine Diagrammkonfiguration an und bearbeite sie.",
|
||||
"edit_chart_title": "Diagramm bearbeiten",
|
||||
"enable_time_dimension": "Zeitdimension aktivieren",
|
||||
"end_date": "Enddatum",
|
||||
"enter_a_name_for_your_chart": "Gib einen Namen für dein Diagramm ein, um es zu speichern.",
|
||||
"enter_value": "Wert eingeben",
|
||||
"equals": "gleich",
|
||||
"failed_to_add_chart_to_dashboard": "Diagramm konnte nicht zum Dashboard hinzugefügt werden",
|
||||
"failed_to_execute_query": "Abfrage konnte nicht ausgeführt werden",
|
||||
"failed_to_load_chart": "Diagramm konnte nicht geladen werden",
|
||||
"failed_to_load_chart_data": "Diagrammdaten konnten nicht geladen werden",
|
||||
"failed_to_save_chart": "Diagramm konnte nicht gespeichert werden",
|
||||
"field": "Feld",
|
||||
"field_label_average_score": "Durchschnittliche Bewertung",
|
||||
"field_label_collected_at": "Erfasst am",
|
||||
"field_label_count": "Anzahl",
|
||||
"field_label_detractor_count": "Anzahl Kritiker",
|
||||
"field_label_emotion": "Emotion",
|
||||
"field_label_field_type": "Feldtyp",
|
||||
"field_label_nps_score": "NPS-Score",
|
||||
"field_label_nps_value": "NPS-Wert",
|
||||
"field_label_passive_count": "Anzahl Passive",
|
||||
"field_label_promoter_count": "Anzahl Promoter",
|
||||
"field_label_response_id": "Antwort-ID",
|
||||
"field_label_sentiment": "Stimmung",
|
||||
"field_label_source_name": "Quellenname",
|
||||
"field_label_source_type": "Quellentyp",
|
||||
"field_label_topic": "Thema",
|
||||
"field_label_user_identifier": "Benutzerkennung",
|
||||
"filter_data": "Daten filtern",
|
||||
"filters": "Filter",
|
||||
"filters_toggle_description": "Nur Daten einbeziehen, die die folgenden Bedingungen erfüllen.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularität",
|
||||
"granularity_day": "Tag",
|
||||
"granularity_hour": "Stunde",
|
||||
"granularity_month": "Monat",
|
||||
"granularity_quarter": "Quartal",
|
||||
"granularity_week": "Woche",
|
||||
"granularity_year": "Jahr",
|
||||
"greater_than": "größer als",
|
||||
"greater_than_or_equal": "größer als oder gleich",
|
||||
"group_by": "Gruppieren nach",
|
||||
"group_by_description": "Schlüssele deine Daten nach einer oder mehreren Dimensionen auf. Die Reihenfolge ist wichtig, wenn du mehrere Dimensionen auswählst.",
|
||||
"group_data": "Daten gruppieren",
|
||||
"is_not_set": "ist nicht festgelegt",
|
||||
"is_set": "ist festgelegt",
|
||||
"less_than": "kleiner als",
|
||||
"less_than_or_equal": "kleiner oder gleich",
|
||||
"measures": "Kennzahlen",
|
||||
"no_charts_found": "Keine Diagramme gefunden.",
|
||||
"no_dashboards_available": "Keine Dashboards verfügbar",
|
||||
"no_dashboards_create_first": "Erstelle zuerst ein Dashboard, um Diagramme hinzuzufügen.",
|
||||
"no_data_available": "Keine Daten verfügbar",
|
||||
"no_data_returned": "Keine Daten von der Abfrage zurückgegeben",
|
||||
"no_data_returned_for_chart": "Keine Daten für Diagramm zurückgegeben",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Keine (nur Filter)",
|
||||
"no_valid_data_to_display": "Keine gültigen Daten zur Anzeige",
|
||||
"not_contains": "enthält nicht",
|
||||
"not_equals": "ist nicht gleich",
|
||||
"open_chart": "Diagramm {{name}} öffnen",
|
||||
"open_options": "Diagrammoptionen öffnen",
|
||||
"or_filter_logic": "ODER",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Bitte gib einen Diagrammnamen ein",
|
||||
"please_enter_filter_values": "Bitte gib Werte für alle Filter ein",
|
||||
"please_select_at_least_one_dimension": "Bitte wähle mindestens eine Dimension aus oder deaktiviere die Gruppierung",
|
||||
"please_select_at_least_one_measure": "Bitte wähle mindestens eine Kennzahl aus",
|
||||
"please_select_dashboard": "Bitte wähle ein Dashboard aus",
|
||||
"predefined_measures": "Vordefinierte Kennzahlen",
|
||||
"preset": "Vorlage",
|
||||
"query_executed_successfully": "Abfrage erfolgreich ausgeführt",
|
||||
"reset_to_ai_suggestion": "Auf KI-Vorschlag zurücksetzen",
|
||||
"save_chart": "Diagramm speichern",
|
||||
"save_chart_dialog_title": "Diagramm speichern",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Dimensionen auswählen...",
|
||||
"select_field": "Feld auswählen",
|
||||
"select_measures": "Metriken auswählen...",
|
||||
"select_preset": "Vorlage auswählen",
|
||||
"showing_first_n_of": "Zeige die ersten {{n}} von {{count}} Zeilen",
|
||||
"start_date": "Startdatum",
|
||||
"time_dimension": "Zeitdimension",
|
||||
"time_dimension_title": "Zeitbasierte Gruppierung hinzufügen",
|
||||
"time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} Diagramm(e) hinzufügen",
|
||||
"charts_add_failed": "Diagramme konnten nicht zum Dashboard hinzugefügt werden",
|
||||
"charts_add_partial_failure": "{count} Diagramm(e) konnten nicht hinzugefügt werden",
|
||||
"charts_added_to_dashboard": "Diagramme zum Dashboard hinzugefügt",
|
||||
"charts_load_failed": "Diagramme konnten nicht geladen werden",
|
||||
"create_dashboard": "Dashboard erstellen",
|
||||
"create_dashboard_description": "Gib einen Namen für dein neues Dashboard ein.",
|
||||
"create_failed": "Dashboard konnte nicht erstellt werden",
|
||||
"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.",
|
||||
"dashboard_name": "Dashboard-Name",
|
||||
"dashboard_name_placeholder": "Mein Dashboard",
|
||||
"dashboard_name_required": "Dashboard-Name ist erforderlich",
|
||||
"dashboard_save_failed": "Dashboard konnte nicht gespeichert werden",
|
||||
"dashboard_saved": "Dashboard erfolgreich gespeichert",
|
||||
"delete_confirmation": "Bist du sicher, dass du dieses Dashboard löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete_failed": "Dashboard konnte nicht gelöscht werden",
|
||||
"delete_success": "Dashboard erfolgreich gelöscht",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "API-Key hinzufügen",
|
||||
"api_key": "API-Key",
|
||||
|
||||
+14
-262
@@ -125,9 +125,6 @@
|
||||
"activity": "Activity",
|
||||
"add": "Add",
|
||||
"add_action": "Add action",
|
||||
"add_chart": "Add chart",
|
||||
"add_charts": "Add charts",
|
||||
"add_existing_chart_description": "Search and select charts to add to this dashboard.",
|
||||
"add_filter": "Add filter",
|
||||
"add_logo": "Add logo",
|
||||
"add_member": "Add member",
|
||||
@@ -139,7 +136,6 @@
|
||||
"allow": "Allow",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
|
||||
"analysis": "Analysis",
|
||||
"and": "And",
|
||||
"anonymous": "Anonymous",
|
||||
"api_keys": "API Keys",
|
||||
@@ -158,8 +154,6 @@
|
||||
"centered_modal": "Centered Modal",
|
||||
"change_organization": "Change organization",
|
||||
"change_workspace": "Change workspace",
|
||||
"chart": "Chart",
|
||||
"charts": "Charts",
|
||||
"choices": "Choices",
|
||||
"choose_organization": "Choose organization",
|
||||
"choose_workspace": "Choose workspace",
|
||||
@@ -202,8 +196,6 @@
|
||||
"created_by": "Created by",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Dark overlay",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Date",
|
||||
"days": "days",
|
||||
"default": "Default",
|
||||
@@ -257,7 +249,6 @@
|
||||
"hidden": "Hidden",
|
||||
"hidden_field": "Hidden field",
|
||||
"hidden_fields": "Hidden fields",
|
||||
"hide": "Hide",
|
||||
"hide_column": "Hide column",
|
||||
"id": "ID",
|
||||
"image": "Image",
|
||||
@@ -304,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "Do not worry – your surveys look great on every device and screen size!",
|
||||
"mobile_overlay_title": "Oops, tiny screen detected!",
|
||||
"months": "months",
|
||||
"more_options": "More options",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
@@ -316,7 +306,6 @@
|
||||
"no": "No",
|
||||
"no_actions_found": "No actions found",
|
||||
"no_background_image_found": "No background image found.",
|
||||
"no_changes": "No changes",
|
||||
"no_code": "No code",
|
||||
"no_files_uploaded": "No files were uploaded",
|
||||
"no_overlay": "No overlay",
|
||||
@@ -328,7 +317,6 @@
|
||||
"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",
|
||||
@@ -339,7 +327,6 @@
|
||||
"on": "On",
|
||||
"only_one_file_allowed": "Only one file is allowed",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
|
||||
"open_options": "Open options",
|
||||
"option_id": "Option ID",
|
||||
"option_ids": "Option IDs",
|
||||
"optional": "Optional",
|
||||
@@ -367,7 +354,7 @@
|
||||
"please_upgrade_your_plan": "Please upgrade your plan",
|
||||
"powered_by_formbricks": "Powered by Formbricks",
|
||||
"preview": "Preview",
|
||||
"preview_survey": "Preview survey",
|
||||
"preview_survey": "Preview Survey",
|
||||
"privacy": "Privacy Policy",
|
||||
"product_manager": "Product Manager",
|
||||
"production": "Production",
|
||||
@@ -382,7 +369,6 @@
|
||||
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
|
||||
"read_docs": "Read docs",
|
||||
"recipients": "Recipients",
|
||||
"refresh": "Refresh",
|
||||
"remove": "Remove",
|
||||
"remove_from_team": "Remove from team",
|
||||
"reorder_and_hide_columns": "Reorder and hide columns",
|
||||
@@ -390,7 +376,6 @@
|
||||
"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",
|
||||
@@ -404,7 +389,6 @@
|
||||
"save_changes": "Save changes",
|
||||
"saving": "Saving",
|
||||
"search": "Search",
|
||||
"search_charts": "Search charts...",
|
||||
"security": "Security",
|
||||
"segment": "Segment",
|
||||
"segments": "Segments",
|
||||
@@ -429,7 +413,6 @@
|
||||
"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",
|
||||
@@ -1635,188 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Your survey would be shown on this URL.",
|
||||
"your_survey_would_not_be_shown": "Your survey would not be shown."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "OR",
|
||||
"add_chart_to_dashboard": "Add Chart to Dashboard",
|
||||
"add_chart_to_dashboard_description": "Select a dashboard to add this chart to. The chart will be saved automatically.",
|
||||
"add_filter": "Add filter",
|
||||
"add_to_dashboard": "Add to Dashboard",
|
||||
"advanced_chart_builder_config_prompt": "Configure your chart and click \"Run Query\" to preview",
|
||||
"ai_query_placeholder": "e.g. How many users signed up last week?",
|
||||
"ai_query_section_description": "Describe what you want to see and let AI build the chart.",
|
||||
"ai_query_section_title": "Ask your data",
|
||||
"and_filter_logic": "AND",
|
||||
"apply_changes": "Apply Changes",
|
||||
"chart": "Chart",
|
||||
"chart_added_to_dashboard": "Chart added to dashboard!",
|
||||
"chart_builder_choose_chart_type": "Choose chart type",
|
||||
"chart_data": "Chart Data",
|
||||
"chart_data_tab": "Data",
|
||||
"chart_deleted_successfully": "Chart deleted successfully",
|
||||
"chart_deletion_error": "Failed to delete chart",
|
||||
"chart_duplicated_successfully": "Chart duplicated successfully",
|
||||
"chart_duplication_error": "Failed to duplicate chart",
|
||||
"chart_name": "Chart Name",
|
||||
"chart_name_placeholder": "Chart name",
|
||||
"chart_preview": "Chart Preview",
|
||||
"chart_render_error": "Something went wrong while rendering this chart.",
|
||||
"chart_saved_successfully": "Chart saved successfully!",
|
||||
"chart_type_area": "Area Chart",
|
||||
"chart_type_bar": "Bar Chart",
|
||||
"chart_type_big_number": "Big Number",
|
||||
"chart_type_line": "Line Chart",
|
||||
"chart_type_not_supported": "Chart type \"{{chartType}}\" not yet supported",
|
||||
"chart_type_pie": "Pie Chart",
|
||||
"chart_updated_successfully": "Chart updated successfully!",
|
||||
"configure_description": "Modify the chart type and other settings for this visualization.",
|
||||
"configure_title": "Configure Chart",
|
||||
"configure_type_label": "Chart Type",
|
||||
"contains": "contains",
|
||||
"create_chart": "Create chart",
|
||||
"create_chart_description": "Use AI to generate a chart or build one manually.",
|
||||
"create_chart_with_ai": "Create chart with AI",
|
||||
"custom_range": "Custom Range",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_select_placeholder": "Select a dashboard",
|
||||
"data_label": "Data",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Last 30 days",
|
||||
"date_preset_last_7_days": "Last 7 days",
|
||||
"date_preset_last_month": "Last month",
|
||||
"date_preset_this_month": "This month",
|
||||
"date_preset_this_quarter": "This quarter",
|
||||
"date_preset_this_year": "This year",
|
||||
"date_preset_today": "Today",
|
||||
"date_preset_yesterday": "Yesterday",
|
||||
"date_range": "Date Range",
|
||||
"delete_chart_confirmation": "Are you sure you want to delete this chart?",
|
||||
"dimensions": "Dimensions",
|
||||
"dimensions_toggle_description": "Group data by sentiment, question type, and other dimensions.",
|
||||
"edit_chart_description": "View and edit your chart configuration.",
|
||||
"edit_chart_title": "Edit Chart",
|
||||
"enable_time_dimension": "Enable Time Dimension",
|
||||
"end_date": "End date",
|
||||
"enter_a_name_for_your_chart": "Enter a name for your chart to save it.",
|
||||
"enter_value": "Enter value",
|
||||
"equals": "equals",
|
||||
"failed_to_add_chart_to_dashboard": "Failed to add chart to dashboard",
|
||||
"failed_to_execute_query": "Failed to execute query",
|
||||
"failed_to_load_chart": "Failed to load chart",
|
||||
"failed_to_load_chart_data": "Failed to load chart data",
|
||||
"failed_to_save_chart": "Failed to save chart",
|
||||
"field": "Field",
|
||||
"field_label_average_score": "Average Score",
|
||||
"field_label_collected_at": "Collected At",
|
||||
"field_label_count": "Count",
|
||||
"field_label_detractor_count": "Detractor Count",
|
||||
"field_label_emotion": "Emotion",
|
||||
"field_label_field_type": "Field Type",
|
||||
"field_label_nps_score": "NPS Score",
|
||||
"field_label_nps_value": "NPS Value",
|
||||
"field_label_passive_count": "Passive Count",
|
||||
"field_label_promoter_count": "Promoter Count",
|
||||
"field_label_response_id": "Response ID",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Source Name",
|
||||
"field_label_source_type": "Source Type",
|
||||
"field_label_topic": "Topic",
|
||||
"field_label_user_identifier": "User Identifier",
|
||||
"filter_data": "Filter data",
|
||||
"filters": "Filters",
|
||||
"filters_toggle_description": "Only include data that meets the following conditions.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularity",
|
||||
"granularity_day": "Day",
|
||||
"granularity_hour": "Hour",
|
||||
"granularity_month": "Month",
|
||||
"granularity_quarter": "Quarter",
|
||||
"granularity_week": "Week",
|
||||
"granularity_year": "Year",
|
||||
"greater_than": "greater than",
|
||||
"greater_than_or_equal": "greater than or equal",
|
||||
"group_by": "Group By",
|
||||
"group_by_description": "Break down your data by one or more dimensions. The order is important if you choose multiple dimensions.",
|
||||
"group_data": "Group data",
|
||||
"is_not_set": "is not set",
|
||||
"is_set": "is set",
|
||||
"less_than": "less than",
|
||||
"less_than_or_equal": "less than or equal",
|
||||
"measures": "Measures",
|
||||
"no_charts_found": "No charts found.",
|
||||
"no_dashboards_available": "No dashboards available",
|
||||
"no_dashboards_create_first": "Create a dashboard first to add charts to it.",
|
||||
"no_data_available": "No data available",
|
||||
"no_data_returned": "No data returned from query",
|
||||
"no_data_returned_for_chart": "No data returned for chart",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "None (filter only)",
|
||||
"no_valid_data_to_display": "No valid data to display",
|
||||
"not_contains": "not contains",
|
||||
"not_equals": "not equals",
|
||||
"open_chart": "Open chart {{name}}",
|
||||
"open_options": "Open chart options",
|
||||
"or_filter_logic": "OR",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Please enter a chart name",
|
||||
"please_enter_filter_values": "Please enter values for all filters",
|
||||
"please_select_at_least_one_dimension": "Please select at least one dimension or disable grouping",
|
||||
"please_select_at_least_one_measure": "Please select at least one measure",
|
||||
"please_select_dashboard": "Please select a dashboard",
|
||||
"predefined_measures": "Predefined Measures",
|
||||
"preset": "Preset",
|
||||
"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",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Select dimensions...",
|
||||
"select_field": "Select field",
|
||||
"select_measures": "Select measures...",
|
||||
"select_preset": "Select preset",
|
||||
"showing_first_n_of": "Showing first {{n}} of {{count}} rows",
|
||||
"start_date": "Start date",
|
||||
"time_dimension": "Time Dimension",
|
||||
"time_dimension_title": "Add time-based grouping",
|
||||
"time_dimension_toggle_description": "Monitor trends over time.",
|
||||
"update_chart": "Update chart"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Add {count} chart(s)",
|
||||
"charts_add_failed": "Failed to add charts to dashboard",
|
||||
"charts_add_partial_failure": "Failed to add {count} chart(s)",
|
||||
"charts_added_to_dashboard": "Charts added to dashboard",
|
||||
"charts_load_failed": "Failed to load charts",
|
||||
"create_dashboard": "Create dashboard",
|
||||
"create_dashboard_description": "Enter a name for your new dashboard.",
|
||||
"create_failed": "Failed to create dashboard",
|
||||
"create_new_chart": "Create new chart",
|
||||
"create_success": "Dashboard created successfully!",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
|
||||
"dashboard_name": "Dashboard Name",
|
||||
"dashboard_name_placeholder": "My dashboard",
|
||||
"dashboard_name_required": "Dashboard name is required",
|
||||
"dashboard_save_failed": "Failed to save dashboard",
|
||||
"dashboard_saved": "Dashboard saved successfully",
|
||||
"delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
|
||||
"delete_failed": "Failed to delete dashboard",
|
||||
"delete_success": "Dashboard deleted successfully",
|
||||
"duplicate_failed": "Failed to duplicate dashboard",
|
||||
"duplicate_success": "Dashboard duplicated successfully!",
|
||||
"failed_to_load_chart_data": "Failed to load chart data",
|
||||
"no_charts_available_description": "No more charts available to add. Create a new one.",
|
||||
"no_charts_to_add_message": "No charts to add to this dashboard.",
|
||||
"no_dashboards_found": "No dashboards found.",
|
||||
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
|
||||
"please_enter_name": "Please enter a dashboard name"
|
||||
},
|
||||
"no_feedback_records_message": "You don't have Feedback Records to report on. Setup Feedback Sources to feed data into the system.",
|
||||
"setup_feedback_source": "Setup feedback sources"
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Add API Key",
|
||||
"api_key": "API Key",
|
||||
@@ -1828,7 +1629,7 @@
|
||||
"api_key_updated": "API Key updated",
|
||||
"delete_api_key_confirmation": "Any applications using this key will no longer be able to access your Formbricks data.",
|
||||
"duplicate_access": "Duplicate workspace access not allowed",
|
||||
"no_api_keys_yet": "No API keys found. Create an API key to get started.",
|
||||
"no_api_keys_yet": "You do not have any API keys yet",
|
||||
"no_env_permissions_found": "No environment permissions found",
|
||||
"organization_access": "Organization Access",
|
||||
"organization_access_description": "Select read or write privileges for organization-wide resources.",
|
||||
@@ -2514,14 +2315,14 @@
|
||||
"archive_directory": "Archive Directory",
|
||||
"archive_not_allowed": "You are not allowed to archive this directory.",
|
||||
"are_you_sure_you_want_to_archive": "Are you sure you want to archive this directory? Workspaces will no longer have access to it.",
|
||||
"assign_workspaces_description": "Control which workspaces can access this directory. Each workspace can only access one directory.",
|
||||
"assign_workspaces_description": "Control which workspaces can access this feedback record directory.",
|
||||
"connectors_description": "Connectors that send feedback records to this directory.",
|
||||
"create_feedback_directory": "Create feedback directory",
|
||||
"description": "Manage feedback record directories and their workspace assignments.",
|
||||
"directory_archived_successfully": "Directory archived successfully",
|
||||
"directory_created_successfully": "Directory created successfully",
|
||||
"directory_id": "Directory ID",
|
||||
"directory_name": "Directory name",
|
||||
"directory_name": "Directory Name",
|
||||
"directory_settings_description": "Manage directory name, workspace assignments, and more.",
|
||||
"directory_settings_title": "{directoryName} Settings",
|
||||
"directory_unarchived_successfully": "Directory unarchived successfully",
|
||||
@@ -2531,19 +2332,13 @@
|
||||
"error_directory_name_duplicate": "A feedback record directory with this name already exists.",
|
||||
"error_directory_name_required": "Directory name is required.",
|
||||
"error_directory_workspaces_invalid_org": "Some specified workspaces do not belong to this organization.",
|
||||
"has_access": "Has access",
|
||||
"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": "{count, plural, one {1 connector will be paused because its workspace no longer has access to this directory. Continue?} other {{count} connectors will be paused because their workspaces no longer have access to this directory. Continue?}}",
|
||||
"pause_connectors_confirmation_title": "Pause affected connectors?",
|
||||
"select_workspaces_placeholder": "Select workspaces...",
|
||||
"show_archived": "Show archived",
|
||||
"title": "Feedback Record Directories",
|
||||
"unarchive": "Unarchive",
|
||||
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more workspaces are already assigned to another Feedback Directory.",
|
||||
"workspace_access": "Workspace access",
|
||||
"workspace_already_assigned_to_directory": "Already assigned to \"{directoryName}\""
|
||||
"unarchive": "Unarchive"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "AI data analysis is disabled for this organization.",
|
||||
@@ -3565,7 +3360,7 @@
|
||||
"add_tag": "Add Tag",
|
||||
"count": "Count",
|
||||
"delete_tag_confirmation": "Are you sure you want to delete this tag?",
|
||||
"manage_tags": "Manage tags",
|
||||
"manage_tags": "Manage Tags",
|
||||
"manage_tags_description": "Merge and remove response tags.",
|
||||
"merge": "Merge",
|
||||
"no_tag_found": "No tag found",
|
||||
@@ -3584,21 +3379,15 @@
|
||||
"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 directly to Formbricks via HTTP.",
|
||||
"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",
|
||||
"configure_mapping": "Configure Mapping",
|
||||
"connection": "Connection",
|
||||
"connector_created_successfully": "Connector created successfully",
|
||||
"connector_deleted_successfully": "Connector deleted successfully",
|
||||
@@ -3613,18 +3402,14 @@
|
||||
"csv_empty_column_headers": "CSV contains empty column headers. All columns must have a name.",
|
||||
"csv_file_too_large": "CSV file is too large. Maximum size is 2MB.",
|
||||
"csv_files_only": "CSV files only",
|
||||
"csv_import": "CSV import",
|
||||
"csv_import": "CSV Import",
|
||||
"csv_import_complete": "CSV import complete: {successes} succeeded, {failures} failed, {skipped} skipped",
|
||||
"csv_import_duplicate_warning": "Importing data twice will create duplicate records.",
|
||||
"csv_inconsistent_columns": "Row {row} has inconsistent columns. All rows must have the same headers.",
|
||||
"csv_max_records": "Maximum {max} records allowed.",
|
||||
"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",
|
||||
@@ -3634,46 +3419,24 @@
|
||||
"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": "This workspace has access to the {directoryNames} feedback directories.",
|
||||
"feedback_sources_directory_access_single": "This workspace has access to the {directoryNames} feedback directory.",
|
||||
"feedback_sources_settings_description": "Connect and manage the sources that feed your feedback records.",
|
||||
"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 survey",
|
||||
"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": "Creates one feedback record for each answer to each question.",
|
||||
"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",
|
||||
"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 a feedback source?",
|
||||
"n_supported_questions": "{count} supported questions",
|
||||
"no_feedback_record_directory_available": "No feedback record directory assigned to this workspace. Create or assign one first.",
|
||||
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
|
||||
@@ -3685,16 +3448,15 @@
|
||||
"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 it and we will build it!",
|
||||
"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:",
|
||||
@@ -3703,14 +3465,12 @@
|
||||
"select_survey_questions_description": "Choose which survey questions should create FeedbackRecords.",
|
||||
"set_value": "set value",
|
||||
"setup_connection": "Setup connection",
|
||||
"showing_count_loaded": "Showing {count} records from Feedback Directory {directoryName}",
|
||||
"showing_count_loaded": "Showing {count} records",
|
||||
"showing_rows": "Showing 3 of {count} rows",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "Import feedback from CSV files",
|
||||
"source_connect_feedback_record_mcp_description": "Connect feedback records via the Formbricks MCP.",
|
||||
"source_connect_formbricks_description": "Connect feedback from your Formbricks survey",
|
||||
"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",
|
||||
@@ -3719,13 +3479,9 @@
|
||||
"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",
|
||||
"topics_and_subtopics": "Topics & Subtopics",
|
||||
"total_feedback_records": "Total: {checked} of {total} Feedback Records selected across {surveyCount} surveys",
|
||||
"unify_feedback": "Unify Feedback",
|
||||
"update_mapping_description": "Update the mapping configuration for this source.",
|
||||
@@ -3733,11 +3489,7 @@
|
||||
"upload_csv_data_description": "Upload a CSV file to import feedback data.",
|
||||
"upload_csv_file": "Upload CSV File",
|
||||
"user_identifier": "User",
|
||||
"value": "Value",
|
||||
"value_boolean": "Value (Boolean)",
|
||||
"value_date": "Value (Date)",
|
||||
"value_number": "Value (Number)",
|
||||
"value_text": "Value (Text)"
|
||||
"value": "Value"
|
||||
},
|
||||
"xm-templates": {
|
||||
"ces": "CES",
|
||||
|
||||
@@ -125,8 +125,6 @@
|
||||
"activity": "Actividad",
|
||||
"add": "Añadir",
|
||||
"add_action": "Añadir acción",
|
||||
"add_charts": "Añadir gráficos",
|
||||
"add_existing_chart_description": "Busca y selecciona gráficos para añadir a este panel.",
|
||||
"add_filter": "Añadir filtro",
|
||||
"add_logo": "Añadir logotipo",
|
||||
"add_member": "Añadir miembro",
|
||||
@@ -138,7 +136,6 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
|
||||
"analysis": "Análisis",
|
||||
"and": "Y",
|
||||
"anonymous": "Anónimo",
|
||||
"api_keys": "Claves API",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "Modal centrado",
|
||||
"change_organization": "Cambiar organización",
|
||||
"change_workspace": "Cambiar espacio de trabajo",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Opciones",
|
||||
"choose_organization": "Elegir organización",
|
||||
"choose_workspace": "Elegir proyecto",
|
||||
@@ -201,8 +196,6 @@
|
||||
"created_by": "Creado por",
|
||||
"customer_success": "Éxito del cliente",
|
||||
"dark_overlay": "Superposición oscura",
|
||||
"dashboard": "Panel de control",
|
||||
"dashboards": "Paneles",
|
||||
"date": "Fecha",
|
||||
"days": "días",
|
||||
"default": "Predeterminado",
|
||||
@@ -256,7 +249,6 @@
|
||||
"hidden": "Oculto",
|
||||
"hidden_field": "Campo oculto",
|
||||
"hidden_fields": "Campos ocultos",
|
||||
"hide": "Ocultar",
|
||||
"hide_column": "Ocultar columna",
|
||||
"id": "ID",
|
||||
"image": "Imagen",
|
||||
@@ -303,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "No te preocupes – ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
|
||||
"mobile_overlay_title": "¡Ups, pantalla pequeña detectada!",
|
||||
"months": "meses",
|
||||
"more_options": "Más opciones",
|
||||
"move_down": "Mover hacia abajo",
|
||||
"move_up": "Mover hacia arriba",
|
||||
"multiple_languages": "Múltiples idiomas",
|
||||
@@ -315,7 +306,6 @@
|
||||
"no": "No",
|
||||
"no_actions_found": "No se encontraron acciones",
|
||||
"no_background_image_found": "No se encontró imagen de fondo.",
|
||||
"no_changes": "Sin cambios",
|
||||
"no_code": "Sin código",
|
||||
"no_files_uploaded": "No se subieron archivos",
|
||||
"no_overlay": "Sin superposición",
|
||||
@@ -337,7 +327,6 @@
|
||||
"on": "Activado",
|
||||
"only_one_file_allowed": "Solo se permite un archivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Solo los propietarios y gestores pueden realizar esta acción.",
|
||||
"open_options": "Abrir opciones",
|
||||
"option_id": "ID de opción",
|
||||
"option_ids": "IDs de opciones",
|
||||
"optional": "Opcional",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "Limita la cantidad de respuestas que recibes de participantes que cumplen ciertos criterios.",
|
||||
"read_docs": "Leer documentación",
|
||||
"recipients": "Destinatarios",
|
||||
"refresh": "Actualizar",
|
||||
"remove": "Eliminar",
|
||||
"remove_from_team": "Eliminar del equipo",
|
||||
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "Guardar cambios",
|
||||
"saving": "Guardando",
|
||||
"search": "Buscar",
|
||||
"search_charts": "Buscar gráficos...",
|
||||
"security": "Seguridad",
|
||||
"segment": "Segmento",
|
||||
"segments": "Segmentos",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Tu encuesta se mostraría en esta URL.",
|
||||
"your_survey_would_not_be_shown": "Tu encuesta no se mostraría."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "O",
|
||||
"add_chart_to_dashboard": "Añadir gráfico al panel de control",
|
||||
"add_chart_to_dashboard_description": "Selecciona un panel de control para añadir este gráfico. El gráfico se guardará automáticamente.",
|
||||
"add_filter": "Añadir filtro",
|
||||
"add_to_dashboard": "Añadir al panel de control",
|
||||
"advanced_chart_builder_config_prompt": "Configura tu gráfico y haz clic en \"Ejecutar consulta\" para previsualizar",
|
||||
"ai_query_placeholder": "p. ej. ¿Cuántos usuarios se registraron la semana pasada?",
|
||||
"ai_query_section_description": "Describe lo que quieres ver y deja que la IA construya el gráfico.",
|
||||
"ai_query_section_title": "Pregunta a tus datos",
|
||||
"and_filter_logic": "Y",
|
||||
"apply_changes": "Aplicar cambios",
|
||||
"chart": "Gráfico",
|
||||
"chart_added_to_dashboard": "¡Gráfico añadido al panel de control!",
|
||||
"chart_builder_choose_chart_type": "Elige el tipo de gráfico",
|
||||
"chart_data": "Datos del gráfico",
|
||||
"chart_data_tab": "Datos",
|
||||
"chart_deleted_successfully": "Gráfico eliminado correctamente",
|
||||
"chart_deletion_error": "Error al eliminar el gráfico",
|
||||
"chart_duplicated_successfully": "Gráfico duplicado correctamente",
|
||||
"chart_duplication_error": "Error al duplicar el gráfico",
|
||||
"chart_name": "Nombre del gráfico",
|
||||
"chart_name_placeholder": "Nombre del gráfico",
|
||||
"chart_preview": "Vista previa del gráfico",
|
||||
"chart_render_error": "Algo salió mal al renderizar este gráfico.",
|
||||
"chart_saved_successfully": "¡Gráfico guardado correctamente!",
|
||||
"chart_type_area": "Gráfico de área",
|
||||
"chart_type_bar": "Gráfico de barras",
|
||||
"chart_type_big_number": "Número grande",
|
||||
"chart_type_line": "Gráfico de líneas",
|
||||
"chart_type_not_supported": "El tipo de gráfico \"{{chartType}}\" aún no está soportado",
|
||||
"chart_type_pie": "Gráfico circular",
|
||||
"chart_updated_successfully": "¡Gráfico actualizado correctamente!",
|
||||
"configure_description": "Modifica el tipo de gráfico y otros ajustes para esta visualización.",
|
||||
"configure_title": "Configurar gráfico",
|
||||
"configure_type_label": "Tipo de gráfico",
|
||||
"contains": "contiene",
|
||||
"create_chart": "Crear gráfico",
|
||||
"create_chart_description": "Usa IA para generar un gráfico o créalo manualmente.",
|
||||
"create_chart_with_ai": "Crear gráfico con IA",
|
||||
"custom_range": "Rango personalizado",
|
||||
"dashboard": "Panel de control",
|
||||
"dashboard_select_placeholder": "Selecciona un panel de control",
|
||||
"data_label": "Datos",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Últimos 30 días",
|
||||
"date_preset_last_7_days": "Últimos 7 días",
|
||||
"date_preset_last_month": "Último mes",
|
||||
"date_preset_this_month": "Este mes",
|
||||
"date_preset_this_quarter": "Este trimestre",
|
||||
"date_preset_this_year": "Este año",
|
||||
"date_preset_today": "Hoy",
|
||||
"date_preset_yesterday": "Ayer",
|
||||
"date_range": "Rango de fechas",
|
||||
"delete_chart_confirmation": "¿Estás seguro de que quieres eliminar este gráfico?",
|
||||
"dimensions": "Dimensiones",
|
||||
"dimensions_toggle_description": "Agrupa los datos por sentimiento, tipo de pregunta y otras dimensiones.",
|
||||
"edit_chart_description": "Visualiza y edita la configuración de tu gráfico.",
|
||||
"edit_chart_title": "Editar gráfico",
|
||||
"enable_time_dimension": "Activar dimensión temporal",
|
||||
"end_date": "Fecha de finalización",
|
||||
"enter_a_name_for_your_chart": "Introduce un nombre para tu gráfico para guardarlo.",
|
||||
"enter_value": "Introduce un valor",
|
||||
"equals": "es igual a",
|
||||
"failed_to_add_chart_to_dashboard": "Error al añadir el gráfico al panel de control",
|
||||
"failed_to_execute_query": "Error al ejecutar la consulta",
|
||||
"failed_to_load_chart": "Error al cargar el gráfico",
|
||||
"failed_to_load_chart_data": "Error al cargar los datos del gráfico",
|
||||
"failed_to_save_chart": "Error al guardar el gráfico",
|
||||
"field": "Campo",
|
||||
"field_label_average_score": "Puntuación media",
|
||||
"field_label_collected_at": "Recopilado el",
|
||||
"field_label_count": "Recuento",
|
||||
"field_label_detractor_count": "Recuento de detractores",
|
||||
"field_label_emotion": "Emoción",
|
||||
"field_label_field_type": "Tipo de campo",
|
||||
"field_label_nps_score": "Puntuación NPS",
|
||||
"field_label_nps_value": "Valor NPS",
|
||||
"field_label_passive_count": "Recuento de pasivos",
|
||||
"field_label_promoter_count": "Recuento de promotores",
|
||||
"field_label_response_id": "ID de respuesta",
|
||||
"field_label_sentiment": "Sentimiento",
|
||||
"field_label_source_name": "Nombre de origen",
|
||||
"field_label_source_type": "Tipo de origen",
|
||||
"field_label_topic": "Tema",
|
||||
"field_label_user_identifier": "Identificador de usuario",
|
||||
"filter_data": "Filtrar datos",
|
||||
"filters": "Filtros",
|
||||
"filters_toggle_description": "Incluye solo los datos que cumplan las siguientes condiciones.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularidad",
|
||||
"granularity_day": "Día",
|
||||
"granularity_hour": "Hora",
|
||||
"granularity_month": "Mes",
|
||||
"granularity_quarter": "Trimestre",
|
||||
"granularity_week": "Semana",
|
||||
"granularity_year": "Año",
|
||||
"greater_than": "mayor que",
|
||||
"greater_than_or_equal": "mayor o igual que",
|
||||
"group_by": "Agrupar por",
|
||||
"group_by_description": "Desglosa tus datos por una o más dimensiones. El orden es importante si eliges varias dimensiones.",
|
||||
"group_data": "Agrupar datos",
|
||||
"is_not_set": "no está establecido",
|
||||
"is_set": "está establecido",
|
||||
"less_than": "menor que",
|
||||
"less_than_or_equal": "menor o igual que",
|
||||
"measures": "Medidas",
|
||||
"no_charts_found": "No se encontraron gráficos.",
|
||||
"no_dashboards_available": "No hay paneles de control disponibles",
|
||||
"no_dashboards_create_first": "Crea primero un panel de control para añadirle gráficos.",
|
||||
"no_data_available": "No hay datos disponibles",
|
||||
"no_data_returned": "La consulta no ha devuelto datos",
|
||||
"no_data_returned_for_chart": "No se han devuelto datos para el gráfico",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Ninguno (solo filtro)",
|
||||
"no_valid_data_to_display": "No hay datos válidos para mostrar",
|
||||
"not_contains": "no contiene",
|
||||
"not_equals": "no es igual a",
|
||||
"open_chart": "Abrir gráfico {{name}}",
|
||||
"open_options": "Abrir opciones del gráfico",
|
||||
"or_filter_logic": "O",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Introduce un nombre para el gráfico",
|
||||
"please_enter_filter_values": "Por favor, introduce valores para todos los filtros",
|
||||
"please_select_at_least_one_dimension": "Por favor, selecciona al menos una dimensión o desactiva la agrupación",
|
||||
"please_select_at_least_one_measure": "Selecciona al menos una medida",
|
||||
"please_select_dashboard": "Selecciona un panel de control",
|
||||
"predefined_measures": "Medidas predefinidas",
|
||||
"preset": "Preajuste",
|
||||
"query_executed_successfully": "Consulta ejecutada correctamente",
|
||||
"reset_to_ai_suggestion": "Restablecer a sugerencia de IA",
|
||||
"save_chart": "Guardar gráfico",
|
||||
"save_chart_dialog_title": "Guardar gráfico",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Seleccionar dimensiones...",
|
||||
"select_field": "Seleccionar campo",
|
||||
"select_measures": "Seleccionar medidas...",
|
||||
"select_preset": "Seleccionar preajuste",
|
||||
"showing_first_n_of": "Mostrando las primeras {{n}} de {{count}} filas",
|
||||
"start_date": "Fecha de inicio",
|
||||
"time_dimension": "Dimensión temporal",
|
||||
"time_dimension_title": "Añadir agrupación temporal",
|
||||
"time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Añadir {count} gráfico(s)",
|
||||
"charts_add_failed": "Error al añadir gráficos al panel",
|
||||
"charts_add_partial_failure": "Error al añadir {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos añadidos al panel",
|
||||
"charts_load_failed": "Error al cargar los gráficos",
|
||||
"create_dashboard": "Crear panel",
|
||||
"create_dashboard_description": "Introduce un nombre para tu panel de control nuevo.",
|
||||
"create_failed": "Error al crear el panel de control",
|
||||
"create_success": "Panel de control creado correctamente",
|
||||
"dashboard": "Panel",
|
||||
"dashboard_delete_confirmation": "¿Estás seguro de que quieres eliminar este panel? Esta acción no se puede deshacer.",
|
||||
"dashboard_name": "Nombre del panel de control",
|
||||
"dashboard_name_placeholder": "Mi panel de control",
|
||||
"dashboard_name_required": "El nombre del panel es obligatorio",
|
||||
"dashboard_save_failed": "Error al guardar el panel",
|
||||
"dashboard_saved": "Panel guardado correctamente",
|
||||
"delete_confirmation": "¿Estás seguro de que quieres eliminar este panel de control? Esta acción no se puede deshacer.",
|
||||
"delete_failed": "Error al eliminar el panel de control",
|
||||
"delete_success": "Panel de control eliminado correctamente",
|
||||
"duplicate_failed": "Error al duplicar el panel de control",
|
||||
"duplicate_success": "Panel de control duplicado correctamente",
|
||||
"failed_to_load_chart_data": "Error al cargar los datos del gráfico",
|
||||
"no_charts_available_description": "No hay gráficos que se puedan añadir a este panel. O bien no existen gráficos todavía, o todos los gráficos existentes ya se han añadido. Ve a la página de Gráficos para crear nuevos gráficos.",
|
||||
"no_charts_to_add_message": "No hay gráficos para añadir a este panel.",
|
||||
"no_dashboards_found": "No se han encontrado paneles de control.",
|
||||
"no_data_message": "Sin datos. Actualmente no hay información que mostrar. Añade gráficos para crear tu panel.",
|
||||
"please_enter_name": "Por favor, introduce un nombre para el panel de control"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Añadir clave API",
|
||||
"api_key": "Clave API",
|
||||
|
||||
@@ -125,8 +125,6 @@
|
||||
"activity": "Activité",
|
||||
"add": "Ajouter",
|
||||
"add_action": "Ajouter une action",
|
||||
"add_charts": "Ajouter des graphiques",
|
||||
"add_existing_chart_description": "Recherchez et sélectionnez des graphiques à ajouter à ce tableau de bord.",
|
||||
"add_filter": "Ajouter un filtre",
|
||||
"add_logo": "Ajouter un logo",
|
||||
"add_member": "Ajouter un membre",
|
||||
@@ -138,7 +136,6 @@
|
||||
"allow": "Autoriser",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "Et",
|
||||
"anonymous": "Anonyme",
|
||||
"api_keys": "Clés API",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "Au centre",
|
||||
"change_organization": "Changer d'organisation",
|
||||
"change_workspace": "Changer d'espace de travail",
|
||||
"chart": "Graphique",
|
||||
"charts": "Graphiques",
|
||||
"choices": "Choix",
|
||||
"choose_organization": "Choisir l'organisation",
|
||||
"choose_workspace": "Choisir un projet",
|
||||
@@ -201,8 +196,6 @@
|
||||
"created_by": "Créé par",
|
||||
"customer_success": "Succès Client",
|
||||
"dark_overlay": "Foncée",
|
||||
"dashboard": "Tableau de bord",
|
||||
"dashboards": "Tableaux de bord",
|
||||
"date": "Date",
|
||||
"days": "jours",
|
||||
"default": "Par défaut",
|
||||
@@ -256,7 +249,6 @@
|
||||
"hidden": "Caché",
|
||||
"hidden_field": "Champ caché",
|
||||
"hidden_fields": "Champs cachés",
|
||||
"hide": "Masquer",
|
||||
"hide_column": "Cacher la colonne",
|
||||
"id": "ID",
|
||||
"image": "Image",
|
||||
@@ -303,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas – tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
|
||||
"mobile_overlay_title": "Oups, écran minuscule détecté!",
|
||||
"months": "mois",
|
||||
"more_options": "Plus d'options",
|
||||
"move_down": "Déplacer vers le bas",
|
||||
"move_up": "Déplacer vers le haut",
|
||||
"multiple_languages": "Plusieurs langues",
|
||||
@@ -315,7 +306,6 @@
|
||||
"no": "Non",
|
||||
"no_actions_found": "Aucune action trouvée",
|
||||
"no_background_image_found": "Aucune image de fond trouvée.",
|
||||
"no_changes": "Aucune modification",
|
||||
"no_code": "Sans code",
|
||||
"no_files_uploaded": "Aucun fichier n'a été téléchargé.",
|
||||
"no_overlay": "Aucune superposition",
|
||||
@@ -337,7 +327,6 @@
|
||||
"on": "Sur",
|
||||
"only_one_file_allowed": "Un seul fichier est autorisé",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
|
||||
"open_options": "Ouvrir les options",
|
||||
"option_id": "Identifiant de l'option",
|
||||
"option_ids": "Identifiants des options",
|
||||
"optional": "Facultatif",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
|
||||
"read_docs": "Lire la documentation",
|
||||
"recipients": "Destinataires",
|
||||
"refresh": "Actualiser",
|
||||
"remove": "Retirer",
|
||||
"remove_from_team": "Retirer de l'équipe",
|
||||
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "Enregistrer les modifications",
|
||||
"saving": "Sauvegarder",
|
||||
"search": "Recherche",
|
||||
"search_charts": "Rechercher des graphiques...",
|
||||
"security": "Sécurité",
|
||||
"segment": "Segmenter",
|
||||
"segments": "Segments",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.",
|
||||
"your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "OU",
|
||||
"add_chart_to_dashboard": "Ajouter le graphique au tableau de bord",
|
||||
"add_chart_to_dashboard_description": "Sélectionnez un tableau de bord pour y ajouter ce graphique. Le graphique sera enregistré automatiquement.",
|
||||
"add_filter": "Ajouter un filtre",
|
||||
"add_to_dashboard": "Ajouter au tableau de bord",
|
||||
"advanced_chart_builder_config_prompt": "Configurez votre graphique et cliquez sur « Exécuter la requête » pour prévisualiser",
|
||||
"ai_query_placeholder": "ex. Combien d'utilisateurs se sont inscrits la semaine dernière ?",
|
||||
"ai_query_section_description": "Décrivez ce que vous souhaitez voir et laissez l'IA créer le graphique.",
|
||||
"ai_query_section_title": "Interrogez vos données",
|
||||
"and_filter_logic": "ET",
|
||||
"apply_changes": "Appliquer les modifications",
|
||||
"chart": "Graphique",
|
||||
"chart_added_to_dashboard": "Graphique ajouté au tableau de bord !",
|
||||
"chart_builder_choose_chart_type": "Choisir le type de graphique",
|
||||
"chart_data": "Données du graphique",
|
||||
"chart_data_tab": "Données",
|
||||
"chart_deleted_successfully": "Graphique supprimé avec succès",
|
||||
"chart_deletion_error": "Échec de la suppression du graphique",
|
||||
"chart_duplicated_successfully": "Graphique dupliqué avec succès",
|
||||
"chart_duplication_error": "Échec de la duplication du graphique",
|
||||
"chart_name": "Nom du graphique",
|
||||
"chart_name_placeholder": "Nom du graphique",
|
||||
"chart_preview": "Aperçu du graphique",
|
||||
"chart_render_error": "Une erreur s'est produite lors du rendu de ce graphique.",
|
||||
"chart_saved_successfully": "Graphique enregistré avec succès !",
|
||||
"chart_type_area": "Graphique en aires",
|
||||
"chart_type_bar": "Graphique à barres",
|
||||
"chart_type_big_number": "Grand nombre",
|
||||
"chart_type_line": "Graphique linéaire",
|
||||
"chart_type_not_supported": "Le type de graphique « {{chartType}} » n'est pas encore pris en charge",
|
||||
"chart_type_pie": "Graphique circulaire",
|
||||
"chart_updated_successfully": "Graphique mis à jour avec succès !",
|
||||
"configure_description": "Modifiez le type de graphique et d'autres paramètres pour cette visualisation.",
|
||||
"configure_title": "Configurer le graphique",
|
||||
"configure_type_label": "Type de graphique",
|
||||
"contains": "contient",
|
||||
"create_chart": "Créer un graphique",
|
||||
"create_chart_description": "Utilisez l'IA pour générer un graphique ou créez-en un manuellement.",
|
||||
"create_chart_with_ai": "Créer un graphique avec l'IA",
|
||||
"custom_range": "Plage personnalisée",
|
||||
"dashboard": "Tableau de bord",
|
||||
"dashboard_select_placeholder": "Sélectionnez un tableau de bord",
|
||||
"data_label": "Données",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "30 derniers jours",
|
||||
"date_preset_last_7_days": "7 derniers jours",
|
||||
"date_preset_last_month": "Le mois dernier",
|
||||
"date_preset_this_month": "Ce mois-ci",
|
||||
"date_preset_this_quarter": "Ce trimestre",
|
||||
"date_preset_this_year": "Cette année",
|
||||
"date_preset_today": "Aujourd'hui",
|
||||
"date_preset_yesterday": "Hier",
|
||||
"date_range": "Plage de dates",
|
||||
"delete_chart_confirmation": "Êtes-vous sûr de vouloir supprimer ce graphique ?",
|
||||
"dimensions": "Dimensions",
|
||||
"dimensions_toggle_description": "Groupe les données par sentiment, type de question et autres dimensions.",
|
||||
"edit_chart_description": "Consultez et modifiez la configuration de votre graphique.",
|
||||
"edit_chart_title": "Modifier le graphique",
|
||||
"enable_time_dimension": "Activer la dimension temporelle",
|
||||
"end_date": "Date de fin",
|
||||
"enter_a_name_for_your_chart": "Saisissez un nom pour votre graphique afin de l'enregistrer.",
|
||||
"enter_value": "Saisissez une valeur",
|
||||
"equals": "égal",
|
||||
"failed_to_add_chart_to_dashboard": "Échec de l'ajout du graphique au tableau de bord",
|
||||
"failed_to_execute_query": "Échec de l'exécution de la requête",
|
||||
"failed_to_load_chart": "Échec du chargement du graphique",
|
||||
"failed_to_load_chart_data": "Échec du chargement des données du graphique",
|
||||
"failed_to_save_chart": "Échec de l'enregistrement du graphique",
|
||||
"field": "Champ",
|
||||
"field_label_average_score": "Score moyen",
|
||||
"field_label_collected_at": "Collecté le",
|
||||
"field_label_count": "Nombre",
|
||||
"field_label_detractor_count": "Nombre de détracteurs",
|
||||
"field_label_emotion": "Émotion",
|
||||
"field_label_field_type": "Type de champ",
|
||||
"field_label_nps_score": "Score NPS",
|
||||
"field_label_nps_value": "Valeur NPS",
|
||||
"field_label_passive_count": "Nombre de passifs",
|
||||
"field_label_promoter_count": "Nombre de promoteurs",
|
||||
"field_label_response_id": "ID de réponse",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Nom de la source",
|
||||
"field_label_source_type": "Type de source",
|
||||
"field_label_topic": "Sujet",
|
||||
"field_label_user_identifier": "Identifiant utilisateur",
|
||||
"filter_data": "Filtrer les données",
|
||||
"filters": "Filtres",
|
||||
"filters_toggle_description": "Inclure uniquement les données qui répondent aux conditions suivantes.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularité",
|
||||
"granularity_day": "Jour",
|
||||
"granularity_hour": "Heure",
|
||||
"granularity_month": "Mois",
|
||||
"granularity_quarter": "Trimestre",
|
||||
"granularity_week": "Semaine",
|
||||
"granularity_year": "Année",
|
||||
"greater_than": "supérieur à",
|
||||
"greater_than_or_equal": "supérieur ou égal à",
|
||||
"group_by": "Regrouper par",
|
||||
"group_by_description": "Décompose tes données selon une ou plusieurs dimensions. L'ordre est important si tu choisis plusieurs dimensions.",
|
||||
"group_data": "Grouper les données",
|
||||
"is_not_set": "n'est pas défini",
|
||||
"is_set": "est défini",
|
||||
"less_than": "inférieur à",
|
||||
"less_than_or_equal": "inférieur ou égal à",
|
||||
"measures": "Mesures",
|
||||
"no_charts_found": "Aucun graphique trouvé.",
|
||||
"no_dashboards_available": "Aucun tableau de bord disponible",
|
||||
"no_dashboards_create_first": "Créez d'abord un tableau de bord pour y ajouter des graphiques.",
|
||||
"no_data_available": "Aucune donnée disponible",
|
||||
"no_data_returned": "Aucune donnée retournée par la requête",
|
||||
"no_data_returned_for_chart": "Aucune donnée retournée pour le graphique",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Aucun (filtre uniquement)",
|
||||
"no_valid_data_to_display": "Aucune donnée valide à afficher",
|
||||
"not_contains": "ne contient pas",
|
||||
"not_equals": "n'est pas égal à",
|
||||
"open_chart": "Ouvrir le graphique {{name}}",
|
||||
"open_options": "Ouvrir les options du graphique",
|
||||
"or_filter_logic": "OU",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Veuillez saisir un nom de graphique",
|
||||
"please_enter_filter_values": "Merci de saisir des valeurs pour tous les filtres",
|
||||
"please_select_at_least_one_dimension": "Merci de sélectionner au moins une dimension ou de désactiver le groupement",
|
||||
"please_select_at_least_one_measure": "Veuillez sélectionner au moins une mesure",
|
||||
"please_select_dashboard": "Veuillez sélectionner un tableau de bord",
|
||||
"predefined_measures": "Mesures prédéfinies",
|
||||
"preset": "Préréglage",
|
||||
"query_executed_successfully": "Requête exécutée avec succès",
|
||||
"reset_to_ai_suggestion": "Réinitialiser à la suggestion IA",
|
||||
"save_chart": "Enregistrer le graphique",
|
||||
"save_chart_dialog_title": "Enregistrer le graphique",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Sélectionner les dimensions...",
|
||||
"select_field": "Sélectionner un champ",
|
||||
"select_measures": "Sélectionner les mesures...",
|
||||
"select_preset": "Sélectionner un préréglage",
|
||||
"showing_first_n_of": "Affichage des {{n}} premières lignes sur {{count}}",
|
||||
"start_date": "Date de début",
|
||||
"time_dimension": "Dimension temporelle",
|
||||
"time_dimension_title": "Ajouter un groupement temporel",
|
||||
"time_dimension_toggle_description": "Surveille les tendances dans le temps."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Ajouter {count} graphique(s)",
|
||||
"charts_add_failed": "Échec de l'ajout des graphiques au tableau de bord",
|
||||
"charts_add_partial_failure": "Échec de l'ajout de {count} graphique(s)",
|
||||
"charts_added_to_dashboard": "Graphiques ajoutés au tableau de bord",
|
||||
"charts_load_failed": "Échec du chargement des graphiques",
|
||||
"create_dashboard": "Créer un tableau de bord",
|
||||
"create_dashboard_description": "Saisissez un nom pour votre nouveau tableau de bord.",
|
||||
"create_failed": "Échec de la création du tableau de bord",
|
||||
"create_success": "Tableau de bord créé avec succès !",
|
||||
"dashboard": "Tableau de bord",
|
||||
"dashboard_delete_confirmation": "Es-tu sûr(e) de vouloir supprimer ce tableau de bord ? Cette action est irréversible.",
|
||||
"dashboard_name": "Nom du tableau de bord",
|
||||
"dashboard_name_placeholder": "Mon tableau de bord",
|
||||
"dashboard_name_required": "Le nom du tableau de bord est requis",
|
||||
"dashboard_save_failed": "Échec de l'enregistrement du tableau de bord",
|
||||
"dashboard_saved": "Tableau de bord enregistré avec succès",
|
||||
"delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce tableau de bord ? Cette action est irréversible.",
|
||||
"delete_failed": "Échec de la suppression du tableau de bord",
|
||||
"delete_success": "Tableau de bord supprimé avec succès",
|
||||
"duplicate_failed": "Échec de la duplication du tableau de bord",
|
||||
"duplicate_success": "Tableau de bord dupliqué avec succès !",
|
||||
"failed_to_load_chart_data": "Échec du chargement des données du graphique",
|
||||
"no_charts_available_description": "Il n'y a aucun graphique pouvant être ajouté à ce tableau de bord. Soit aucun graphique n'existe encore, soit tous les graphiques existants ont déjà été ajoutés. Rendez-vous sur la page Graphiques pour créer de nouveaux graphiques.",
|
||||
"no_charts_to_add_message": "Aucun graphique à ajouter à ce tableau de bord.",
|
||||
"no_dashboards_found": "Aucun tableau de bord trouvé.",
|
||||
"no_data_message": "Aucune donnée. Il n'y a actuellement aucune information à afficher. Ajoute des graphiques pour construire ton tableau de bord.",
|
||||
"please_enter_name": "Veuillez saisir un nom de tableau de bord"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Ajouter une clé API",
|
||||
"api_key": "Clé API",
|
||||
|
||||
@@ -125,8 +125,6 @@
|
||||
"activity": "Tevékenység",
|
||||
"add": "Hozzáadás",
|
||||
"add_action": "Művelet hozzáadása",
|
||||
"add_charts": "Diagramok hozzáadása",
|
||||
"add_existing_chart_description": "Keressen és válasszon diagramokat a műszerfalhoz való hozzáadáshoz.",
|
||||
"add_filter": "Szűrő hozzáadása",
|
||||
"add_logo": "Logo hozzáadása",
|
||||
"add_member": "Tag hozzáadása",
|
||||
@@ -138,7 +136,6 @@
|
||||
"allow": "Engedélyezés",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Lehetővé tétel a felhasználók számára, hogy a kérdőíven kívülre kattintva kilépjenek",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "{type} típusok törlésekor ismeretlen hiba történt",
|
||||
"analysis": "Elemzés",
|
||||
"and": "És",
|
||||
"anonymous": "Névtelen",
|
||||
"api_keys": "API-kulcsok",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "Középre helyezett kizárólagos",
|
||||
"change_organization": "Szervezet módosítása",
|
||||
"change_workspace": "Munkaterület módosítása",
|
||||
"chart": "Diagram",
|
||||
"charts": "Diagramok",
|
||||
"choices": "Választási lehetőségek",
|
||||
"choose_organization": "Szervezet kiválasztása",
|
||||
"choose_workspace": "Munkaterület kiválasztása",
|
||||
@@ -201,8 +196,6 @@
|
||||
"created_by": "Létrehozta",
|
||||
"customer_success": "Ügyfélsiker",
|
||||
"dark_overlay": "Sötét rávetítés",
|
||||
"dashboard": "Vezérlőpult",
|
||||
"dashboards": "Irányítópultok",
|
||||
"date": "Dátum",
|
||||
"days": "nap",
|
||||
"default": "Alapértelmezett",
|
||||
@@ -256,7 +249,6 @@
|
||||
"hidden": "Rejtett",
|
||||
"hidden_field": "Rejtett mező",
|
||||
"hidden_fields": "Rejtett mezők",
|
||||
"hide": "Elrejtés",
|
||||
"hide_column": "Oszlop elrejtése",
|
||||
"id": "Azonosító",
|
||||
"image": "Kép",
|
||||
@@ -303,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "Ne aggódjon – a kérdőívei minden eszközön és képernyőméretnél remekül néznek ki!",
|
||||
"mobile_overlay_title": "Hoppá, apró képernyő észlelve!",
|
||||
"months": "hónap",
|
||||
"more_options": "További lehetőségek",
|
||||
"move_down": "Mozgatás le",
|
||||
"move_up": "Mozgatás fel",
|
||||
"multiple_languages": "Több nyelv",
|
||||
@@ -315,7 +306,6 @@
|
||||
"no": "Nem",
|
||||
"no_actions_found": "Nem találhatók műveletek",
|
||||
"no_background_image_found": "Nem található háttérkép.",
|
||||
"no_changes": "Nincsenek változások",
|
||||
"no_code": "Kód nélkül",
|
||||
"no_files_uploaded": "Nem lettek fájlok feltöltve",
|
||||
"no_overlay": "Nincs rávetítés",
|
||||
@@ -337,7 +327,6 @@
|
||||
"on": "Be",
|
||||
"only_one_file_allowed": "Csak egy fájl engedélyezett",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
|
||||
"open_options": "Beállítások megnyitása",
|
||||
"option_id": "Választásazonosító",
|
||||
"option_ids": "Választásazonosítók",
|
||||
"optional": "Elhagyható",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "A bizonyos feltételeknek megfelelő résztvevőktől kapott válaszok számának korlátozása.",
|
||||
"read_docs": "Dokumentáció elolvasása",
|
||||
"recipients": "Címzettek",
|
||||
"refresh": "Frissítés",
|
||||
"remove": "Eltávolítás",
|
||||
"remove_from_team": "Eltávolítás a csapatból",
|
||||
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "Változtatások mentése",
|
||||
"saving": "Mentés",
|
||||
"search": "Keresés",
|
||||
"search_charts": "Diagramok keresése...",
|
||||
"security": "Biztonság",
|
||||
"segment": "Szakasz",
|
||||
"segments": "Szakaszok",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "A kérdőív ezen az URL-en jelenne meg.",
|
||||
"your_survey_would_not_be_shown": "A kérdőív nem jelenne meg."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "VAGY",
|
||||
"add_chart_to_dashboard": "Diagram hozzáadása a vezérlőpulthoz",
|
||||
"add_chart_to_dashboard_description": "Válassz egy vezérlőpultot, amelyhez hozzáadod ezt a diagramot. A diagram automatikusan mentésre kerül.",
|
||||
"add_filter": "Szűrő hozzáadása",
|
||||
"add_to_dashboard": "Hozzáadás a vezérlőpulthoz",
|
||||
"advanced_chart_builder_config_prompt": "Állítsd be a diagramot, és kattints a \"Lekérdezés futtatása\" gombra az előnézethez",
|
||||
"ai_query_placeholder": "pl. Hány felhasználó regisztrált a múlt héten?",
|
||||
"ai_query_section_description": "Írd le, mit szeretnél látni, és hagyd, hogy az AI elkészítse a diagramot.",
|
||||
"ai_query_section_title": "Kérdezd meg az adataidat",
|
||||
"and_filter_logic": "ÉS",
|
||||
"apply_changes": "Módosítások alkalmazása",
|
||||
"chart": "Diagram",
|
||||
"chart_added_to_dashboard": "A diagram hozzáadva a vezérlőpulthoz!",
|
||||
"chart_builder_choose_chart_type": "Válassz diagramtípust",
|
||||
"chart_data": "Diagram adatai",
|
||||
"chart_data_tab": "Adatok",
|
||||
"chart_deleted_successfully": "A diagram sikeresen törölve",
|
||||
"chart_deletion_error": "A diagram törlése sikertelen",
|
||||
"chart_duplicated_successfully": "A diagram sikeresen duplikálva",
|
||||
"chart_duplication_error": "A diagram duplikálása sikertelen",
|
||||
"chart_name": "Diagram neve",
|
||||
"chart_name_placeholder": "Diagram neve",
|
||||
"chart_preview": "Diagram előnézete",
|
||||
"chart_render_error": "Hiba történt a diagram megjelenítése során.",
|
||||
"chart_saved_successfully": "A diagram sikeresen mentve!",
|
||||
"chart_type_area": "Területdiagram",
|
||||
"chart_type_bar": "Oszlopdiagram",
|
||||
"chart_type_big_number": "Nagy szám",
|
||||
"chart_type_line": "Vonaldiagram",
|
||||
"chart_type_not_supported": "A(z) \"{{chartType}}\" diagramtípus még nem támogatott",
|
||||
"chart_type_pie": "Kördiagram",
|
||||
"chart_updated_successfully": "A diagram sikeresen frissítve!",
|
||||
"configure_description": "Módosítsd a diagram típusát és egyéb beállításait ehhez a vizualizációhoz.",
|
||||
"configure_title": "Diagram konfigurálása",
|
||||
"configure_type_label": "Diagram típusa",
|
||||
"contains": "tartalmazza",
|
||||
"create_chart": "Diagram létrehozása",
|
||||
"create_chart_description": "Használj mesterséges intelligenciát diagram generálásához, vagy készíts egyet manuálisan.",
|
||||
"create_chart_with_ai": "Diagram létrehozása mesterséges intelligenciával",
|
||||
"custom_range": "Egyéni tartomány",
|
||||
"dashboard": "Vezérlőpult",
|
||||
"dashboard_select_placeholder": "Válassz egy vezérlőpultot",
|
||||
"data_label": "Adat",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Elmúlt 30 nap",
|
||||
"date_preset_last_7_days": "Elmúlt 7 nap",
|
||||
"date_preset_last_month": "Elmúlt hónap",
|
||||
"date_preset_this_month": "Ez a hónap",
|
||||
"date_preset_this_quarter": "Ez a negyedév",
|
||||
"date_preset_this_year": "Ez az év",
|
||||
"date_preset_today": "Ma",
|
||||
"date_preset_yesterday": "Tegnap",
|
||||
"date_range": "Dátumtartomány",
|
||||
"delete_chart_confirmation": "Biztosan törölni szeretnéd ezt a diagramot?",
|
||||
"dimensions": "Dimenziók",
|
||||
"dimensions_toggle_description": "Adatok csoportosítása hangulat, kérdéstípus és egyéb dimenziók szerint.",
|
||||
"edit_chart_description": "Tekintsd meg és szerkeszd a diagram konfigurációját.",
|
||||
"edit_chart_title": "Diagram szerkesztése",
|
||||
"enable_time_dimension": "Időbeli dimenzió engedélyezése",
|
||||
"end_date": "Befejezési dátum",
|
||||
"enter_a_name_for_your_chart": "Add meg a diagram nevét a mentéshez.",
|
||||
"enter_value": "Adj meg értéket",
|
||||
"equals": "egyenlő",
|
||||
"failed_to_add_chart_to_dashboard": "A diagram hozzáadása a vezérlőpulthoz sikertelen",
|
||||
"failed_to_execute_query": "A lekérdezés végrehajtása sikertelen",
|
||||
"failed_to_load_chart": "A diagram betöltése sikertelen",
|
||||
"failed_to_load_chart_data": "A diagram adatainak betöltése sikertelen",
|
||||
"failed_to_save_chart": "A diagram mentése sikertelen",
|
||||
"field": "Mező",
|
||||
"field_label_average_score": "Átlagos pontszám",
|
||||
"field_label_collected_at": "Gyűjtve",
|
||||
"field_label_count": "Darabszám",
|
||||
"field_label_detractor_count": "Kritikusok száma",
|
||||
"field_label_emotion": "Érzelem",
|
||||
"field_label_field_type": "Mező típusa",
|
||||
"field_label_nps_score": "NPS pontszám",
|
||||
"field_label_nps_value": "NPS érték",
|
||||
"field_label_passive_count": "Passzívak száma",
|
||||
"field_label_promoter_count": "Támogatók száma",
|
||||
"field_label_response_id": "Válaszazonosító",
|
||||
"field_label_sentiment": "Hangulat",
|
||||
"field_label_source_name": "Forrás neve",
|
||||
"field_label_source_type": "Forrás típusa",
|
||||
"field_label_topic": "Téma",
|
||||
"field_label_user_identifier": "Felhasználóazonosító",
|
||||
"filter_data": "Adatok szűrése",
|
||||
"filters": "Szűrők",
|
||||
"filters_toggle_description": "Csak azokat az adatokat tartalmazza, amelyek megfelelnek a következő feltételeknek.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Részletesség",
|
||||
"granularity_day": "Nap",
|
||||
"granularity_hour": "óra",
|
||||
"granularity_month": "hónap",
|
||||
"granularity_quarter": "negyedév",
|
||||
"granularity_week": "hét",
|
||||
"granularity_year": "év",
|
||||
"greater_than": "nagyobb mint",
|
||||
"greater_than_or_equal": "nagyobb vagy egyenlő",
|
||||
"group_by": "Csoportosítás",
|
||||
"group_by_description": "Bontsa le adatait egy vagy több dimenzió szerint. A sorrend fontos, ha több dimenziót választ.",
|
||||
"group_data": "Adatok csoportosítása",
|
||||
"is_not_set": "nincs beállítva",
|
||||
"is_set": "beállítva",
|
||||
"less_than": "kisebb mint",
|
||||
"less_than_or_equal": "kisebb vagy egyenlő",
|
||||
"measures": "Mérőszámok",
|
||||
"no_charts_found": "Nem található diagram.",
|
||||
"no_dashboards_available": "Nincsenek elérhető vezérlőpultok",
|
||||
"no_dashboards_create_first": "Először hozz létre egy vezérlőpultot, hogy diagramokat adhass hozzá.",
|
||||
"no_data_available": "Nincsenek elérhető adatok",
|
||||
"no_data_returned": "A lekérdezés nem adott vissza adatokat",
|
||||
"no_data_returned_for_chart": "A diagram nem adott vissza adatokat",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Nincs (csak szűrés)",
|
||||
"no_valid_data_to_display": "Nincsenek megjeleníthető érvényes adatok",
|
||||
"not_contains": "nem tartalmazza",
|
||||
"not_equals": "nem egyenlő",
|
||||
"open_chart": "{{name}} diagram megnyitása",
|
||||
"open_options": "Diagram beállításainak megnyitása",
|
||||
"or_filter_logic": "VAGY",
|
||||
"original": "Eredeti",
|
||||
"please_enter_chart_name": "Kérjük, add meg a diagram nevét",
|
||||
"please_enter_filter_values": "Kérem, adjon meg értékeket az összes szűrőhöz",
|
||||
"please_select_at_least_one_dimension": "Kérem, válasszon legalább egy dimenziót, vagy tiltsa le a csoportosítást",
|
||||
"please_select_at_least_one_measure": "Kérjük, válassz ki legalább egy mérőszámot",
|
||||
"please_select_dashboard": "Kérjük, válassz egy vezérlőpultot",
|
||||
"predefined_measures": "Előre definiált mérőszámok",
|
||||
"preset": "Előbeállítás",
|
||||
"query_executed_successfully": "Lekérdezés sikeresen végrehajtva",
|
||||
"reset_to_ai_suggestion": "Visszaállítás AI javaslatra",
|
||||
"save_chart": "Diagram mentése",
|
||||
"save_chart_dialog_title": "Diagram mentése",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Dimenziók kiválasztása...",
|
||||
"select_field": "Mező kiválasztása",
|
||||
"select_measures": "Mérőszámok kiválasztása...",
|
||||
"select_preset": "Előbeállítás kiválasztása",
|
||||
"showing_first_n_of": "Az első {{n}} sor megjelenítése {{count}} sorból",
|
||||
"start_date": "Kezdési dátum",
|
||||
"time_dimension": "Időbeli dimenzió",
|
||||
"time_dimension_title": "Időalapú csoportosítás hozzáadása",
|
||||
"time_dimension_toggle_description": "Trendek figyelemmel kísérése az idő függvényében."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} diagram hozzáadása",
|
||||
"charts_add_failed": "A diagramok műszerfalhoz való hozzáadása sikertelen",
|
||||
"charts_add_partial_failure": "{count} diagram hozzáadása sikertelen",
|
||||
"charts_added_to_dashboard": "Diagramok hozzáadva a műszerfalhoz",
|
||||
"charts_load_failed": "A diagramok betöltése sikertelen",
|
||||
"create_dashboard": "Műszerfal létrehozása",
|
||||
"create_dashboard_description": "Adjon nevet az új vezérlőpultnak.",
|
||||
"create_failed": "A vezérlőpult létrehozása sikertelen",
|
||||
"create_success": "A vezérlőpult sikeresen létrehozva!",
|
||||
"dashboard": "Műszerfal",
|
||||
"dashboard_delete_confirmation": "Biztos benne, hogy törölni kívánja ezt a műszerfalat? Ez a művelet nem vonható vissza.",
|
||||
"dashboard_name": "Vezérlőpult neve",
|
||||
"dashboard_name_placeholder": "Saját vezérlőpult",
|
||||
"dashboard_name_required": "Az irányítópult neve kötelező",
|
||||
"dashboard_save_failed": "Az irányítópult mentése sikertelen",
|
||||
"dashboard_saved": "Az irányítópult sikeresen mentve",
|
||||
"delete_confirmation": "Biztosan törölni szeretné ezt a vezérlőpultot? Ez a művelet nem vonható vissza.",
|
||||
"delete_failed": "A vezérlőpult törlése sikertelen",
|
||||
"delete_success": "A vezérlőpult sikeresen törölve",
|
||||
"duplicate_failed": "A vezérlőpult másolása sikertelen",
|
||||
"duplicate_success": "A vezérlőpult sikeresen lemásolva!",
|
||||
"failed_to_load_chart_data": "A diagram adatainak betöltése sikertelen",
|
||||
"no_charts_available_description": "Nincsenek diagramok, amelyek hozzáadhatók ehhez az irányítópulthoz. Vagy még nem léteznek diagramok, vagy az összes meglévő diagram már hozzá lett adva. Látogassa meg a Diagramok oldalt új diagramok létrehozásához.",
|
||||
"no_charts_to_add_message": "Nincsenek hozzáadható diagramok ehhez az irányítópulthoz.",
|
||||
"no_dashboards_found": "Nem található vezérlőpult.",
|
||||
"no_data_message": "Nincsenek adatok. Jelenleg nincsenek megjeleníthető információk. Adjon hozzá diagramokat az irányítópult felépítéséhez.",
|
||||
"please_enter_name": "Kérjük, adjon nevet a vezérlőpultnak"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "API-kulcs hozzáadása",
|
||||
"api_key": "API-kulcs",
|
||||
|
||||
@@ -125,8 +125,6 @@
|
||||
"activity": "アクティビティ",
|
||||
"add": "追加",
|
||||
"add_action": "アクションを追加",
|
||||
"add_charts": "グラフを追加",
|
||||
"add_existing_chart_description": "このダッシュボードに追加するグラフを検索して選択してください。",
|
||||
"add_filter": "フィルターを追加",
|
||||
"add_logo": "ロゴを追加",
|
||||
"add_member": "メンバーを追加",
|
||||
@@ -138,7 +136,6 @@
|
||||
"allow": "許可",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
|
||||
"analysis": "分析",
|
||||
"and": "および",
|
||||
"anonymous": "匿名",
|
||||
"api_keys": "APIキー",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "中央モーダル",
|
||||
"change_organization": "組織を変更",
|
||||
"change_workspace": "ワークスペースを変更",
|
||||
"chart": "チャート",
|
||||
"charts": "チャート",
|
||||
"choices": "選択肢",
|
||||
"choose_organization": "組織を選択",
|
||||
"choose_workspace": "ワークスペースを選択",
|
||||
@@ -201,8 +196,6 @@
|
||||
"created_by": "作成者",
|
||||
"customer_success": "カスタマーサクセス",
|
||||
"dark_overlay": "暗いオーバーレイ",
|
||||
"dashboard": "ダッシュボード",
|
||||
"dashboards": "ダッシュボード",
|
||||
"date": "日付",
|
||||
"days": "日",
|
||||
"default": "デフォルト",
|
||||
@@ -256,7 +249,6 @@
|
||||
"hidden": "非表示",
|
||||
"hidden_field": "非表示フィールド",
|
||||
"hidden_fields": "非表示フィールド",
|
||||
"hide": "非表示",
|
||||
"hide_column": "列を非表示",
|
||||
"id": "ID",
|
||||
"image": "画像",
|
||||
@@ -303,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
|
||||
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
|
||||
"months": "ヶ月",
|
||||
"more_options": "その他のオプション",
|
||||
"move_down": "下に移動",
|
||||
"move_up": "上に移動",
|
||||
"multiple_languages": "多言語",
|
||||
@@ -315,7 +306,6 @@
|
||||
"no": "いいえ",
|
||||
"no_actions_found": "アクションが見つかりません",
|
||||
"no_background_image_found": "背景画像が見つかりません。",
|
||||
"no_changes": "変更なし",
|
||||
"no_code": "ノーコード",
|
||||
"no_files_uploaded": "ファイルがアップロードされていません",
|
||||
"no_overlay": "オーバーレイなし",
|
||||
@@ -337,7 +327,6 @@
|
||||
"on": "オン",
|
||||
"only_one_file_allowed": "ファイルは1つのみ許可されています",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "このアクションを実行できるのは、オーナーと管理者のみです。",
|
||||
"open_options": "オプションを開く",
|
||||
"option_id": "オプションID",
|
||||
"option_ids": "オプションID",
|
||||
"optional": "任意",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "特定の基準を満たす参加者からの回答数を制限する",
|
||||
"read_docs": "ドキュメントを読む",
|
||||
"recipients": "受信者",
|
||||
"refresh": "更新",
|
||||
"remove": "削除",
|
||||
"remove_from_team": "チームから削除",
|
||||
"reorder_and_hide_columns": "列の並び替えと非表示",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "変更を保存",
|
||||
"saving": "保存中",
|
||||
"search": "検索",
|
||||
"search_charts": "グラフを検索...",
|
||||
"security": "セキュリティ",
|
||||
"segment": "セグメント",
|
||||
"segments": "セグメント",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "あなたのフォームはこのURLに表示されます。",
|
||||
"your_survey_would_not_be_shown": "あなたのフォームは表示されません。"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "OR",
|
||||
"add_chart_to_dashboard": "ダッシュボードにチャートを追加",
|
||||
"add_chart_to_dashboard_description": "このチャートを追加するダッシュボードを選択してください。チャートは自動的に保存されます。",
|
||||
"add_filter": "フィルターを追加",
|
||||
"add_to_dashboard": "ダッシュボードに追加",
|
||||
"advanced_chart_builder_config_prompt": "チャートを設定して「クエリを実行」をクリックしてプレビューを表示",
|
||||
"ai_query_placeholder": "例: 先週何人のユーザーが登録しましたか?",
|
||||
"ai_query_section_description": "表示したい内容を説明すると、AIがチャートを作成します。",
|
||||
"ai_query_section_title": "データに質問する",
|
||||
"and_filter_logic": "AND",
|
||||
"apply_changes": "変更を適用",
|
||||
"chart": "チャート",
|
||||
"chart_added_to_dashboard": "チャートをダッシュボードに追加しました!",
|
||||
"chart_builder_choose_chart_type": "チャートタイプを選択",
|
||||
"chart_data": "チャートデータ",
|
||||
"chart_data_tab": "データ",
|
||||
"chart_deleted_successfully": "チャートを削除しました",
|
||||
"chart_deletion_error": "チャートの削除に失敗しました",
|
||||
"chart_duplicated_successfully": "チャートを複製しました",
|
||||
"chart_duplication_error": "チャートの複製に失敗しました",
|
||||
"chart_name": "チャート名",
|
||||
"chart_name_placeholder": "チャート名",
|
||||
"chart_preview": "チャートプレビュー",
|
||||
"chart_render_error": "このチャートの表示中に問題が発生しました。",
|
||||
"chart_saved_successfully": "チャートを保存しました!",
|
||||
"chart_type_area": "エリアチャート",
|
||||
"chart_type_bar": "棒グラフ",
|
||||
"chart_type_big_number": "大きな数値",
|
||||
"chart_type_line": "折れ線グラフ",
|
||||
"chart_type_not_supported": "チャートタイプ「{{chartType}}」はまだサポートされていません",
|
||||
"chart_type_pie": "円グラフ",
|
||||
"chart_updated_successfully": "チャートを更新しました!",
|
||||
"configure_description": "このビジュアライゼーションのチャートタイプやその他の設定を変更します。",
|
||||
"configure_title": "チャートを設定",
|
||||
"configure_type_label": "チャートタイプ",
|
||||
"contains": "を含む",
|
||||
"create_chart": "グラフを作成",
|
||||
"create_chart_description": "AIを使用してチャートを生成するか、手動で作成します。",
|
||||
"create_chart_with_ai": "AIでグラフを作成",
|
||||
"custom_range": "カスタム範囲",
|
||||
"dashboard": "ダッシュボード",
|
||||
"dashboard_select_placeholder": "ダッシュボードを選択",
|
||||
"data_label": "データ",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "過去30日間",
|
||||
"date_preset_last_7_days": "過去7日間",
|
||||
"date_preset_last_month": "先月",
|
||||
"date_preset_this_month": "今月",
|
||||
"date_preset_this_quarter": "今四半期",
|
||||
"date_preset_this_year": "今年",
|
||||
"date_preset_today": "今日",
|
||||
"date_preset_yesterday": "昨日",
|
||||
"date_range": "日付範囲",
|
||||
"delete_chart_confirmation": "このチャートを削除してもよろしいですか?",
|
||||
"dimensions": "ディメンション",
|
||||
"dimensions_toggle_description": "センチメント、質問タイプ、その他のディメンションでデータをグループ化します。",
|
||||
"edit_chart_description": "チャート設定を表示および編集します。",
|
||||
"edit_chart_title": "チャートを編集",
|
||||
"enable_time_dimension": "時間ディメンションを有効化",
|
||||
"end_date": "終了日",
|
||||
"enter_a_name_for_your_chart": "チャートを保存するには名前を入力してください。",
|
||||
"enter_value": "値を入力",
|
||||
"equals": "と等しい",
|
||||
"failed_to_add_chart_to_dashboard": "ダッシュボードへのチャート追加に失敗しました",
|
||||
"failed_to_execute_query": "クエリの実行に失敗しました",
|
||||
"failed_to_load_chart": "チャートの読み込みに失敗しました",
|
||||
"failed_to_load_chart_data": "チャートデータの読み込みに失敗しました",
|
||||
"failed_to_save_chart": "チャートの保存に失敗しました",
|
||||
"field": "フィールド",
|
||||
"field_label_average_score": "平均スコア",
|
||||
"field_label_collected_at": "収集日時",
|
||||
"field_label_count": "カウント",
|
||||
"field_label_detractor_count": "批判者数",
|
||||
"field_label_emotion": "感情",
|
||||
"field_label_field_type": "フィールドタイプ",
|
||||
"field_label_nps_score": "NPSスコア",
|
||||
"field_label_nps_value": "NPS値",
|
||||
"field_label_passive_count": "中立者数",
|
||||
"field_label_promoter_count": "推奨者数",
|
||||
"field_label_response_id": "回答ID",
|
||||
"field_label_sentiment": "感情分析",
|
||||
"field_label_source_name": "ソース名",
|
||||
"field_label_source_type": "ソースタイプ",
|
||||
"field_label_topic": "トピック",
|
||||
"field_label_user_identifier": "ユーザー識別子",
|
||||
"filter_data": "データをフィルター",
|
||||
"filters": "フィルター",
|
||||
"filters_toggle_description": "以下の条件を満たすデータのみを含めます。",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "粒度",
|
||||
"granularity_day": "日",
|
||||
"granularity_hour": "時間",
|
||||
"granularity_month": "月",
|
||||
"granularity_quarter": "四半期",
|
||||
"granularity_week": "週",
|
||||
"granularity_year": "年",
|
||||
"greater_than": "より大きい",
|
||||
"greater_than_or_equal": "以上",
|
||||
"group_by": "グループ化",
|
||||
"group_by_description": "1つ以上のディメンションでデータを分類します。複数のディメンションを選択する場合、順序が重要です。",
|
||||
"group_data": "データをグループ化",
|
||||
"is_not_set": "設定されていない",
|
||||
"is_set": "設定されている",
|
||||
"less_than": "より小さい",
|
||||
"less_than_or_equal": "以下",
|
||||
"measures": "メジャー",
|
||||
"no_charts_found": "チャートが見つかりません。",
|
||||
"no_dashboards_available": "利用可能なダッシュボードがありません",
|
||||
"no_dashboards_create_first": "チャートを追加するには、まずダッシュボードを作成してください。",
|
||||
"no_data_available": "利用可能なデータがありません",
|
||||
"no_data_returned": "クエリからデータが返されませんでした",
|
||||
"no_data_returned_for_chart": "チャートのデータが返されませんでした",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "なし(フィルターのみ)",
|
||||
"no_valid_data_to_display": "表示する有効なデータがありません",
|
||||
"not_contains": "を含まない",
|
||||
"not_equals": "と等しくない",
|
||||
"open_chart": "チャート{{name}}を開く",
|
||||
"open_options": "チャートオプションを開く",
|
||||
"or_filter_logic": "OR",
|
||||
"original": "オリジナル",
|
||||
"please_enter_chart_name": "チャート名を入力してください",
|
||||
"please_enter_filter_values": "すべてのフィルターに値を入力してください",
|
||||
"please_select_at_least_one_dimension": "少なくとも1つのディメンションを選択するか、グループ化を無効にしてください",
|
||||
"please_select_at_least_one_measure": "少なくとも1つのメジャーを選択してください",
|
||||
"please_select_dashboard": "ダッシュボードを選択してください",
|
||||
"predefined_measures": "事前定義されたメジャー",
|
||||
"preset": "プリセット",
|
||||
"query_executed_successfully": "クエリが正常に実行されました",
|
||||
"reset_to_ai_suggestion": "AIの提案にリセット",
|
||||
"save_chart": "チャートを保存",
|
||||
"save_chart_dialog_title": "チャートを保存",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "ディメンションを選択...",
|
||||
"select_field": "フィールドを選択",
|
||||
"select_measures": "メジャーを選択...",
|
||||
"select_preset": "プリセットを選択",
|
||||
"showing_first_n_of": "{{count}}行中、最初の{{n}}行を表示",
|
||||
"start_date": "開始日",
|
||||
"time_dimension": "時間ディメンション",
|
||||
"time_dimension_title": "時間ベースのグループ化を追加",
|
||||
"time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count}個のグラフを追加",
|
||||
"charts_add_failed": "ダッシュボードへのグラフの追加に失敗しました",
|
||||
"charts_add_partial_failure": "{count}個のグラフの追加に失敗しました",
|
||||
"charts_added_to_dashboard": "グラフをダッシュボードに追加しました",
|
||||
"charts_load_failed": "グラフの読み込みに失敗しました",
|
||||
"create_dashboard": "ダッシュボードを作成",
|
||||
"create_dashboard_description": "新しいダッシュボードの名前を入力してください。",
|
||||
"create_failed": "ダッシュボードの作成に失敗しました",
|
||||
"create_success": "ダッシュボードを正常に作成しました!",
|
||||
"dashboard": "ダッシュボード",
|
||||
"dashboard_delete_confirmation": "このダッシュボードを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"dashboard_name": "ダッシュボード名",
|
||||
"dashboard_name_placeholder": "マイダッシュボード",
|
||||
"dashboard_name_required": "ダッシュボード名は必須です",
|
||||
"dashboard_save_failed": "ダッシュボードの保存に失敗しました",
|
||||
"dashboard_saved": "ダッシュボードを正常に保存しました",
|
||||
"delete_confirmation": "このダッシュボードを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"delete_failed": "ダッシュボードの削除に失敗しました",
|
||||
"delete_success": "ダッシュボードを正常に削除しました",
|
||||
"duplicate_failed": "ダッシュボードの複製に失敗しました",
|
||||
"duplicate_success": "ダッシュボードを正常に複製しました!",
|
||||
"failed_to_load_chart_data": "チャートデータの読み込みに失敗しました",
|
||||
"no_charts_available_description": "このダッシュボードに追加できるチャートがありません。チャートがまだ存在しないか、既存のチャートがすべて追加済みです。新しいチャートを作成するには、チャートページに移動してください。",
|
||||
"no_charts_to_add_message": "このダッシュボードに追加するチャートがありません。",
|
||||
"no_dashboards_found": "ダッシュボードが見つかりません。",
|
||||
"no_data_message": "データがありません。現在表示する情報がありません。ダッシュボードを構築するにはチャートを追加してください。",
|
||||
"please_enter_name": "ダッシュボード名を入力してください"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "APIキーを追加",
|
||||
"api_key": "APIキー",
|
||||
|
||||
+2
-191
@@ -125,8 +125,6 @@
|
||||
"activity": "Activiteit",
|
||||
"add": "Toevoegen",
|
||||
"add_action": "Actie toevoegen",
|
||||
"add_charts": "Grafieken toevoegen",
|
||||
"add_existing_chart_description": "Zoek en selecteer grafieken om toe te voegen aan dit dashboard.",
|
||||
"add_filter": "Filter toevoegen",
|
||||
"add_logo": "Logo toevoegen",
|
||||
"add_member": "Lid toevoegen",
|
||||
@@ -138,7 +136,6 @@
|
||||
"allow": "Toestaan",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "En",
|
||||
"anonymous": "Anoniem",
|
||||
"api_keys": "API-sleutels",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "Gecentreerd modaal",
|
||||
"change_organization": "Organisatie wijzigen",
|
||||
"change_workspace": "Werkruimte wijzigen",
|
||||
"chart": "Grafiek",
|
||||
"charts": "Grafieken",
|
||||
"choices": "Keuzes",
|
||||
"choose_organization": "Kies organisatie",
|
||||
"choose_workspace": "Kies werkruimte",
|
||||
@@ -191,7 +186,7 @@
|
||||
"count_questions": "{count, plural, one {{count} vraag} other {{count} vragen}}",
|
||||
"count_responses": "{count, plural, one {{count} reactie} other {{count} reacties}}",
|
||||
"count_selections": "{count, plural, one {{count} selectie} other {{count} selecties}}",
|
||||
"create": "Creëren",
|
||||
"create": "Aanmaken",
|
||||
"create_new_organization": "Creëer een nieuwe organisatie",
|
||||
"create_segment": "Segment maken",
|
||||
"create_survey": "Enquête maken",
|
||||
@@ -201,8 +196,6 @@
|
||||
"created_by": "Gemaakt door",
|
||||
"customer_success": "Klant succes",
|
||||
"dark_overlay": "Donkere overlay",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Datum",
|
||||
"days": "dagen",
|
||||
"default": "Standaard",
|
||||
@@ -256,7 +249,6 @@
|
||||
"hidden": "Verborgen",
|
||||
"hidden_field": "Verborgen veld",
|
||||
"hidden_fields": "Verborgen velden",
|
||||
"hide": "Verbergen",
|
||||
"hide_column": "Kolom verbergen",
|
||||
"id": "ID",
|
||||
"image": "Afbeelding",
|
||||
@@ -303,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
|
||||
"mobile_overlay_title": "Oeps, klein scherm gedetecteerd!",
|
||||
"months": "maanden",
|
||||
"more_options": "Meer opties",
|
||||
"move_down": "Ga naar beneden",
|
||||
"move_up": "Ga omhoog",
|
||||
"multiple_languages": "Meerdere talen",
|
||||
@@ -315,7 +306,6 @@
|
||||
"no": "Nee",
|
||||
"no_actions_found": "Geen acties gevonden",
|
||||
"no_background_image_found": "Geen achtergrondafbeelding gevonden.",
|
||||
"no_changes": "Geen wijzigingen",
|
||||
"no_code": "Geen code",
|
||||
"no_files_uploaded": "Er zijn geen bestanden geüpload",
|
||||
"no_overlay": "Geen overlay",
|
||||
@@ -337,7 +327,6 @@
|
||||
"on": "Op",
|
||||
"only_one_file_allowed": "Er is slechts één bestand toegestaan",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Alleen eigenaren en beheerders kunnen deze actie uitvoeren.",
|
||||
"open_options": "Opties openen",
|
||||
"option_id": "Optie-ID",
|
||||
"option_ids": "Optie-ID's",
|
||||
"optional": "Optioneel",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
|
||||
"read_docs": "Documentatie lezen",
|
||||
"recipients": "Ontvangers",
|
||||
"refresh": "Vernieuwen",
|
||||
"remove": "Verwijderen",
|
||||
"remove_from_team": "Verwijderen uit team",
|
||||
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "Wijzigingen opslaan",
|
||||
"saving": "Besparing",
|
||||
"search": "Zoekopdracht",
|
||||
"search_charts": "Zoek grafieken...",
|
||||
"security": "Beveiliging",
|
||||
"segment": "Segment",
|
||||
"segments": "Segmenten",
|
||||
@@ -483,7 +470,7 @@
|
||||
"variables": "Variabelen",
|
||||
"verified_email": "Geverifieerde e-mail",
|
||||
"video": "Video",
|
||||
"view": "Bekijken",
|
||||
"view": "Weergeven",
|
||||
"warning": "Waarschuwing",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We kunnen uw licentie niet verifiëren omdat de licentieserver niet bereikbaar is.",
|
||||
"webhook": "Webhook",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Uw enquête wordt op deze URL weergegeven.",
|
||||
"your_survey_would_not_be_shown": "Uw enquête wordt niet getoond."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "OF",
|
||||
"add_chart_to_dashboard": "Grafiek toevoegen aan dashboard",
|
||||
"add_chart_to_dashboard_description": "Selecteer een dashboard om deze grafiek aan toe te voegen. De grafiek wordt automatisch opgeslagen.",
|
||||
"add_filter": "Filter toevoegen",
|
||||
"add_to_dashboard": "Toevoegen aan dashboard",
|
||||
"advanced_chart_builder_config_prompt": "Configureer je grafiek en klik op \"Query uitvoeren\" om een voorbeeld te zien",
|
||||
"ai_query_placeholder": "bijv. Hoeveel gebruikers hebben zich vorige week aangemeld?",
|
||||
"ai_query_section_description": "Beschrijf wat je wilt zien en laat AI de grafiek bouwen.",
|
||||
"ai_query_section_title": "Vraag het aan je data",
|
||||
"and_filter_logic": "EN",
|
||||
"apply_changes": "Wijzigingen toepassen",
|
||||
"chart": "Grafiek",
|
||||
"chart_added_to_dashboard": "Grafiek toegevoegd aan dashboard!",
|
||||
"chart_builder_choose_chart_type": "Kies grafiektype",
|
||||
"chart_data": "Grafiekdata",
|
||||
"chart_data_tab": "Data",
|
||||
"chart_deleted_successfully": "Grafiek succesvol verwijderd",
|
||||
"chart_deletion_error": "Verwijderen van grafiek mislukt",
|
||||
"chart_duplicated_successfully": "Grafiek succesvol gedupliceerd",
|
||||
"chart_duplication_error": "Dupliceren van grafiek mislukt",
|
||||
"chart_name": "Grafieknaam",
|
||||
"chart_name_placeholder": "Grafieknaam",
|
||||
"chart_preview": "Grafiekvoorbeeld",
|
||||
"chart_render_error": "Er is iets misgegaan bij het weergeven van deze grafiek.",
|
||||
"chart_saved_successfully": "Grafiek succesvol opgeslagen!",
|
||||
"chart_type_area": "Vlakdiagram",
|
||||
"chart_type_bar": "Staafdiagram",
|
||||
"chart_type_big_number": "Groot getal",
|
||||
"chart_type_line": "Lijndiagram",
|
||||
"chart_type_not_supported": "Grafiektype \"{{chartType}}\" wordt nog niet ondersteund",
|
||||
"chart_type_pie": "Cirkeldiagram",
|
||||
"chart_updated_successfully": "Grafiek succesvol bijgewerkt!",
|
||||
"configure_description": "Pas het diagramtype en andere instellingen voor deze visualisatie aan.",
|
||||
"configure_title": "Diagram configureren",
|
||||
"configure_type_label": "Diagramtype",
|
||||
"contains": "bevat",
|
||||
"create_chart": "Grafiek maken",
|
||||
"create_chart_description": "Gebruik AI om een diagram te genereren of bouw er handmatig een.",
|
||||
"create_chart_with_ai": "Grafiek maken met AI",
|
||||
"custom_range": "Aangepast bereik",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_select_placeholder": "Selecteer een dashboard",
|
||||
"data_label": "Data",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Laatste 30 dagen",
|
||||
"date_preset_last_7_days": "Laatste 7 dagen",
|
||||
"date_preset_last_month": "Vorige maand",
|
||||
"date_preset_this_month": "Deze maand",
|
||||
"date_preset_this_quarter": "Dit kwartaal",
|
||||
"date_preset_this_year": "Dit jaar",
|
||||
"date_preset_today": "Vandaag",
|
||||
"date_preset_yesterday": "Gisteren",
|
||||
"date_range": "Datumbereik",
|
||||
"delete_chart_confirmation": "Weet je zeker dat je deze grafiek wilt verwijderen?",
|
||||
"dimensions": "Dimensies",
|
||||
"dimensions_toggle_description": "Groepeer data op sentiment, vraagtype en andere dimensies.",
|
||||
"edit_chart_description": "Bekijk en bewerk je diagramconfiguratie.",
|
||||
"edit_chart_title": "Diagram bewerken",
|
||||
"enable_time_dimension": "Tijdsdimensie inschakelen",
|
||||
"end_date": "Einddatum",
|
||||
"enter_a_name_for_your_chart": "Voer een naam in voor je diagram om het op te slaan.",
|
||||
"enter_value": "Voer waarde in",
|
||||
"equals": "is gelijk aan",
|
||||
"failed_to_add_chart_to_dashboard": "Diagram toevoegen aan dashboard mislukt",
|
||||
"failed_to_execute_query": "Query uitvoeren mislukt",
|
||||
"failed_to_load_chart": "Diagram laden mislukt",
|
||||
"failed_to_load_chart_data": "Diagramdata laden mislukt",
|
||||
"failed_to_save_chart": "Opslaan van diagram mislukt",
|
||||
"field": "Veld",
|
||||
"field_label_average_score": "Gemiddelde score",
|
||||
"field_label_collected_at": "Verzameld op",
|
||||
"field_label_count": "Aantal",
|
||||
"field_label_detractor_count": "Aantal detractors",
|
||||
"field_label_emotion": "Emotie",
|
||||
"field_label_field_type": "Veldtype",
|
||||
"field_label_nps_score": "NPS-score",
|
||||
"field_label_nps_value": "NPS-waarde",
|
||||
"field_label_passive_count": "Aantal passieven",
|
||||
"field_label_promoter_count": "Aantal promoters",
|
||||
"field_label_response_id": "Antwoord-ID",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Bronnaam",
|
||||
"field_label_source_type": "Brontype",
|
||||
"field_label_topic": "Onderwerp",
|
||||
"field_label_user_identifier": "Gebruikersidentificatie",
|
||||
"filter_data": "Data filteren",
|
||||
"filters": "Filters",
|
||||
"filters_toggle_description": "Neem alleen gegevens op die aan de volgende voorwaarden voldoen.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granulariteit",
|
||||
"granularity_day": "Dag",
|
||||
"granularity_hour": "Uur",
|
||||
"granularity_month": "Maand",
|
||||
"granularity_quarter": "Kwartaal",
|
||||
"granularity_week": "Week",
|
||||
"granularity_year": "Jaar",
|
||||
"greater_than": "groter dan",
|
||||
"greater_than_or_equal": "groter dan of gelijk aan",
|
||||
"group_by": "Groeperen op",
|
||||
"group_by_description": "Splits je data op aan de hand van één of meerdere dimensies. De volgorde is belangrijk als je meerdere dimensies kiest.",
|
||||
"group_data": "Data groeperen",
|
||||
"is_not_set": "is niet ingesteld",
|
||||
"is_set": "is ingesteld",
|
||||
"less_than": "kleiner dan",
|
||||
"less_than_or_equal": "kleiner dan of gelijk aan",
|
||||
"measures": "Metingen",
|
||||
"no_charts_found": "Geen diagrammen gevonden.",
|
||||
"no_dashboards_available": "Geen dashboards beschikbaar",
|
||||
"no_dashboards_create_first": "Maak eerst een dashboard aan om er diagrammen aan toe te voegen.",
|
||||
"no_data_available": "Geen gegevens beschikbaar",
|
||||
"no_data_returned": "Geen gegevens geretourneerd uit query",
|
||||
"no_data_returned_for_chart": "Geen gegevens geretourneerd voor diagram",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Geen (alleen filteren)",
|
||||
"no_valid_data_to_display": "Geen geldige gegevens om weer te geven",
|
||||
"not_contains": "bevat niet",
|
||||
"not_equals": "is niet gelijk aan",
|
||||
"open_chart": "Open diagram {{name}}",
|
||||
"open_options": "Open diagramopties",
|
||||
"or_filter_logic": "OF",
|
||||
"original": "Origineel",
|
||||
"please_enter_chart_name": "Voer een diagramnaam in",
|
||||
"please_enter_filter_values": "Voer waarden in voor alle filters",
|
||||
"please_select_at_least_one_dimension": "Selecteer minimaal één dimensie of schakel groepering uit",
|
||||
"please_select_at_least_one_measure": "Selecteer ten minste één meting",
|
||||
"please_select_dashboard": "Selecteer een dashboard",
|
||||
"predefined_measures": "Vooraf gedefinieerde metingen",
|
||||
"preset": "Voorinstelling",
|
||||
"query_executed_successfully": "Query succesvol uitgevoerd",
|
||||
"reset_to_ai_suggestion": "Herstel naar AI-suggestie",
|
||||
"save_chart": "Diagram opslaan",
|
||||
"save_chart_dialog_title": "Diagram opslaan",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Selecteer dimensies...",
|
||||
"select_field": "Selecteer veld",
|
||||
"select_measures": "Selecteer metingen...",
|
||||
"select_preset": "Selecteer voorinstelling",
|
||||
"showing_first_n_of": "Eerste {{n}} van {{count}} rijen worden getoond",
|
||||
"start_date": "Startdatum",
|
||||
"time_dimension": "Tijdsdimensie",
|
||||
"time_dimension_title": "Tijdgebaseerde groepering toevoegen",
|
||||
"time_dimension_toggle_description": "Volg trends over tijd."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} grafiek(en) toevoegen",
|
||||
"charts_add_failed": "Grafieken toevoegen aan dashboard mislukt",
|
||||
"charts_add_partial_failure": "{count} grafiek(en) toevoegen mislukt",
|
||||
"charts_added_to_dashboard": "Grafieken toegevoegd aan dashboard",
|
||||
"charts_load_failed": "Grafieken laden mislukt",
|
||||
"create_dashboard": "Dashboard maken",
|
||||
"create_dashboard_description": "Voer een naam in voor je nieuwe dashboard.",
|
||||
"create_failed": "Dashboard creëren mislukt",
|
||||
"create_success": "Dashboard succesvol aangemaakt!",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_delete_confirmation": "Weet je zeker dat je dit dashboard wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"dashboard_name": "Dashboardnaam",
|
||||
"dashboard_name_placeholder": "Mijn dashboard",
|
||||
"dashboard_name_required": "Dashboardnaam is verplicht",
|
||||
"dashboard_save_failed": "Dashboard opslaan mislukt",
|
||||
"dashboard_saved": "Dashboard succesvol opgeslagen",
|
||||
"delete_confirmation": "Weet je zeker dat je dit dashboard wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"delete_failed": "Dashboard verwijderen mislukt",
|
||||
"delete_success": "Dashboard succesvol verwijderd",
|
||||
"duplicate_failed": "Dashboard dupliceren mislukt",
|
||||
"duplicate_success": "Dashboard succesvol gedupliceerd!",
|
||||
"failed_to_load_chart_data": "Grafiekgegevens laden mislukt",
|
||||
"no_charts_available_description": "Er zijn geen grafieken die aan dit dashboard kunnen worden toegevoegd. Er bestaan nog geen grafieken, of alle bestaande grafieken zijn al toegevoegd. Ga naar de pagina Grafieken om nieuwe grafieken te maken.",
|
||||
"no_charts_to_add_message": "Geen grafieken om toe te voegen aan dit dashboard.",
|
||||
"no_dashboards_found": "Geen dashboards gevonden.",
|
||||
"no_data_message": "Geen gegevens. Er is momenteel geen informatie om weer te geven. Voeg grafieken toe om je dashboard op te bouwen.",
|
||||
"please_enter_name": "Voer een dashboardnaam in"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "API-sleutel toevoegen",
|
||||
"api_key": "API-sleutel",
|
||||
|
||||
@@ -125,8 +125,6 @@
|
||||
"activity": "Atividade",
|
||||
"add": "Adicionar",
|
||||
"add_action": "Adicionar ação",
|
||||
"add_charts": "Adicionar gráficos",
|
||||
"add_existing_chart_description": "Pesquise e selecione gráficos para adicionar a este painel.",
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_logo": "Adicionar logo",
|
||||
"add_member": "Adicionar membro",
|
||||
@@ -138,7 +136,6 @@
|
||||
"allow": "permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
|
||||
"analysis": "Análise",
|
||||
"and": "E",
|
||||
"anonymous": "Anônimo",
|
||||
"api_keys": "Chaves de API",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"change_organization": "Alterar organização",
|
||||
"change_workspace": "Alterar espaço de trabalho",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Escolhas",
|
||||
"choose_organization": "Escolher organização",
|
||||
"choose_workspace": "Escolher projeto",
|
||||
@@ -201,8 +196,6 @@
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "sobreposição escura",
|
||||
"dashboard": "Painel",
|
||||
"dashboards": "Painéis",
|
||||
"date": "Encontro",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
@@ -256,7 +249,6 @@
|
||||
"hidden": "Escondido",
|
||||
"hidden_field": "Campo oculto",
|
||||
"hidden_fields": "Campos ocultos",
|
||||
"hide": "Ocultar",
|
||||
"hide_column": "Ocultar coluna",
|
||||
"id": "ID",
|
||||
"image": "imagem",
|
||||
@@ -303,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
|
||||
"mobile_overlay_title": "Eita, tela pequena detectada!",
|
||||
"months": "meses",
|
||||
"more_options": "Mais opções",
|
||||
"move_down": "Descer",
|
||||
"move_up": "Subir",
|
||||
"multiple_languages": "Vários idiomas",
|
||||
@@ -315,7 +306,6 @@
|
||||
"no": "Não",
|
||||
"no_actions_found": "Nenhuma ação encontrada",
|
||||
"no_background_image_found": "Imagem de fundo não encontrada.",
|
||||
"no_changes": "Sem alterações",
|
||||
"no_code": "Sem código",
|
||||
"no_files_uploaded": "Nenhum arquivo foi enviado",
|
||||
"no_overlay": "Sem sobreposição",
|
||||
@@ -337,7 +327,6 @@
|
||||
"on": "ligado",
|
||||
"only_one_file_allowed": "É permitido apenas um arquivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
|
||||
"open_options": "Abrir opções",
|
||||
"option_id": "ID da opção",
|
||||
"option_ids": "IDs da Opção",
|
||||
"optional": "Opcional",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
|
||||
"read_docs": "Ler documentação",
|
||||
"recipients": "Destinatários",
|
||||
"refresh": "Atualizar",
|
||||
"remove": "remover",
|
||||
"remove_from_team": "Remover da equipe",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "Salvar alterações",
|
||||
"saving": "Salvando",
|
||||
"search": "Buscar",
|
||||
"search_charts": "Pesquisar gráficos...",
|
||||
"security": "Segurança",
|
||||
"segment": "segmento",
|
||||
"segments": "Segmentos",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Sua pesquisa seria exibida neste URL.",
|
||||
"your_survey_would_not_be_shown": "Sua pesquisa não seria exibida."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "OU",
|
||||
"add_chart_to_dashboard": "Adicionar gráfico ao painel",
|
||||
"add_chart_to_dashboard_description": "Selecione um painel para adicionar este gráfico. O gráfico será salvo automaticamente.",
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_to_dashboard": "Adicionar ao painel",
|
||||
"advanced_chart_builder_config_prompt": "Configure seu gráfico e clique em \"Executar consulta\" para visualizar",
|
||||
"ai_query_placeholder": "ex: Quantos usuários se cadastraram na semana passada?",
|
||||
"ai_query_section_description": "Descreva o que você quer ver e deixe a IA construir o gráfico.",
|
||||
"ai_query_section_title": "Pergunte aos seus dados",
|
||||
"and_filter_logic": "E",
|
||||
"apply_changes": "Aplicar alterações",
|
||||
"chart": "Gráfico",
|
||||
"chart_added_to_dashboard": "Gráfico adicionado ao painel!",
|
||||
"chart_builder_choose_chart_type": "Escolher tipo de gráfico",
|
||||
"chart_data": "Dados do gráfico",
|
||||
"chart_data_tab": "Dados",
|
||||
"chart_deleted_successfully": "Gráfico excluído com sucesso",
|
||||
"chart_deletion_error": "Falha ao excluir gráfico",
|
||||
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
|
||||
"chart_duplication_error": "Falha ao duplicar gráfico",
|
||||
"chart_name": "Nome do gráfico",
|
||||
"chart_name_placeholder": "Nome do gráfico",
|
||||
"chart_preview": "Visualização do gráfico",
|
||||
"chart_render_error": "Algo deu errado ao renderizar este gráfico.",
|
||||
"chart_saved_successfully": "Gráfico salvo com sucesso!",
|
||||
"chart_type_area": "Gráfico de área",
|
||||
"chart_type_bar": "Gráfico de barras",
|
||||
"chart_type_big_number": "Número grande",
|
||||
"chart_type_line": "Gráfico de linhas",
|
||||
"chart_type_not_supported": "Tipo de gráfico \"{{chartType}}\" ainda não suportado",
|
||||
"chart_type_pie": "Gráfico de pizza",
|
||||
"chart_updated_successfully": "Gráfico atualizado com sucesso!",
|
||||
"configure_description": "Modifique o tipo de gráfico e outras configurações para esta visualização.",
|
||||
"configure_title": "Configurar gráfico",
|
||||
"configure_type_label": "Tipo de gráfico",
|
||||
"contains": "contém",
|
||||
"create_chart": "Criar gráfico",
|
||||
"create_chart_description": "Use IA para gerar um gráfico ou crie um manualmente.",
|
||||
"create_chart_with_ai": "Criar gráfico com IA",
|
||||
"custom_range": "Intervalo personalizado",
|
||||
"dashboard": "Painel",
|
||||
"dashboard_select_placeholder": "Selecione um painel",
|
||||
"data_label": "Dados",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Últimos 30 dias",
|
||||
"date_preset_last_7_days": "Últimos 7 dias",
|
||||
"date_preset_last_month": "Último mês",
|
||||
"date_preset_this_month": "Este mês",
|
||||
"date_preset_this_quarter": "Este trimestre",
|
||||
"date_preset_this_year": "Este ano",
|
||||
"date_preset_today": "Hoje",
|
||||
"date_preset_yesterday": "Ontem",
|
||||
"date_range": "Intervalo de datas",
|
||||
"delete_chart_confirmation": "Tem certeza de que deseja excluir este gráfico?",
|
||||
"dimensions": "Dimensões",
|
||||
"dimensions_toggle_description": "Agrupe dados por sentimento, tipo de pergunta e outras dimensões.",
|
||||
"edit_chart_description": "Visualize e edite a configuração do seu gráfico.",
|
||||
"edit_chart_title": "Editar gráfico",
|
||||
"enable_time_dimension": "Ativar dimensão de tempo",
|
||||
"end_date": "Data final",
|
||||
"enter_a_name_for_your_chart": "Digite um nome para o seu gráfico para salvá-lo.",
|
||||
"enter_value": "Digite o valor",
|
||||
"equals": "igual",
|
||||
"failed_to_add_chart_to_dashboard": "Falha ao adicionar gráfico ao painel",
|
||||
"failed_to_execute_query": "Falha ao executar consulta",
|
||||
"failed_to_load_chart": "Falha ao carregar gráfico",
|
||||
"failed_to_load_chart_data": "Falha ao carregar dados do gráfico",
|
||||
"failed_to_save_chart": "Falha ao salvar gráfico",
|
||||
"field": "Campo",
|
||||
"field_label_average_score": "Pontuação média",
|
||||
"field_label_collected_at": "Coletado em",
|
||||
"field_label_count": "Contagem",
|
||||
"field_label_detractor_count": "Contagem de detratores",
|
||||
"field_label_emotion": "Emoção",
|
||||
"field_label_field_type": "Tipo de campo",
|
||||
"field_label_nps_score": "Pontuação de NPS",
|
||||
"field_label_nps_value": "Valor de NPS",
|
||||
"field_label_passive_count": "Contagem de passivos",
|
||||
"field_label_promoter_count": "Contagem de promotores",
|
||||
"field_label_response_id": "ID da resposta",
|
||||
"field_label_sentiment": "Sentimento",
|
||||
"field_label_source_name": "Nome da fonte",
|
||||
"field_label_source_type": "Tipo de fonte",
|
||||
"field_label_topic": "Tópico",
|
||||
"field_label_user_identifier": "Identificador do usuário",
|
||||
"filter_data": "Filtrar dados",
|
||||
"filters": "Filtros",
|
||||
"filters_toggle_description": "Incluir apenas dados que atendam às seguintes condições.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularidade",
|
||||
"granularity_day": "Dia",
|
||||
"granularity_hour": "Hora",
|
||||
"granularity_month": "Mês",
|
||||
"granularity_quarter": "Trimestre",
|
||||
"granularity_week": "Semana",
|
||||
"granularity_year": "Ano",
|
||||
"greater_than": "maior que",
|
||||
"greater_than_or_equal": "maior ou igual a",
|
||||
"group_by": "Agrupar por",
|
||||
"group_by_description": "Divida seus dados por uma ou mais dimensões. A ordem é importante se você escolher várias dimensões.",
|
||||
"group_data": "Agrupar dados",
|
||||
"is_not_set": "não está definido",
|
||||
"is_set": "está definido",
|
||||
"less_than": "menor que",
|
||||
"less_than_or_equal": "menor ou igual a",
|
||||
"measures": "Medidas",
|
||||
"no_charts_found": "Nenhum gráfico encontrado.",
|
||||
"no_dashboards_available": "Nenhum painel disponível",
|
||||
"no_dashboards_create_first": "Crie um painel primeiro para adicionar gráficos a ele.",
|
||||
"no_data_available": "Nenhum dado disponível",
|
||||
"no_data_returned": "Nenhum dado retornado da consulta",
|
||||
"no_data_returned_for_chart": "Nenhum dado retornado para o gráfico",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Nenhum (apenas filtro)",
|
||||
"no_valid_data_to_display": "Nenhum dado válido para exibir",
|
||||
"not_contains": "não contém",
|
||||
"not_equals": "diferente de",
|
||||
"open_chart": "Abrir gráfico {{name}}",
|
||||
"open_options": "Abrir opções do gráfico",
|
||||
"or_filter_logic": "OU",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Por favor, insira um nome para o gráfico",
|
||||
"please_enter_filter_values": "Por favor, insira valores para todos os filtros",
|
||||
"please_select_at_least_one_dimension": "Por favor, selecione pelo menos uma dimensão ou desative o agrupamento",
|
||||
"please_select_at_least_one_measure": "Por favor, selecione pelo menos uma medida",
|
||||
"please_select_dashboard": "Por favor, selecione um painel",
|
||||
"predefined_measures": "Medidas predefinidas",
|
||||
"preset": "Predefinição",
|
||||
"query_executed_successfully": "Consulta executada com sucesso",
|
||||
"reset_to_ai_suggestion": "Redefinir para sugestão da IA",
|
||||
"save_chart": "Salvar gráfico",
|
||||
"save_chart_dialog_title": "Salvar gráfico",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Selecionar dimensões...",
|
||||
"select_field": "Selecionar campo",
|
||||
"select_measures": "Selecionar medidas...",
|
||||
"select_preset": "Selecionar predefinição",
|
||||
"showing_first_n_of": "Mostrando os primeiros {{n}} de {{count}} registros",
|
||||
"start_date": "Data inicial",
|
||||
"time_dimension": "Dimensão temporal",
|
||||
"time_dimension_title": "Adicionar agrupamento por tempo",
|
||||
"time_dimension_toggle_description": "Monitore tendências ao longo do tempo."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adicionar {count} gráfico(s)",
|
||||
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
|
||||
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
|
||||
"charts_load_failed": "Falha ao carregar gráficos",
|
||||
"create_dashboard": "Criar painel",
|
||||
"create_dashboard_description": "Digite um nome para o seu novo painel.",
|
||||
"create_failed": "Falha ao criar painel",
|
||||
"create_success": "Painel criado com sucesso!",
|
||||
"dashboard": "Painel",
|
||||
"dashboard_delete_confirmation": "Tem certeza de que deseja excluir este painel? Esta ação não pode ser desfeita.",
|
||||
"dashboard_name": "Nome do painel",
|
||||
"dashboard_name_placeholder": "Meu painel",
|
||||
"dashboard_name_required": "O nome do painel é obrigatório",
|
||||
"dashboard_save_failed": "Falha ao salvar o painel",
|
||||
"dashboard_saved": "Painel salvo com sucesso",
|
||||
"delete_confirmation": "Tem certeza de que deseja excluir este painel? Esta ação não pode ser desfeita.",
|
||||
"delete_failed": "Falha ao excluir painel",
|
||||
"delete_success": "Painel excluído com sucesso",
|
||||
"duplicate_failed": "Falha ao duplicar painel",
|
||||
"duplicate_success": "Painel duplicado com sucesso!",
|
||||
"failed_to_load_chart_data": "Falha ao carregar os dados do gráfico",
|
||||
"no_charts_available_description": "Não há gráficos que possam ser adicionados a este painel. Ou nenhum gráfico existe ainda, ou todos os gráficos existentes já foram adicionados. Vá para a página de Gráficos para criar novos gráficos.",
|
||||
"no_charts_to_add_message": "Nenhum gráfico para adicionar a este painel.",
|
||||
"no_dashboards_found": "Nenhum painel encontrado.",
|
||||
"no_data_message": "Sem Dados. Não há informações para exibir no momento. Adicione gráficos para construir seu painel.",
|
||||
"please_enter_name": "Por favor, digite um nome para o painel"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Adicionar chave de API",
|
||||
"api_key": "Chave de API",
|
||||
|
||||
@@ -125,8 +125,6 @@
|
||||
"activity": "Atividade",
|
||||
"add": "Adicionar",
|
||||
"add_action": "Adicionar ação",
|
||||
"add_charts": "Adicionar gráficos",
|
||||
"add_existing_chart_description": "Pesquisa e seleciona gráficos para adicionar a este painel.",
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_logo": "Adicionar logótipo",
|
||||
"add_member": "Adicionar membro",
|
||||
@@ -138,7 +136,6 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
|
||||
"analysis": "Análise",
|
||||
"and": "E",
|
||||
"anonymous": "Anónimo",
|
||||
"api_keys": "Chaves API",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"change_organization": "Alterar organização",
|
||||
"change_workspace": "Alterar espaço de trabalho",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Escolhas",
|
||||
"choose_organization": "Escolher organização",
|
||||
"choose_workspace": "Escolher projeto",
|
||||
@@ -201,8 +196,6 @@
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "Sobreposição escura",
|
||||
"dashboard": "Painel",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Data",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
@@ -256,7 +249,6 @@
|
||||
"hidden": "Oculto",
|
||||
"hidden_field": "Campo oculto",
|
||||
"hidden_fields": "Campos ocultos",
|
||||
"hide": "Ocultar",
|
||||
"hide_column": "Ocultar coluna",
|
||||
"id": "ID",
|
||||
"image": "Imagem",
|
||||
@@ -303,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
|
||||
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
|
||||
"months": "meses",
|
||||
"more_options": "Mais opções",
|
||||
"move_down": "Mover para baixo",
|
||||
"move_up": "Mover para cima",
|
||||
"multiple_languages": "Várias línguas",
|
||||
@@ -315,7 +306,6 @@
|
||||
"no": "Não",
|
||||
"no_actions_found": "Nenhuma ação encontrada",
|
||||
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
|
||||
"no_changes": "Sem alterações",
|
||||
"no_code": "Sem código",
|
||||
"no_files_uploaded": "Nenhum ficheiro foi carregado",
|
||||
"no_overlay": "Sem sobreposição",
|
||||
@@ -337,7 +327,6 @@
|
||||
"on": "Ligado",
|
||||
"only_one_file_allowed": "Apenas um ficheiro é permitido",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
|
||||
"open_options": "Abrir opções",
|
||||
"option_id": "ID de Opção",
|
||||
"option_ids": "IDs de Opção",
|
||||
"optional": "Opcional",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
|
||||
"read_docs": "Ler documentação",
|
||||
"recipients": "Destinatários",
|
||||
"refresh": "Atualizar",
|
||||
"remove": "Remover",
|
||||
"remove_from_team": "Remover da equipa",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "Guardar alterações",
|
||||
"saving": "Guardando",
|
||||
"search": "Procurar",
|
||||
"search_charts": "Pesquisar gráficos...",
|
||||
"security": "Segurança",
|
||||
"segment": "Segmento",
|
||||
"segments": "Segmentos",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "O seu inquérito seria mostrado neste URL.",
|
||||
"your_survey_would_not_be_shown": "O seu inquérito não seria mostrado."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "OU",
|
||||
"add_chart_to_dashboard": "Adicionar gráfico ao painel",
|
||||
"add_chart_to_dashboard_description": "Seleciona um painel para adicionar este gráfico. O gráfico será guardado automaticamente.",
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_to_dashboard": "Adicionar ao painel",
|
||||
"advanced_chart_builder_config_prompt": "Configura o teu gráfico e clica em \"Executar consulta\" para pré-visualizar",
|
||||
"ai_query_placeholder": "ex: Quantos utilizadores se registaram na semana passada?",
|
||||
"ai_query_section_description": "Descreve o que queres ver e deixa a IA construir o gráfico.",
|
||||
"ai_query_section_title": "Pergunta aos teus dados",
|
||||
"and_filter_logic": "E",
|
||||
"apply_changes": "Aplicar alterações",
|
||||
"chart": "Gráfico",
|
||||
"chart_added_to_dashboard": "Gráfico adicionado ao painel!",
|
||||
"chart_builder_choose_chart_type": "Escolher tipo de gráfico",
|
||||
"chart_data": "Dados do gráfico",
|
||||
"chart_data_tab": "Dados",
|
||||
"chart_deleted_successfully": "Gráfico eliminado com sucesso",
|
||||
"chart_deletion_error": "Falha ao eliminar gráfico",
|
||||
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
|
||||
"chart_duplication_error": "Falha ao duplicar gráfico",
|
||||
"chart_name": "Nome do gráfico",
|
||||
"chart_name_placeholder": "Nome do gráfico",
|
||||
"chart_preview": "Pré-visualização do gráfico",
|
||||
"chart_render_error": "Algo correu mal ao renderizar este gráfico.",
|
||||
"chart_saved_successfully": "Gráfico guardado com sucesso!",
|
||||
"chart_type_area": "Gráfico de área",
|
||||
"chart_type_bar": "Gráfico de barras",
|
||||
"chart_type_big_number": "Número grande",
|
||||
"chart_type_line": "Gráfico de linhas",
|
||||
"chart_type_not_supported": "Tipo de gráfico \"{{chartType}}\" ainda não suportado",
|
||||
"chart_type_pie": "Gráfico circular",
|
||||
"chart_updated_successfully": "Gráfico atualizado com sucesso!",
|
||||
"configure_description": "Modifique o tipo de gráfico e outras definições para esta visualização.",
|
||||
"configure_title": "Configurar gráfico",
|
||||
"configure_type_label": "Tipo de gráfico",
|
||||
"contains": "contém",
|
||||
"create_chart": "Criar gráfico",
|
||||
"create_chart_description": "Use IA para gerar um gráfico ou crie um manualmente.",
|
||||
"create_chart_with_ai": "Criar gráfico com IA",
|
||||
"custom_range": "Intervalo personalizado",
|
||||
"dashboard": "Painel",
|
||||
"dashboard_select_placeholder": "Selecione um painel",
|
||||
"data_label": "Dados",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Últimos 30 dias",
|
||||
"date_preset_last_7_days": "Últimos 7 dias",
|
||||
"date_preset_last_month": "Último mês",
|
||||
"date_preset_this_month": "Este mês",
|
||||
"date_preset_this_quarter": "Este trimestre",
|
||||
"date_preset_this_year": "Este ano",
|
||||
"date_preset_today": "Hoje",
|
||||
"date_preset_yesterday": "Ontem",
|
||||
"date_range": "Intervalo de datas",
|
||||
"delete_chart_confirmation": "Tens a certeza de que queres eliminar este gráfico?",
|
||||
"dimensions": "Dimensões",
|
||||
"dimensions_toggle_description": "Agrupa dados por sentimento, tipo de pergunta e outras dimensões.",
|
||||
"edit_chart_description": "Visualize e edite a configuração do seu gráfico.",
|
||||
"edit_chart_title": "Editar gráfico",
|
||||
"enable_time_dimension": "Ativar dimensão temporal",
|
||||
"end_date": "Data de fim",
|
||||
"enter_a_name_for_your_chart": "Introduza um nome para o seu gráfico para o guardar.",
|
||||
"enter_value": "Introduza o valor",
|
||||
"equals": "igual",
|
||||
"failed_to_add_chart_to_dashboard": "Falha ao adicionar gráfico ao painel",
|
||||
"failed_to_execute_query": "Falha ao executar consulta",
|
||||
"failed_to_load_chart": "Falha ao carregar gráfico",
|
||||
"failed_to_load_chart_data": "Falha ao carregar dados do gráfico",
|
||||
"failed_to_save_chart": "Falha ao guardar gráfico",
|
||||
"field": "Campo",
|
||||
"field_label_average_score": "Pontuação média",
|
||||
"field_label_collected_at": "Recolhido em",
|
||||
"field_label_count": "Contagem",
|
||||
"field_label_detractor_count": "Contagem de detratores",
|
||||
"field_label_emotion": "Emoção",
|
||||
"field_label_field_type": "Tipo de campo",
|
||||
"field_label_nps_score": "Pontuação NPS",
|
||||
"field_label_nps_value": "Valor NPS",
|
||||
"field_label_passive_count": "Contagem de passivos",
|
||||
"field_label_promoter_count": "Contagem de promotores",
|
||||
"field_label_response_id": "ID de resposta",
|
||||
"field_label_sentiment": "Sentimento",
|
||||
"field_label_source_name": "Nome da origem",
|
||||
"field_label_source_type": "Tipo de origem",
|
||||
"field_label_topic": "Tópico",
|
||||
"field_label_user_identifier": "Identificador de utilizador",
|
||||
"filter_data": "Filtrar dados",
|
||||
"filters": "Filtros",
|
||||
"filters_toggle_description": "Incluir apenas dados que cumpram as seguintes condições.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularidade",
|
||||
"granularity_day": "Dia",
|
||||
"granularity_hour": "Hora",
|
||||
"granularity_month": "Mês",
|
||||
"granularity_quarter": "Trimestre",
|
||||
"granularity_week": "Semana",
|
||||
"granularity_year": "Ano",
|
||||
"greater_than": "maior que",
|
||||
"greater_than_or_equal": "maior ou igual a",
|
||||
"group_by": "Agrupar por",
|
||||
"group_by_description": "Divide os teus dados por uma ou mais dimensões. A ordem é importante se escolheres múltiplas dimensões.",
|
||||
"group_data": "Agrupar dados",
|
||||
"is_not_set": "não está definido",
|
||||
"is_set": "está definido",
|
||||
"less_than": "menor que",
|
||||
"less_than_or_equal": "menor ou igual a",
|
||||
"measures": "Medidas",
|
||||
"no_charts_found": "Nenhum gráfico encontrado.",
|
||||
"no_dashboards_available": "Nenhum painel disponível",
|
||||
"no_dashboards_create_first": "Cria primeiro um painel para adicionar gráficos.",
|
||||
"no_data_available": "Nenhum dado disponível",
|
||||
"no_data_returned": "Nenhum dado devolvido pela consulta",
|
||||
"no_data_returned_for_chart": "Nenhum dado devolvido para o gráfico",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Nenhum (apenas filtro)",
|
||||
"no_valid_data_to_display": "Nenhum dado válido para exibir",
|
||||
"not_contains": "não contém",
|
||||
"not_equals": "não é igual a",
|
||||
"open_chart": "Abrir gráfico {{name}}",
|
||||
"open_options": "Abrir opções do gráfico",
|
||||
"or_filter_logic": "OU",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Por favor, introduz um nome para o gráfico",
|
||||
"please_enter_filter_values": "Por favor, introduz valores para todos os filtros",
|
||||
"please_select_at_least_one_dimension": "Por favor, seleciona pelo menos uma dimensão ou desativa o agrupamento",
|
||||
"please_select_at_least_one_measure": "Por favor, seleciona pelo menos uma medida",
|
||||
"please_select_dashboard": "Por favor, seleciona um painel",
|
||||
"predefined_measures": "Medidas predefinidas",
|
||||
"preset": "Predefinição",
|
||||
"query_executed_successfully": "Consulta executada com sucesso",
|
||||
"reset_to_ai_suggestion": "Repor sugestão da IA",
|
||||
"save_chart": "Guardar gráfico",
|
||||
"save_chart_dialog_title": "Guardar gráfico",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Selecionar dimensões...",
|
||||
"select_field": "Selecionar campo",
|
||||
"select_measures": "Selecionar medidas...",
|
||||
"select_preset": "Selecionar predefinição",
|
||||
"showing_first_n_of": "A mostrar as primeiras {{n}} de {{count}} linhas",
|
||||
"start_date": "Data de início",
|
||||
"time_dimension": "Dimensão temporal",
|
||||
"time_dimension_title": "Adicionar agrupamento temporal",
|
||||
"time_dimension_toggle_description": "Monitoriza tendências ao longo do tempo."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adicionar {count} gráfico(s)",
|
||||
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
|
||||
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
|
||||
"charts_load_failed": "Falha ao carregar gráficos",
|
||||
"create_dashboard": "Criar painel",
|
||||
"create_dashboard_description": "Introduza um nome para o seu novo painel.",
|
||||
"create_failed": "Falha ao criar painel",
|
||||
"create_success": "Painel criado com sucesso!",
|
||||
"dashboard": "Painel",
|
||||
"dashboard_delete_confirmation": "Tens a certeza de que queres eliminar este painel? Esta ação não pode ser revertida.",
|
||||
"dashboard_name": "Nome do painel",
|
||||
"dashboard_name_placeholder": "O meu painel",
|
||||
"dashboard_name_required": "O nome do painel é obrigatório",
|
||||
"dashboard_save_failed": "Falha ao guardar o painel",
|
||||
"dashboard_saved": "Painel guardado com sucesso",
|
||||
"delete_confirmation": "Tem a certeza de que pretende eliminar este painel? Esta ação não pode ser revertida.",
|
||||
"delete_failed": "Falha ao eliminar painel",
|
||||
"delete_success": "Painel eliminado com sucesso",
|
||||
"duplicate_failed": "Falha ao duplicar painel",
|
||||
"duplicate_success": "Painel duplicado com sucesso!",
|
||||
"failed_to_load_chart_data": "Falha ao carregar os dados do gráfico",
|
||||
"no_charts_available_description": "Não há gráficos que possam ser adicionados a este painel. Ou ainda não existem gráficos, ou todos os gráficos existentes já foram adicionados. Vai à página de Gráficos para criar novos gráficos.",
|
||||
"no_charts_to_add_message": "Não há gráficos para adicionar a este painel.",
|
||||
"no_dashboards_found": "Nenhum painel encontrado.",
|
||||
"no_data_message": "Sem Dados. Atualmente não há informação para apresentar. Adiciona gráficos para construir o teu painel.",
|
||||
"please_enter_name": "Por favor, introduza um nome para o painel"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Adicionar chave API",
|
||||
"api_key": "Chave API",
|
||||
|
||||
+1
-190
@@ -125,8 +125,6 @@
|
||||
"activity": "Activitate",
|
||||
"add": "Adaugă",
|
||||
"add_action": "Adăugați acțiune",
|
||||
"add_charts": "Adaugă grafice",
|
||||
"add_existing_chart_description": "Caută și selectează grafice pentru a le adăuga la acest panou de control.",
|
||||
"add_filter": "Adăugați filtru",
|
||||
"add_logo": "Adaugă logo",
|
||||
"add_member": "Adaugă membru",
|
||||
@@ -138,7 +136,6 @@
|
||||
"allow": "Permite",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
|
||||
"analysis": "Analiză",
|
||||
"and": "Și",
|
||||
"anonymous": "Anonim",
|
||||
"api_keys": "Chei API",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "Modală centralizată",
|
||||
"change_organization": "Schimbă organizația",
|
||||
"change_workspace": "Schimbă spațiul de lucru",
|
||||
"chart": "Grafic",
|
||||
"charts": "Grafice",
|
||||
"choices": "Alegeri",
|
||||
"choose_organization": "Alege organizația",
|
||||
"choose_workspace": "Alege workspace",
|
||||
@@ -201,8 +196,6 @@
|
||||
"created_by": "Creat de",
|
||||
"customer_success": "Succesul Clientului",
|
||||
"dark_overlay": "Suprapunere întunecată",
|
||||
"dashboard": "Tablou de bord",
|
||||
"dashboards": "Tablouri de bord",
|
||||
"date": "Dată",
|
||||
"days": "zile",
|
||||
"default": "Implicit",
|
||||
@@ -256,7 +249,6 @@
|
||||
"hidden": "Ascuns",
|
||||
"hidden_field": "Câmp ascuns",
|
||||
"hidden_fields": "Câmpuri ascunse",
|
||||
"hide": "Ascunde",
|
||||
"hide_column": "Ascunde coloana",
|
||||
"id": "ID",
|
||||
"image": "Imagine",
|
||||
@@ -303,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "Nu vă faceți griji – chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
|
||||
"mobile_overlay_title": "Ups, ecran mic detectat!",
|
||||
"months": "luni",
|
||||
"more_options": "Mai multe opțiuni",
|
||||
"move_down": "Mută în jos",
|
||||
"move_up": "Mută sus",
|
||||
"multiple_languages": "Mai multe limbi",
|
||||
@@ -315,7 +306,6 @@
|
||||
"no": "Nu",
|
||||
"no_actions_found": "Nu au fost găsite acțiuni",
|
||||
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
|
||||
"no_changes": "Nicio modificare",
|
||||
"no_code": "Fără Cod",
|
||||
"no_files_uploaded": "Nu au fost încărcate fișiere",
|
||||
"no_overlay": "Fără overlay",
|
||||
@@ -337,7 +327,6 @@
|
||||
"on": "Pe",
|
||||
"only_one_file_allowed": "Este permis doar un fișier",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.",
|
||||
"open_options": "Deschide opțiunile",
|
||||
"option_id": "ID opțiune",
|
||||
"option_ids": "ID-uri opțiuni",
|
||||
"optional": "Opțional",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "Limitați numărul de răspunsuri primite de la participanții care îndeplinesc anumite criterii.",
|
||||
"read_docs": "Citește documentația",
|
||||
"recipients": "Destinatari",
|
||||
"refresh": "Reîmprospătează",
|
||||
"remove": "Șterge",
|
||||
"remove_from_team": "Elimină din echipă",
|
||||
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "Salvează modificările",
|
||||
"saving": "Salvare",
|
||||
"search": "Căutare",
|
||||
"search_charts": "Caută grafice...",
|
||||
"security": "Securitate",
|
||||
"segment": "Segment",
|
||||
"segments": "Segment",
|
||||
@@ -483,7 +470,7 @@
|
||||
"variables": "Variante",
|
||||
"verified_email": "Email verificat",
|
||||
"video": "Video",
|
||||
"view": "Vezi",
|
||||
"view": "Vizualizare",
|
||||
"warning": "Avertisment",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nu am putut verifica licența dvs. deoarece serverul de licențe este inaccesibil.",
|
||||
"webhook": "Webhook",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Sondajul dumneavoastră ar fi afișat pe acest URL.",
|
||||
"your_survey_would_not_be_shown": "Sondajul dumneavoastră nu va fi afișat."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "SAU",
|
||||
"add_chart_to_dashboard": "Adaugă grafic la Tablou de Bord",
|
||||
"add_chart_to_dashboard_description": "Selectează un tablou de bord la care să adaugi acest grafic. Graficul va fi salvat automat.",
|
||||
"add_filter": "Adaugă filtru",
|
||||
"add_to_dashboard": "Adaugă la Tablou de Bord",
|
||||
"advanced_chart_builder_config_prompt": "Configurează graficul și apasă pe \"Rulează interogarea\" pentru previzualizare",
|
||||
"ai_query_placeholder": "ex: Câți utilizatori s-au înscris săptămâna trecută?",
|
||||
"ai_query_section_description": "Descrie ce vrei să vezi și lasă AI-ul să construiască graficul.",
|
||||
"ai_query_section_title": "Întreabă-ți datele",
|
||||
"and_filter_logic": "ȘI",
|
||||
"apply_changes": "Aplică modificările",
|
||||
"chart": "Grafic",
|
||||
"chart_added_to_dashboard": "Grafic adăugat la tablou de bord!",
|
||||
"chart_builder_choose_chart_type": "Alege tipul de grafic",
|
||||
"chart_data": "Datele graficului",
|
||||
"chart_data_tab": "Date",
|
||||
"chart_deleted_successfully": "Graficul a fost șters cu succes",
|
||||
"chart_deletion_error": "Nu s-a putut șterge graficul",
|
||||
"chart_duplicated_successfully": "Graficul a fost duplicat cu succes",
|
||||
"chart_duplication_error": "Nu s-a putut duplica graficul",
|
||||
"chart_name": "Numele graficului",
|
||||
"chart_name_placeholder": "Numele graficului",
|
||||
"chart_preview": "Previzualizare grafic",
|
||||
"chart_render_error": "Ceva nu a mers bine la afișarea acestui grafic.",
|
||||
"chart_saved_successfully": "Graficul a fost salvat cu succes!",
|
||||
"chart_type_area": "Grafic de tip arie",
|
||||
"chart_type_bar": "Grafic de tip bară",
|
||||
"chart_type_big_number": "Număr mare",
|
||||
"chart_type_line": "Grafic de tip linie",
|
||||
"chart_type_not_supported": "Tipul de grafic \"{{chartType}}\" nu este încă suportat",
|
||||
"chart_type_pie": "Grafic de tip plăcintă",
|
||||
"chart_updated_successfully": "Graficul a fost actualizat cu succes!",
|
||||
"configure_description": "Modifică tipul graficului și alte setări pentru această vizualizare.",
|
||||
"configure_title": "Configurează graficul",
|
||||
"configure_type_label": "Tip grafic",
|
||||
"contains": "conține",
|
||||
"create_chart": "Creează grafic",
|
||||
"create_chart_description": "Folosește AI pentru a genera un grafic sau creează unul manual.",
|
||||
"create_chart_with_ai": "Creează grafic cu AI",
|
||||
"custom_range": "Interval personalizat",
|
||||
"dashboard": "Tablou de bord",
|
||||
"dashboard_select_placeholder": "Selectează un tablou de bord",
|
||||
"data_label": "Date",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Ultimele 30 de zile",
|
||||
"date_preset_last_7_days": "Ultimele 7 zile",
|
||||
"date_preset_last_month": "Ultima lună",
|
||||
"date_preset_this_month": "Luna aceasta",
|
||||
"date_preset_this_quarter": "Trimestrul acesta",
|
||||
"date_preset_this_year": "Anul acesta",
|
||||
"date_preset_today": "Astăzi",
|
||||
"date_preset_yesterday": "Ieri",
|
||||
"date_range": "Interval de date",
|
||||
"delete_chart_confirmation": "Ești sigur că vrei să ștergi acest grafic?",
|
||||
"dimensions": "Dimensiuni",
|
||||
"dimensions_toggle_description": "Grupează datele după sentiment, tipul întrebării și alte dimensiuni.",
|
||||
"edit_chart_description": "Vezi și editează configurația graficului tău.",
|
||||
"edit_chart_title": "Editează graficul",
|
||||
"enable_time_dimension": "Activează dimensiunea de timp",
|
||||
"end_date": "Data de sfârșit",
|
||||
"enter_a_name_for_your_chart": "Introdu un nume pentru grafic ca să îl salvezi.",
|
||||
"enter_value": "Introdu valoarea",
|
||||
"equals": "egal",
|
||||
"failed_to_add_chart_to_dashboard": "Nu s-a putut adăuga graficul în tablou de bord",
|
||||
"failed_to_execute_query": "Nu s-a putut executa interogarea",
|
||||
"failed_to_load_chart": "Nu s-a putut încărca graficul",
|
||||
"failed_to_load_chart_data": "Nu s-au putut încărca datele graficului",
|
||||
"failed_to_save_chart": "Nu s-a putut salva graficul",
|
||||
"field": "Câmp",
|
||||
"field_label_average_score": "Scor mediu",
|
||||
"field_label_collected_at": "Colectat la",
|
||||
"field_label_count": "Număr",
|
||||
"field_label_detractor_count": "Număr de detractori",
|
||||
"field_label_emotion": "Emoție",
|
||||
"field_label_field_type": "Tip câmp",
|
||||
"field_label_nps_score": "Scor NPS",
|
||||
"field_label_nps_value": "Valoare NPS",
|
||||
"field_label_passive_count": "Număr de pasivi",
|
||||
"field_label_promoter_count": "Număr de promotori",
|
||||
"field_label_response_id": "ID răspuns",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Nume sursă",
|
||||
"field_label_source_type": "Tip sursă",
|
||||
"field_label_topic": "Subiect",
|
||||
"field_label_user_identifier": "Identificator utilizator",
|
||||
"filter_data": "Filtrează datele",
|
||||
"filters": "Filtre",
|
||||
"filters_toggle_description": "Include doar datele care îndeplinesc următoarele condiții.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Granularitate",
|
||||
"granularity_day": "Zi",
|
||||
"granularity_hour": "Oră",
|
||||
"granularity_month": "Lună",
|
||||
"granularity_quarter": "Trimestru",
|
||||
"granularity_week": "Săptămână",
|
||||
"granularity_year": "An",
|
||||
"greater_than": "mai mare decât",
|
||||
"greater_than_or_equal": "mai mare sau egal cu",
|
||||
"group_by": "Grupează după",
|
||||
"group_by_description": "Descompune datele după una sau mai multe dimensiuni. Ordinea este importantă dacă alegi mai multe dimensiuni.",
|
||||
"group_data": "Grupează datele",
|
||||
"is_not_set": "nu este setat",
|
||||
"is_set": "este setat",
|
||||
"less_than": "mai mic decât",
|
||||
"less_than_or_equal": "mai mic sau egal",
|
||||
"measures": "Măsurători",
|
||||
"no_charts_found": "Nu s-au găsit grafice.",
|
||||
"no_dashboards_available": "Nu există tablouri de bord disponibile",
|
||||
"no_dashboards_create_first": "Creează mai întâi un tablou de bord pentru a adăuga grafice.",
|
||||
"no_data_available": "Nu există date disponibile",
|
||||
"no_data_returned": "Nu au fost returnate date din interogare",
|
||||
"no_data_returned_for_chart": "Nu au fost returnate date pentru grafic",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Niciuna (doar filtru)",
|
||||
"no_valid_data_to_display": "Nu există date valide de afișat",
|
||||
"not_contains": "nu conține",
|
||||
"not_equals": "nu este egal",
|
||||
"open_chart": "Deschide graficul {{name}}",
|
||||
"open_options": "Deschide opțiunile graficului",
|
||||
"or_filter_logic": "SAU",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Te rugăm să introduci un nume pentru grafic",
|
||||
"please_enter_filter_values": "Te rugăm să introduci valori pentru toate filtrele",
|
||||
"please_select_at_least_one_dimension": "Te rugăm să selectezi cel puțin o dimensiune sau să dezactivezi gruparea",
|
||||
"please_select_at_least_one_measure": "Te rugăm să selectezi cel puțin o măsurătoare",
|
||||
"please_select_dashboard": "Te rugăm să selectezi un tablou de bord",
|
||||
"predefined_measures": "Măsurători predefinite",
|
||||
"preset": "Presetare",
|
||||
"query_executed_successfully": "Interogarea a fost executată cu succes",
|
||||
"reset_to_ai_suggestion": "Resetează la sugestia AI",
|
||||
"save_chart": "Salvează graficul",
|
||||
"save_chart_dialog_title": "Salvează graficul",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Selectează dimensiuni...",
|
||||
"select_field": "Selectează câmpul",
|
||||
"select_measures": "Selectează măsuri...",
|
||||
"select_preset": "Selectează presetarea",
|
||||
"showing_first_n_of": "Se afișează primele {{n}} din {{count}} rânduri",
|
||||
"start_date": "Data de început",
|
||||
"time_dimension": "Dimensiune temporală",
|
||||
"time_dimension_title": "Adaugă grupare pe bază de timp",
|
||||
"time_dimension_toggle_description": "Monitorizează tendințele în timp."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adaugă {count} grafic(e)",
|
||||
"charts_add_failed": "Nu s-au putut adăuga graficele la panoul de control",
|
||||
"charts_add_partial_failure": "Nu s-au putut adăuga {count} grafic(e)",
|
||||
"charts_added_to_dashboard": "Grafice adăugate la panoul de control",
|
||||
"charts_load_failed": "Nu s-au putut încărca graficele",
|
||||
"create_dashboard": "Creează panou de control",
|
||||
"create_dashboard_description": "Introdu un nume pentru noul tău tablou de bord.",
|
||||
"create_failed": "Crearea tabloului de bord a eșuat",
|
||||
"create_success": "Tablou de bord creat cu succes!",
|
||||
"dashboard": "Panou de control",
|
||||
"dashboard_delete_confirmation": "Ești sigur că vrei să ștergi acest panou de control? Această acțiune nu poate fi anulată.",
|
||||
"dashboard_name": "Nume tablou de bord",
|
||||
"dashboard_name_placeholder": "Tabloul meu de bord",
|
||||
"dashboard_name_required": "Numele tabloului de bord este obligatoriu",
|
||||
"dashboard_save_failed": "Salvarea tabloului de bord a eșuat",
|
||||
"dashboard_saved": "Tabloul de bord a fost salvat cu succes",
|
||||
"delete_confirmation": "Ești sigur că vrei să ștergi acest tablou de bord? Această acțiune nu poate fi anulată.",
|
||||
"delete_failed": "Ștergerea tabloului de bord a eșuat",
|
||||
"delete_success": "Tablou de bord șters cu succes",
|
||||
"duplicate_failed": "Duplicarea tabloului de bord a eșuat",
|
||||
"duplicate_success": "Tablou de bord duplicat cu succes!",
|
||||
"failed_to_load_chart_data": "Încărcarea datelor graficului a eșuat",
|
||||
"no_charts_available_description": "Nu există grafice care pot fi adăugate la acest tablou de bord. Fie nu există încă grafice, fie toate graficele existente au fost deja adăugate. Mergi la pagina Grafice pentru a crea grafice noi.",
|
||||
"no_charts_to_add_message": "Nu există grafice de adăugat la acest tablou de bord.",
|
||||
"no_dashboards_found": "Nu s-a găsit niciun tablou de bord.",
|
||||
"no_data_message": "Fără date. În prezent nu există informații de afișat. Adaugă grafice pentru a-ți construi tabloul de bord.",
|
||||
"please_enter_name": "Te rugăm să introduci un nume pentru tablou de bord"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Adaugă cheie API",
|
||||
"api_key": "Cheie API",
|
||||
|
||||
+1
-190
@@ -125,8 +125,6 @@
|
||||
"activity": "Активность",
|
||||
"add": "Добавить",
|
||||
"add_action": "Добавить действие",
|
||||
"add_charts": "Добавить графики",
|
||||
"add_existing_chart_description": "Найдите и выберите графики для добавления на этот дашборд.",
|
||||
"add_filter": "Добавить фильтр",
|
||||
"add_logo": "Добавить логотип",
|
||||
"add_member": "Добавить участника",
|
||||
@@ -138,7 +136,6 @@
|
||||
"allow": "Разрешить",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
|
||||
"analysis": "Аналитика",
|
||||
"and": "и",
|
||||
"anonymous": "Аноним",
|
||||
"api_keys": "API-ключи",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "Центрированное модальное окно",
|
||||
"change_organization": "Сменить организацию",
|
||||
"change_workspace": "Сменить рабочее пространство",
|
||||
"chart": "График",
|
||||
"charts": "Графики",
|
||||
"choices": "Варианты",
|
||||
"choose_organization": "Выберите организацию",
|
||||
"choose_workspace": "Выбрать рабочее пространство",
|
||||
@@ -201,8 +196,6 @@
|
||||
"created_by": "Создано пользователем",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Тёмный оверлей",
|
||||
"dashboard": "Панель управления",
|
||||
"dashboards": "Дашборды",
|
||||
"date": "Дата",
|
||||
"days": "дни",
|
||||
"default": "По умолчанию",
|
||||
@@ -256,7 +249,6 @@
|
||||
"hidden": "Скрыто",
|
||||
"hidden_field": "Скрытое поле",
|
||||
"hidden_fields": "Скрытые поля",
|
||||
"hide": "Скрыть",
|
||||
"hide_column": "Скрыть столбец",
|
||||
"id": "ID",
|
||||
"image": "Изображение",
|
||||
@@ -303,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
|
||||
"mobile_overlay_title": "Ой, обнаружен маленький экран!",
|
||||
"months": "месяцы",
|
||||
"more_options": "Дополнительные опции",
|
||||
"move_down": "Переместить вниз",
|
||||
"move_up": "Переместить вверх",
|
||||
"multiple_languages": "Несколько языков",
|
||||
@@ -315,7 +306,6 @@
|
||||
"no": "Нет",
|
||||
"no_actions_found": "Действия не найдены",
|
||||
"no_background_image_found": "Фоновое изображение не найдено.",
|
||||
"no_changes": "Нет изменений",
|
||||
"no_code": "Нет кода",
|
||||
"no_files_uploaded": "Файлы не были загружены",
|
||||
"no_overlay": "Без наложения",
|
||||
@@ -337,7 +327,6 @@
|
||||
"on": "Вкл.",
|
||||
"only_one_file_allowed": "Разрешён только один файл",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Только владельцы и менеджеры могут выполнять это действие.",
|
||||
"open_options": "Открыть параметры",
|
||||
"option_id": "ID опции",
|
||||
"option_ids": "ID опций",
|
||||
"optional": "Необязательно",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "Ограничьте количество ответов, которые вы получаете от участников, соответствующих определённым критериям.",
|
||||
"read_docs": "Читать документацию",
|
||||
"recipients": "Получатели",
|
||||
"refresh": "Обновить",
|
||||
"remove": "Удалить",
|
||||
"remove_from_team": "Удалить из команды",
|
||||
"reorder_and_hide_columns": "Изменить порядок и скрыть столбцы",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "Сохранить изменения",
|
||||
"saving": "Сохранение",
|
||||
"search": "Поиск",
|
||||
"search_charts": "Поиск графиков...",
|
||||
"security": "Безопасность",
|
||||
"segment": "Сегмент",
|
||||
"segments": "Сегменты",
|
||||
@@ -483,7 +470,7 @@
|
||||
"variables": "Переменные",
|
||||
"verified_email": "Подтверждённый email",
|
||||
"video": "Видео",
|
||||
"view": "Просмотреть",
|
||||
"view": "Просмотр",
|
||||
"warning": "Предупреждение",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Не удалось проверить вашу лицензию, так как сервер лицензий недоступен.",
|
||||
"webhook": "Webhook",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Ваш опрос будет отображаться по этому URL.",
|
||||
"your_survey_would_not_be_shown": "Ваш опрос не будет отображаться."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "ИЛИ",
|
||||
"add_chart_to_dashboard": "Добавить график на панель",
|
||||
"add_chart_to_dashboard_description": "Выбери панель, чтобы добавить на неё этот график. График будет сохранён автоматически.",
|
||||
"add_filter": "Добавить фильтр",
|
||||
"add_to_dashboard": "Добавить на панель",
|
||||
"advanced_chart_builder_config_prompt": "Настрой график и нажми «Выполнить запрос», чтобы посмотреть предварительный просмотр",
|
||||
"ai_query_placeholder": "например: Сколько пользователей зарегистрировались на прошлой неделе?",
|
||||
"ai_query_section_description": "Опиши, что хочешь увидеть, и AI построит график.",
|
||||
"ai_query_section_title": "Спроси свои данные",
|
||||
"and_filter_logic": "И",
|
||||
"apply_changes": "Применить изменения",
|
||||
"chart": "График",
|
||||
"chart_added_to_dashboard": "График добавлен на панель!",
|
||||
"chart_builder_choose_chart_type": "Выбери тип графика",
|
||||
"chart_data": "Данные графика",
|
||||
"chart_data_tab": "Данные",
|
||||
"chart_deleted_successfully": "График успешно удалён",
|
||||
"chart_deletion_error": "Не удалось удалить график",
|
||||
"chart_duplicated_successfully": "График успешно дублирован",
|
||||
"chart_duplication_error": "Не удалось дублировать график",
|
||||
"chart_name": "Название графика",
|
||||
"chart_name_placeholder": "Название графика",
|
||||
"chart_preview": "Предпросмотр графика",
|
||||
"chart_render_error": "Что-то пошло не так при отображении этой диаграммы.",
|
||||
"chart_saved_successfully": "График успешно сохранён!",
|
||||
"chart_type_area": "График областью",
|
||||
"chart_type_bar": "Столбчатая диаграмма",
|
||||
"chart_type_big_number": "Большое число",
|
||||
"chart_type_line": "Линейный график",
|
||||
"chart_type_not_supported": "Тип графика «{{chartType}}» пока не поддерживается",
|
||||
"chart_type_pie": "Круговая диаграмма",
|
||||
"chart_updated_successfully": "График успешно обновлён!",
|
||||
"configure_description": "Измени тип графика и другие настройки этой визуализации.",
|
||||
"configure_title": "Настроить график",
|
||||
"configure_type_label": "Тип графика",
|
||||
"contains": "содержит",
|
||||
"create_chart": "Создать график",
|
||||
"create_chart_description": "Используй ИИ для создания графика или собери его вручную.",
|
||||
"create_chart_with_ai": "Создать график с помощью ИИ",
|
||||
"custom_range": "Произвольный диапазон",
|
||||
"dashboard": "Панель управления",
|
||||
"dashboard_select_placeholder": "Выбери панель управления",
|
||||
"data_label": "Данные",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Последние 30 дней",
|
||||
"date_preset_last_7_days": "Последние 7 дней",
|
||||
"date_preset_last_month": "Прошлый месяц",
|
||||
"date_preset_this_month": "В этом месяце",
|
||||
"date_preset_this_quarter": "В этом квартале",
|
||||
"date_preset_this_year": "В этом году",
|
||||
"date_preset_today": "Сегодня",
|
||||
"date_preset_yesterday": "Вчера",
|
||||
"date_range": "Диапазон дат",
|
||||
"delete_chart_confirmation": "Ты уверен, что хочешь удалить этот график?",
|
||||
"dimensions": "Измерения",
|
||||
"dimensions_toggle_description": "Группируйте данные по настроению, типу вопроса и другим измерениям.",
|
||||
"edit_chart_description": "Просмотри и измени настройки своего графика.",
|
||||
"edit_chart_title": "Редактировать график",
|
||||
"enable_time_dimension": "Включить временное измерение",
|
||||
"end_date": "Дата окончания",
|
||||
"enter_a_name_for_your_chart": "Введи название для графика, чтобы сохранить его.",
|
||||
"enter_value": "Введи значение",
|
||||
"equals": "равно",
|
||||
"failed_to_add_chart_to_dashboard": "Не удалось добавить график на панель управления",
|
||||
"failed_to_execute_query": "Не удалось выполнить запрос",
|
||||
"failed_to_load_chart": "Не удалось загрузить график",
|
||||
"failed_to_load_chart_data": "Не удалось загрузить данные графика",
|
||||
"failed_to_save_chart": "Не удалось сохранить график",
|
||||
"field": "Поле",
|
||||
"field_label_average_score": "Средний балл",
|
||||
"field_label_collected_at": "Дата сбора",
|
||||
"field_label_count": "Количество",
|
||||
"field_label_detractor_count": "Количество критиков",
|
||||
"field_label_emotion": "Эмоция",
|
||||
"field_label_field_type": "Тип поля",
|
||||
"field_label_nps_score": "Оценка NPS",
|
||||
"field_label_nps_value": "Значение NPS",
|
||||
"field_label_passive_count": "Количество пассивных",
|
||||
"field_label_promoter_count": "Количество промоутеров",
|
||||
"field_label_response_id": "ID ответа",
|
||||
"field_label_sentiment": "Тональность",
|
||||
"field_label_source_name": "Название источника",
|
||||
"field_label_source_type": "Тип источника",
|
||||
"field_label_topic": "Тема",
|
||||
"field_label_user_identifier": "Идентификатор пользователя",
|
||||
"filter_data": "Фильтровать данные",
|
||||
"filters": "Фильтры",
|
||||
"filters_toggle_description": "Включай только те данные, которые соответствуют следующим условиям.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Детализация",
|
||||
"granularity_day": "День",
|
||||
"granularity_hour": "Час",
|
||||
"granularity_month": "Месяц",
|
||||
"granularity_quarter": "Квартал",
|
||||
"granularity_week": "Неделя",
|
||||
"granularity_year": "Год",
|
||||
"greater_than": "больше чем",
|
||||
"greater_than_or_equal": "больше или равно",
|
||||
"group_by": "Группировать по",
|
||||
"group_by_description": "Разбейте данные по одному или нескольким измерениям. Порядок важен, если вы выбираете несколько измерений.",
|
||||
"group_data": "Группировать данные",
|
||||
"is_not_set": "не задано",
|
||||
"is_set": "задано",
|
||||
"less_than": "меньше чем",
|
||||
"less_than_or_equal": "меньше или равно",
|
||||
"measures": "Показатели",
|
||||
"no_charts_found": "Графики не найдены.",
|
||||
"no_dashboards_available": "Нет доступных панелей управления",
|
||||
"no_dashboards_create_first": "Сначала создай панель управления, чтобы добавить на неё графики.",
|
||||
"no_data_available": "Нет доступных данных",
|
||||
"no_data_returned": "Запрос не вернул данных",
|
||||
"no_data_returned_for_chart": "Для графика не получено данных",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Нет (только фильтр)",
|
||||
"no_valid_data_to_display": "Нет корректных данных для отображения",
|
||||
"not_contains": "не содержит",
|
||||
"not_equals": "не равно",
|
||||
"open_chart": "Открыть график {{name}}",
|
||||
"open_options": "Открыть настройки графика",
|
||||
"or_filter_logic": "ИЛИ",
|
||||
"original": "Оригинал",
|
||||
"please_enter_chart_name": "Пожалуйста, введи название графика",
|
||||
"please_enter_filter_values": "Пожалуйста, введите значения для всех фильтров",
|
||||
"please_select_at_least_one_dimension": "Пожалуйста, выберите хотя бы одно измерение или отключите группировку",
|
||||
"please_select_at_least_one_measure": "Пожалуйста, выбери хотя бы один показатель",
|
||||
"please_select_dashboard": "Пожалуйста, выбери панель управления",
|
||||
"predefined_measures": "Предустановленные показатели",
|
||||
"preset": "Пресет",
|
||||
"query_executed_successfully": "Запрос успешно выполнен",
|
||||
"reset_to_ai_suggestion": "Сбросить к предложению ИИ",
|
||||
"save_chart": "Сохранить график",
|
||||
"save_chart_dialog_title": "Сохранить график",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Выберите измерения...",
|
||||
"select_field": "Выберите поле",
|
||||
"select_measures": "Выберите показатели...",
|
||||
"select_preset": "Выберите пресет",
|
||||
"showing_first_n_of": "Показаны первые {{n}} из {{count}} строк",
|
||||
"start_date": "Дата начала",
|
||||
"time_dimension": "Временное измерение",
|
||||
"time_dimension_title": "Добавить группировку по времени",
|
||||
"time_dimension_toggle_description": "Отслеживайте тренды с течением времени."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Добавить {count} график(ов)",
|
||||
"charts_add_failed": "Не удалось добавить графики на дашборд",
|
||||
"charts_add_partial_failure": "Не удалось добавить {count} график(ов)",
|
||||
"charts_added_to_dashboard": "Графики добавлены на дашборд",
|
||||
"charts_load_failed": "Не удалось загрузить графики",
|
||||
"create_dashboard": "Создать дашборд",
|
||||
"create_dashboard_description": "Введите название для новой панели управления.",
|
||||
"create_failed": "Не удалось создать панель управления",
|
||||
"create_success": "Панель управления успешно создана!",
|
||||
"dashboard": "Дашборд",
|
||||
"dashboard_delete_confirmation": "Вы уверены, что хотите удалить этот дашборд? Это действие нельзя отменить.",
|
||||
"dashboard_name": "Название панели управления",
|
||||
"dashboard_name_placeholder": "Моя панель управления",
|
||||
"dashboard_name_required": "Необходимо указать название дашборда",
|
||||
"dashboard_save_failed": "Не удалось сохранить дашборд",
|
||||
"dashboard_saved": "Дашборд успешно сохранён",
|
||||
"delete_confirmation": "Ты уверен, что хочешь удалить эту панель управления? Это действие нельзя отменить.",
|
||||
"delete_failed": "Не удалось удалить панель управления",
|
||||
"delete_success": "Панель управления успешно удалена",
|
||||
"duplicate_failed": "Не удалось дублировать панель управления",
|
||||
"duplicate_success": "Панель управления успешно продублирована!",
|
||||
"failed_to_load_chart_data": "Не удалось загрузить данные графика",
|
||||
"no_charts_available_description": "Нет графиков, которые можно добавить к этому дашборду. Либо графики ещё не созданы, либо все существующие графики уже добавлены. Перейдите на страницу «Графики», чтобы создать новые графики.",
|
||||
"no_charts_to_add_message": "Нет графиков для добавления к этому дашборду.",
|
||||
"no_dashboards_found": "Панели управления не найдены.",
|
||||
"no_data_message": "Нет данных. В настоящее время нет информации для отображения. Добавьте графики, чтобы построить свой дашборд.",
|
||||
"please_enter_name": "Пожалуйста, введите название панели управления"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Добавить API-ключ",
|
||||
"api_key": "API-ключ",
|
||||
|
||||
@@ -125,8 +125,6 @@
|
||||
"activity": "Aktivitet",
|
||||
"add": "Lägg till",
|
||||
"add_action": "Lägg till åtgärd",
|
||||
"add_charts": "Lägg till diagram",
|
||||
"add_existing_chart_description": "Sök och välj diagram att lägga till i den här instrumentpanelen.",
|
||||
"add_filter": "Lägg till filter",
|
||||
"add_logo": "Lägg till logotyp",
|
||||
"add_member": "Lägg till medlem",
|
||||
@@ -138,7 +136,6 @@
|
||||
"allow": "Tillåt",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
|
||||
"analysis": "Analys",
|
||||
"and": "Och",
|
||||
"anonymous": "Anonym",
|
||||
"api_keys": "API-nycklar",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "Centrerad modal",
|
||||
"change_organization": "Byt organisation",
|
||||
"change_workspace": "Byt arbetsyta",
|
||||
"chart": "Diagram",
|
||||
"charts": "Diagram",
|
||||
"choices": "Val",
|
||||
"choose_organization": "Välj organisation",
|
||||
"choose_workspace": "Välj arbetsyta",
|
||||
@@ -201,8 +196,6 @@
|
||||
"created_by": "Skapad av",
|
||||
"customer_success": "Kundframgång",
|
||||
"dark_overlay": "Mörkt överlägg",
|
||||
"dashboard": "Instrumentpanel",
|
||||
"dashboards": "Instrumentpaneler",
|
||||
"date": "Datum",
|
||||
"days": "dagar",
|
||||
"default": "Standard",
|
||||
@@ -256,7 +249,6 @@
|
||||
"hidden": "Dold",
|
||||
"hidden_field": "Dolt fält",
|
||||
"hidden_fields": "Dolda fält",
|
||||
"hide": "Dölj",
|
||||
"hide_column": "Dölj kolumn",
|
||||
"id": "ID",
|
||||
"image": "Bild",
|
||||
@@ -303,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "Oroa dig inte – dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
|
||||
"mobile_overlay_title": "Hoppsan, liten skärm upptäckt!",
|
||||
"months": "månader",
|
||||
"more_options": "Fler alternativ",
|
||||
"move_down": "Flytta ner",
|
||||
"move_up": "Flytta upp",
|
||||
"multiple_languages": "Flera språk",
|
||||
@@ -315,7 +306,6 @@
|
||||
"no": "Nej",
|
||||
"no_actions_found": "Inga åtgärder hittades",
|
||||
"no_background_image_found": "Ingen bakgrundsbild hittades.",
|
||||
"no_changes": "Inga ändringar",
|
||||
"no_code": "Ingen kod",
|
||||
"no_files_uploaded": "Inga filer laddades upp",
|
||||
"no_overlay": "Ingen overlay",
|
||||
@@ -337,7 +327,6 @@
|
||||
"on": "På",
|
||||
"only_one_file_allowed": "Endast en fil är tillåten",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Endast ägare och chefer kan utföra denna åtgärd.",
|
||||
"open_options": "Öppna alternativ",
|
||||
"option_id": "Alternativ-ID",
|
||||
"option_ids": "Alternativ-ID:n",
|
||||
"optional": "Valfritt",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "Begränsa antalet svar du får från deltagare som uppfyller vissa kriterier.",
|
||||
"read_docs": "Läs dokumentation",
|
||||
"recipients": "Mottagare",
|
||||
"refresh": "Uppdatera",
|
||||
"remove": "Ta bort",
|
||||
"remove_from_team": "Ta bort från teamet",
|
||||
"reorder_and_hide_columns": "Ordna om och dölj kolumner",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "Spara ändringar",
|
||||
"saving": "Sparar",
|
||||
"search": "Sök",
|
||||
"search_charts": "Sök diagram...",
|
||||
"security": "Säkerhet",
|
||||
"segment": "Segment",
|
||||
"segments": "Segment",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Din enkät skulle visas på denna URL.",
|
||||
"your_survey_would_not_be_shown": "Din enkät skulle inte visas."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "ELLER",
|
||||
"add_chart_to_dashboard": "Lägg till diagram på instrumentpanelen",
|
||||
"add_chart_to_dashboard_description": "Välj en instrumentpanel att lägga till det här diagrammet på. Diagrammet sparas automatiskt.",
|
||||
"add_filter": "Lägg till filter",
|
||||
"add_to_dashboard": "Lägg till på instrumentpanelen",
|
||||
"advanced_chart_builder_config_prompt": "Konfigurera ditt diagram och klicka på \"Kör fråga\" för att förhandsgranska",
|
||||
"ai_query_placeholder": "t.ex. Hur många användare registrerade sig förra veckan?",
|
||||
"ai_query_section_description": "Beskriv vad du vill se så bygger AI diagrammet åt dig.",
|
||||
"ai_query_section_title": "Fråga din data",
|
||||
"and_filter_logic": "OCH",
|
||||
"apply_changes": "Verkställ ändringar",
|
||||
"chart": "Diagram",
|
||||
"chart_added_to_dashboard": "Diagram tillagt på instrumentpanelen!",
|
||||
"chart_builder_choose_chart_type": "Välj diagramtyp",
|
||||
"chart_data": "Diagramdata",
|
||||
"chart_data_tab": "Data",
|
||||
"chart_deleted_successfully": "Diagrammet har tagits bort",
|
||||
"chart_deletion_error": "Det gick inte att ta bort diagrammet",
|
||||
"chart_duplicated_successfully": "Diagrammet har duplicerats",
|
||||
"chart_duplication_error": "Det gick inte att duplicera diagrammet",
|
||||
"chart_name": "Diagramnamn",
|
||||
"chart_name_placeholder": "Diagramnamn",
|
||||
"chart_preview": "Förhandsgranska diagram",
|
||||
"chart_render_error": "Något gick fel när diagrammet skulle visas.",
|
||||
"chart_saved_successfully": "Diagram sparat!",
|
||||
"chart_type_area": "Ytdiagram",
|
||||
"chart_type_bar": "Stapeldiagram",
|
||||
"chart_type_big_number": "Stort tal",
|
||||
"chart_type_line": "Linjediagram",
|
||||
"chart_type_not_supported": "Diagramtypen \"{{chartType}}\" stöds inte än",
|
||||
"chart_type_pie": "Cirkeldiagram",
|
||||
"chart_updated_successfully": "Diagram uppdaterat!",
|
||||
"configure_description": "Ändra diagramtyp och andra inställningar för den här visualiseringen.",
|
||||
"configure_title": "Konfigurera diagram",
|
||||
"configure_type_label": "Diagramtyp",
|
||||
"contains": "innehåller",
|
||||
"create_chart": "Skapa diagram",
|
||||
"create_chart_description": "Använd AI för att skapa ett diagram eller bygg ett manuellt.",
|
||||
"create_chart_with_ai": "Skapa diagram med AI",
|
||||
"custom_range": "Anpassat intervall",
|
||||
"dashboard": "Instrumentpanel",
|
||||
"dashboard_select_placeholder": "Välj en instrumentpanel",
|
||||
"data_label": "Data",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "Senaste 30 dagarna",
|
||||
"date_preset_last_7_days": "Senaste 7 dagarna",
|
||||
"date_preset_last_month": "Senaste månaden",
|
||||
"date_preset_this_month": "Denna månad",
|
||||
"date_preset_this_quarter": "Detta kvartal",
|
||||
"date_preset_this_year": "Detta år",
|
||||
"date_preset_today": "Idag",
|
||||
"date_preset_yesterday": "Igår",
|
||||
"date_range": "Datumintervall",
|
||||
"delete_chart_confirmation": "Är du säker på att du vill ta bort det här diagrammet?",
|
||||
"dimensions": "Dimensioner",
|
||||
"dimensions_toggle_description": "Gruppera data efter sentiment, frågetyp och andra dimensioner.",
|
||||
"edit_chart_description": "Visa och redigera din diagramkonfiguration.",
|
||||
"edit_chart_title": "Redigera diagram",
|
||||
"enable_time_dimension": "Aktivera tidsdimension",
|
||||
"end_date": "Slutdatum",
|
||||
"enter_a_name_for_your_chart": "Ange ett namn för ditt diagram för att spara det.",
|
||||
"enter_value": "Ange värde",
|
||||
"equals": "är lika med",
|
||||
"failed_to_add_chart_to_dashboard": "Det gick inte att lägga till diagrammet i instrumentpanelen",
|
||||
"failed_to_execute_query": "Det gick inte att köra frågan",
|
||||
"failed_to_load_chart": "Det gick inte att ladda diagrammet",
|
||||
"failed_to_load_chart_data": "Det gick inte att ladda diagramdata",
|
||||
"failed_to_save_chart": "Det gick inte att spara diagrammet",
|
||||
"field": "Fält",
|
||||
"field_label_average_score": "Genomsnittligt betyg",
|
||||
"field_label_collected_at": "Insamlad",
|
||||
"field_label_count": "Antal",
|
||||
"field_label_detractor_count": "Antal kritiker",
|
||||
"field_label_emotion": "Känsla",
|
||||
"field_label_field_type": "Fälttyp",
|
||||
"field_label_nps_score": "NPS-poäng",
|
||||
"field_label_nps_value": "NPS-värde",
|
||||
"field_label_passive_count": "Antal passiva",
|
||||
"field_label_promoter_count": "Antal förespråkare",
|
||||
"field_label_response_id": "Svar-ID",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Källnamn",
|
||||
"field_label_source_type": "Källtyp",
|
||||
"field_label_topic": "Ämne",
|
||||
"field_label_user_identifier": "Användar-ID",
|
||||
"filter_data": "Filtrera data",
|
||||
"filters": "Filter",
|
||||
"filters_toggle_description": "Inkludera bara data som uppfyller följande villkor.",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "Detaljnivå",
|
||||
"granularity_day": "Dag",
|
||||
"granularity_hour": "timme",
|
||||
"granularity_month": "månad",
|
||||
"granularity_quarter": "kvartal",
|
||||
"granularity_week": "vecka",
|
||||
"granularity_year": "år",
|
||||
"greater_than": "större än",
|
||||
"greater_than_or_equal": "större än eller lika med",
|
||||
"group_by": "Gruppera efter",
|
||||
"group_by_description": "Dela upp din data med en eller flera dimensioner. Ordningen är viktig om du väljer flera dimensioner.",
|
||||
"group_data": "Gruppera data",
|
||||
"is_not_set": "är inte satt",
|
||||
"is_set": "är satt",
|
||||
"less_than": "mindre än",
|
||||
"less_than_or_equal": "mindre än eller lika med",
|
||||
"measures": "Mått",
|
||||
"no_charts_found": "Inga diagram hittades.",
|
||||
"no_dashboards_available": "Inga instrumentpaneler tillgängliga",
|
||||
"no_dashboards_create_first": "Skapa först en instrumentpanel för att lägga till diagram.",
|
||||
"no_data_available": "Ingen data tillgänglig",
|
||||
"no_data_returned": "Ingen data returnerades från frågan",
|
||||
"no_data_returned_for_chart": "Ingen data returnerades för diagrammet",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "Ingen (endast filter)",
|
||||
"no_valid_data_to_display": "Ingen giltig data att visa",
|
||||
"not_contains": "innehåller inte",
|
||||
"not_equals": "är inte lika med",
|
||||
"open_chart": "Öppna diagram {{name}}",
|
||||
"open_options": "Öppna diagramalternativ",
|
||||
"or_filter_logic": "ELLER",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Ange ett diagramnamn",
|
||||
"please_enter_filter_values": "Vänligen ange värden för alla filter",
|
||||
"please_select_at_least_one_dimension": "Vänligen välj minst en dimension eller inaktivera gruppering",
|
||||
"please_select_at_least_one_measure": "Välj minst ett mått",
|
||||
"please_select_dashboard": "Välj en instrumentpanel",
|
||||
"predefined_measures": "Fördefinierade mått",
|
||||
"preset": "Förinställning",
|
||||
"query_executed_successfully": "Frågan kördes utan problem",
|
||||
"reset_to_ai_suggestion": "Återställ till AI-förslag",
|
||||
"save_chart": "Spara diagram",
|
||||
"save_chart_dialog_title": "Spara diagram",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "Välj dimensioner...",
|
||||
"select_field": "Välj fält",
|
||||
"select_measures": "Välj mått...",
|
||||
"select_preset": "Välj förinställning",
|
||||
"showing_first_n_of": "Visar de första {{n}} av {{count}} raderna",
|
||||
"start_date": "Startdatum",
|
||||
"time_dimension": "Tidsdimension",
|
||||
"time_dimension_title": "Lägg till tidsbaserad gruppering",
|
||||
"time_dimension_toggle_description": "Övervaka trender över tid."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Lägg till {count} diagram",
|
||||
"charts_add_failed": "Misslyckades med att lägga till diagram i instrumentpanelen",
|
||||
"charts_add_partial_failure": "Misslyckades med att lägga till {count} diagram",
|
||||
"charts_added_to_dashboard": "Diagram tillagda i instrumentpanelen",
|
||||
"charts_load_failed": "Misslyckades med att ladda diagram",
|
||||
"create_dashboard": "Skapa instrumentpanel",
|
||||
"create_dashboard_description": "Ange ett namn för din nya instrumentpanel.",
|
||||
"create_failed": "Det gick inte att skapa instrumentpanelen",
|
||||
"create_success": "Instrumentpanelen har skapats!",
|
||||
"dashboard": "Instrumentpanel",
|
||||
"dashboard_delete_confirmation": "Är du säker på att du vill ta bort den här instrumentpanelen? Den här åtgärden kan inte ångras.",
|
||||
"dashboard_name": "Instrumentpanelens namn",
|
||||
"dashboard_name_placeholder": "Min instrumentpanel",
|
||||
"dashboard_name_required": "Instrumentpanelens namn krävs",
|
||||
"dashboard_save_failed": "Det gick inte att spara instrumentpanelen",
|
||||
"dashboard_saved": "Instrumentpanelen sparades",
|
||||
"delete_confirmation": "Är du säker på att du vill ta bort den här instrumentpanelen? Den här åtgärden kan inte ångras.",
|
||||
"delete_failed": "Det gick inte att ta bort instrumentpanelen",
|
||||
"delete_success": "Instrumentpanelen har tagits bort",
|
||||
"duplicate_failed": "Det gick inte att duplicera instrumentpanelen",
|
||||
"duplicate_success": "Instrumentpanelen har duplicerats!",
|
||||
"failed_to_load_chart_data": "Det gick inte att ladda diagramdata",
|
||||
"no_charts_available_description": "Det finns inga diagram som kan läggas till på den här instrumentpanelen. Antingen finns inga diagram än, eller så har alla befintliga diagram redan lagts till. Gå till sidan Diagram för att skapa nya diagram.",
|
||||
"no_charts_to_add_message": "Inga diagram att lägga till på den här instrumentpanelen.",
|
||||
"no_dashboards_found": "Inga instrumentpaneler hittades.",
|
||||
"no_data_message": "Ingen data. Det finns för närvarande ingen information att visa. Lägg till diagram för att bygga din instrumentpanel.",
|
||||
"please_enter_name": "Ange ett namn på instrumentpanelen"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "Lägg till API-nyckel",
|
||||
"api_key": "API-nyckel",
|
||||
|
||||
@@ -125,8 +125,6 @@
|
||||
"activity": "活动",
|
||||
"add": "添加",
|
||||
"add_action": "添加 操作",
|
||||
"add_charts": "添加图表",
|
||||
"add_existing_chart_description": "搜索并选择要添加到此仪表板的图表。",
|
||||
"add_filter": "添加 过滤器",
|
||||
"add_logo": "添加徽标",
|
||||
"add_member": "添加成员",
|
||||
@@ -138,7 +136,6 @@
|
||||
"allow": "允许",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
|
||||
"analysis": "分析",
|
||||
"and": "和",
|
||||
"anonymous": "匿名",
|
||||
"api_keys": "API 密钥",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "居中 模态",
|
||||
"change_organization": "切换组织",
|
||||
"change_workspace": "切换工作区",
|
||||
"chart": "图表",
|
||||
"charts": "图表",
|
||||
"choices": "选项",
|
||||
"choose_organization": "选择 组织",
|
||||
"choose_workspace": "选择工作区",
|
||||
@@ -201,8 +196,6 @@
|
||||
"created_by": "由 创建",
|
||||
"customer_success": "客户成功",
|
||||
"dark_overlay": "深色遮罩层",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "仪表盘",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "默认",
|
||||
@@ -256,7 +249,6 @@
|
||||
"hidden": "隐藏",
|
||||
"hidden_field": "隐藏 字段",
|
||||
"hidden_fields": "隐藏 字段",
|
||||
"hide": "隐藏",
|
||||
"hide_column": "隐藏 列",
|
||||
"id": "ID",
|
||||
"image": "图片",
|
||||
@@ -303,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
|
||||
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
|
||||
"months": "月",
|
||||
"more_options": "更多选项",
|
||||
"move_down": "下移",
|
||||
"move_up": "上移",
|
||||
"multiple_languages": "多种 语言",
|
||||
@@ -315,7 +306,6 @@
|
||||
"no": "否",
|
||||
"no_actions_found": "未找到操作",
|
||||
"no_background_image_found": "未找到 背景 图片。",
|
||||
"no_changes": "无变更",
|
||||
"no_code": "无代码",
|
||||
"no_files_uploaded": "没有 文件 被 上传",
|
||||
"no_overlay": "无覆盖层",
|
||||
@@ -337,7 +327,6 @@
|
||||
"on": "开启",
|
||||
"only_one_file_allowed": "只 允许 一个 文件",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有 所有者 和 管理者 可以 执行 此 操作。",
|
||||
"open_options": "打开选项",
|
||||
"option_id": "选项 ID",
|
||||
"option_ids": "选项 ID",
|
||||
"optional": "可选",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
|
||||
"read_docs": "阅读文档",
|
||||
"recipients": "收件人",
|
||||
"refresh": "刷新",
|
||||
"remove": "移除",
|
||||
"remove_from_team": "从团队中移除",
|
||||
"reorder_and_hide_columns": "重新排序和隐藏列",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "保存 更改",
|
||||
"saving": "保存",
|
||||
"search": "搜索",
|
||||
"search_charts": "搜索图表...",
|
||||
"security": "安全",
|
||||
"segment": "细分",
|
||||
"segments": "细分",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "您的 调查 会 显示 在 此 URL 上",
|
||||
"your_survey_would_not_be_shown": "您的 调查 不会 显示。"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "或",
|
||||
"add_chart_to_dashboard": "添加图表到 Dashboard",
|
||||
"add_chart_to_dashboard_description": "选择一个 Dashboard,将此图表添加进去。图表会自动保存。",
|
||||
"add_filter": "添加过滤器",
|
||||
"add_to_dashboard": "添加到 Dashboard",
|
||||
"advanced_chart_builder_config_prompt": "配置你的图表,然后点击“运行查询”预览",
|
||||
"ai_query_placeholder": "例如:上周有多少用户注册?",
|
||||
"ai_query_section_description": "描述你想要看到的内容,让 AI 帮你生成图表。",
|
||||
"ai_query_section_title": "向你的数据提问",
|
||||
"and_filter_logic": "且",
|
||||
"apply_changes": "应用更改",
|
||||
"chart": "图表",
|
||||
"chart_added_to_dashboard": "图表已添加到 Dashboard!",
|
||||
"chart_builder_choose_chart_type": "选择图表类型",
|
||||
"chart_data": "图表数据",
|
||||
"chart_data_tab": "数据",
|
||||
"chart_deleted_successfully": "图表删除成功",
|
||||
"chart_deletion_error": "图表删除失败",
|
||||
"chart_duplicated_successfully": "图表复制成功",
|
||||
"chart_duplication_error": "图表复制失败",
|
||||
"chart_name": "图表名称",
|
||||
"chart_name_placeholder": "图表名称",
|
||||
"chart_preview": "图表预览",
|
||||
"chart_render_error": "渲染此图表时出现了问题。",
|
||||
"chart_saved_successfully": "图表保存成功!",
|
||||
"chart_type_area": "面积图",
|
||||
"chart_type_bar": "柱状图",
|
||||
"chart_type_big_number": "大数字",
|
||||
"chart_type_line": "折线图",
|
||||
"chart_type_not_supported": "暂不支持图表类型“{{chartType}}”",
|
||||
"chart_type_pie": "饼图",
|
||||
"chart_updated_successfully": "图表更新成功!",
|
||||
"configure_description": "修改此可视化的图表类型和其他设置。",
|
||||
"configure_title": "配置图表",
|
||||
"configure_type_label": "图表类型",
|
||||
"contains": "包含",
|
||||
"create_chart": "创建图表",
|
||||
"create_chart_description": "使用 AI 生成图表或手动创建。",
|
||||
"create_chart_with_ai": "使用 AI 创建图表",
|
||||
"custom_range": "自定义范围",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_select_placeholder": "请选择一个 Dashboard",
|
||||
"data_label": "数据",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "最近 30 天",
|
||||
"date_preset_last_7_days": "最近 7 天",
|
||||
"date_preset_last_month": "上个月",
|
||||
"date_preset_this_month": "本月",
|
||||
"date_preset_this_quarter": "本季度",
|
||||
"date_preset_this_year": "今年",
|
||||
"date_preset_today": "今天",
|
||||
"date_preset_yesterday": "昨天",
|
||||
"date_range": "日期范围",
|
||||
"delete_chart_confirmation": "你确定要删除这个图表吗?",
|
||||
"dimensions": "维度",
|
||||
"dimensions_toggle_description": "按情感、问题类型和其他维度对数据进行分组。",
|
||||
"edit_chart_description": "查看并编辑你的图表配置。",
|
||||
"edit_chart_title": "编辑图表",
|
||||
"enable_time_dimension": "启用时间维度",
|
||||
"end_date": "结束日期",
|
||||
"enter_a_name_for_your_chart": "请输入图表名称以保存。",
|
||||
"enter_value": "输入值",
|
||||
"equals": "等于",
|
||||
"failed_to_add_chart_to_dashboard": "添加图表到 Dashboard 失败",
|
||||
"failed_to_execute_query": "查询执行失败",
|
||||
"failed_to_load_chart": "加载图表失败",
|
||||
"failed_to_load_chart_data": "加载图表数据失败",
|
||||
"failed_to_save_chart": "图表保存失败",
|
||||
"field": "字段",
|
||||
"field_label_average_score": "平均分",
|
||||
"field_label_collected_at": "收集时间",
|
||||
"field_label_count": "数量",
|
||||
"field_label_detractor_count": "贬损者数量",
|
||||
"field_label_emotion": "情感",
|
||||
"field_label_field_type": "字段类型",
|
||||
"field_label_nps_score": "NPS 得分",
|
||||
"field_label_nps_value": "NPS 值",
|
||||
"field_label_passive_count": "中立者数量",
|
||||
"field_label_promoter_count": "推荐者数量",
|
||||
"field_label_response_id": "响应 ID",
|
||||
"field_label_sentiment": "情绪",
|
||||
"field_label_source_name": "来源名称",
|
||||
"field_label_source_type": "来源类型",
|
||||
"field_label_topic": "主题",
|
||||
"field_label_user_identifier": "用户标识",
|
||||
"filter_data": "筛选数据",
|
||||
"filters": "筛选条件",
|
||||
"filters_toggle_description": "仅包含符合以下条件的数据。",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "粒度",
|
||||
"granularity_day": "天",
|
||||
"granularity_hour": "小时",
|
||||
"granularity_month": "月",
|
||||
"granularity_quarter": "季度",
|
||||
"granularity_week": "周",
|
||||
"granularity_year": "年",
|
||||
"greater_than": "大于",
|
||||
"greater_than_or_equal": "大于或等于",
|
||||
"group_by": "分组依据",
|
||||
"group_by_description": "按一个或多个维度细分你的数据。如果选择多个维度,顺序很重要。",
|
||||
"group_data": "分组数据",
|
||||
"is_not_set": "未设置",
|
||||
"is_set": "已设置",
|
||||
"less_than": "小于",
|
||||
"less_than_or_equal": "小于或等于",
|
||||
"measures": "度量",
|
||||
"no_charts_found": "未找到图表。",
|
||||
"no_dashboards_available": "暂无可用 Dashboard",
|
||||
"no_dashboards_create_first": "请先创建一个 Dashboard,然后再添加图表。",
|
||||
"no_data_available": "暂无数据",
|
||||
"no_data_returned": "查询未返回数据",
|
||||
"no_data_returned_for_chart": "该图表未返回数据",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "无(仅筛选)",
|
||||
"no_valid_data_to_display": "无有效数据可显示",
|
||||
"not_contains": "不包含",
|
||||
"not_equals": "不等于",
|
||||
"open_chart": "打开图表 {{name}}",
|
||||
"open_options": "打开图表选项",
|
||||
"or_filter_logic": "或",
|
||||
"original": "原始",
|
||||
"please_enter_chart_name": "请输入图表名称",
|
||||
"please_enter_filter_values": "请为所有筛选条件输入值",
|
||||
"please_select_at_least_one_dimension": "请至少选择一个维度或禁用分组",
|
||||
"please_select_at_least_one_measure": "请至少选择一个度量",
|
||||
"please_select_dashboard": "请选择一个 Dashboard",
|
||||
"predefined_measures": "预设度量",
|
||||
"preset": "预设",
|
||||
"query_executed_successfully": "查询执行成功",
|
||||
"reset_to_ai_suggestion": "重置为 AI 建议",
|
||||
"save_chart": "保存图表",
|
||||
"save_chart_dialog_title": "保存图表",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "选择维度...",
|
||||
"select_field": "选择字段",
|
||||
"select_measures": "选择度量...",
|
||||
"select_preset": "选择预设",
|
||||
"showing_first_n_of": "显示前 {{n}} 行,共 {{count}} 行",
|
||||
"start_date": "开始日期",
|
||||
"time_dimension": "时间维度",
|
||||
"time_dimension_title": "添加基于时间的分组",
|
||||
"time_dimension_toggle_description": "监控随时间变化的趋势。"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "添加 {count} 个图表",
|
||||
"charts_add_failed": "添加图表到仪表板失败",
|
||||
"charts_add_partial_failure": "添加 {count} 个图表失败",
|
||||
"charts_added_to_dashboard": "图表已添加到仪表板",
|
||||
"charts_load_failed": "加载图表失败",
|
||||
"create_dashboard": "创建仪表板",
|
||||
"create_dashboard_description": "请输入新 Dashboard 的名称。",
|
||||
"create_failed": "创建 Dashboard 失败",
|
||||
"create_success": "Dashboard 创建成功!",
|
||||
"dashboard": "仪表板",
|
||||
"dashboard_delete_confirmation": "你确定要删除此仪表板吗?此操作无法撤销。",
|
||||
"dashboard_name": "Dashboard 名称",
|
||||
"dashboard_name_placeholder": "我的 Dashboard",
|
||||
"dashboard_name_required": "仪表板名称为必填项",
|
||||
"dashboard_save_failed": "保存仪表板失败",
|
||||
"dashboard_saved": "仪表板保存成功",
|
||||
"delete_confirmation": "确定要删除此 Dashboard 吗?此操作无法撤销。",
|
||||
"delete_failed": "删除 Dashboard 失败",
|
||||
"delete_success": "Dashboard 删除成功",
|
||||
"duplicate_failed": "复制 Dashboard 失败",
|
||||
"duplicate_success": "Dashboard 复制成功!",
|
||||
"failed_to_load_chart_data": "加载图表数据失败",
|
||||
"no_charts_available_description": "没有可以添加到此仪表板的图表。要么还没有创建任何图表,要么所有现有图表都已添加。请前往图表页面创建新图表。",
|
||||
"no_charts_to_add_message": "没有可添加到此仪表板的图表。",
|
||||
"no_dashboards_found": "未找到 Dashboard。",
|
||||
"no_data_message": "暂无数据。当前没有可显示的信息。请添加图表来构建你的仪表板。",
|
||||
"please_enter_name": "请输入 Dashboard 名称"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "添加 API 密钥",
|
||||
"api_key": "API 密钥",
|
||||
|
||||
@@ -125,8 +125,6 @@
|
||||
"activity": "活動",
|
||||
"add": "新增",
|
||||
"add_action": "新增操作",
|
||||
"add_charts": "新增圖表",
|
||||
"add_existing_chart_description": "搜尋並選擇要新增至此儀表板的圖表。",
|
||||
"add_filter": "新增篩選器",
|
||||
"add_logo": "新增標誌",
|
||||
"add_member": "新增成員",
|
||||
@@ -138,7 +136,6 @@
|
||||
"allow": "允許",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
|
||||
"analysis": "分析",
|
||||
"and": "且",
|
||||
"anonymous": "匿名",
|
||||
"api_keys": "API 金鑰",
|
||||
@@ -157,8 +154,6 @@
|
||||
"centered_modal": "置中彈窗",
|
||||
"change_organization": "變更組織",
|
||||
"change_workspace": "變更工作區",
|
||||
"chart": "圖表",
|
||||
"charts": "圖表",
|
||||
"choices": "選項",
|
||||
"choose_organization": "選擇 組織",
|
||||
"choose_workspace": "選擇工作區",
|
||||
@@ -201,8 +196,6 @@
|
||||
"created_by": "建立者",
|
||||
"customer_success": "客戶成功",
|
||||
"dark_overlay": "深色覆蓋",
|
||||
"dashboard": "儀表板",
|
||||
"dashboards": "儀表板",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "預設",
|
||||
@@ -256,7 +249,6 @@
|
||||
"hidden": "隱藏",
|
||||
"hidden_field": "隱藏欄位",
|
||||
"hidden_fields": "隱藏欄位",
|
||||
"hide": "隱藏",
|
||||
"hide_column": "隱藏欄位",
|
||||
"id": "ID",
|
||||
"image": "圖片",
|
||||
@@ -303,7 +295,6 @@
|
||||
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
|
||||
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
|
||||
"months": "月",
|
||||
"more_options": "更多選項",
|
||||
"move_down": "下移",
|
||||
"move_up": "上移",
|
||||
"multiple_languages": "多種語言",
|
||||
@@ -315,7 +306,6 @@
|
||||
"no": "否",
|
||||
"no_actions_found": "找不到動作",
|
||||
"no_background_image_found": "找不到背景圖片。",
|
||||
"no_changes": "無變更",
|
||||
"no_code": "無程式碼",
|
||||
"no_files_uploaded": "沒有上傳任何檔案",
|
||||
"no_overlay": "無覆蓋層",
|
||||
@@ -337,7 +327,6 @@
|
||||
"on": "開啟",
|
||||
"only_one_file_allowed": "僅允許一個檔案",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
|
||||
"open_options": "開啟選項",
|
||||
"option_id": "選項 ID",
|
||||
"option_ids": "選項 IDs",
|
||||
"optional": "選填",
|
||||
@@ -380,7 +369,6 @@
|
||||
"quotas_description": "限制 擁有 特定 條件 的 參與者 所 提供 的 回應 數量。",
|
||||
"read_docs": "閱讀文件",
|
||||
"recipients": "收件者",
|
||||
"refresh": "重新整理",
|
||||
"remove": "移除",
|
||||
"remove_from_team": "從團隊中移除",
|
||||
"reorder_and_hide_columns": "重新排序和隱藏欄位",
|
||||
@@ -401,7 +389,6 @@
|
||||
"save_changes": "儲存變更",
|
||||
"saving": "儲存",
|
||||
"search": "搜尋",
|
||||
"search_charts": "搜尋圖表...",
|
||||
"security": "安全性",
|
||||
"segment": "區隔",
|
||||
"segments": "區隔",
|
||||
@@ -1631,182 +1618,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "您的問卷將顯示在此網址。",
|
||||
"your_survey_would_not_be_shown": "您的問卷將不會顯示。"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"OR": "或",
|
||||
"add_chart_to_dashboard": "新增圖表到儀表板",
|
||||
"add_chart_to_dashboard_description": "請選擇一個儀表板來新增此圖表。圖表會自動儲存。",
|
||||
"add_filter": "新增篩選器",
|
||||
"add_to_dashboard": "新增到儀表板",
|
||||
"advanced_chart_builder_config_prompt": "設定你的圖表,然後點擊「執行查詢」預覽",
|
||||
"ai_query_placeholder": "例如:上週有多少用戶註冊?",
|
||||
"ai_query_section_description": "描述你想看到的內容,讓 AI 幫你建立圖表。",
|
||||
"ai_query_section_title": "詢問你的數據",
|
||||
"and_filter_logic": "且",
|
||||
"apply_changes": "套用變更",
|
||||
"chart": "圖表",
|
||||
"chart_added_to_dashboard": "圖表已新增到儀表板!",
|
||||
"chart_builder_choose_chart_type": "選擇圖表類型",
|
||||
"chart_data": "圖表資料",
|
||||
"chart_data_tab": "資料",
|
||||
"chart_deleted_successfully": "圖表已成功刪除",
|
||||
"chart_deletion_error": "刪除圖表失敗",
|
||||
"chart_duplicated_successfully": "圖表已成功複製",
|
||||
"chart_duplication_error": "圖表複製失敗",
|
||||
"chart_name": "圖表名稱",
|
||||
"chart_name_placeholder": "圖表名稱",
|
||||
"chart_preview": "圖表預覽",
|
||||
"chart_render_error": "這個圖表在顯示時發生錯誤。",
|
||||
"chart_saved_successfully": "圖表已成功儲存!",
|
||||
"chart_type_area": "區域圖",
|
||||
"chart_type_bar": "長條圖",
|
||||
"chart_type_big_number": "大數字",
|
||||
"chart_type_line": "折線圖",
|
||||
"chart_type_not_supported": "尚未支援圖表類型「{{chartType}}」",
|
||||
"chart_type_pie": "圓餅圖",
|
||||
"chart_updated_successfully": "圖表已成功更新!",
|
||||
"configure_description": "修改此視覺化的圖表類型及其他設定。",
|
||||
"configure_title": "設定圖表",
|
||||
"configure_type_label": "圖表類型",
|
||||
"contains": "包含",
|
||||
"create_chart": "建立圖表",
|
||||
"create_chart_description": "使用 AI 產生圖表或手動建立。",
|
||||
"create_chart_with_ai": "使用 AI 建立圖表",
|
||||
"custom_range": "自訂範圍",
|
||||
"dashboard": "儀表板",
|
||||
"dashboard_select_placeholder": "請選擇儀表板",
|
||||
"data_label": "資料",
|
||||
"data_source": "Data Source",
|
||||
"date_preset_last_30_days": "過去 30 天",
|
||||
"date_preset_last_7_days": "過去 7 天",
|
||||
"date_preset_last_month": "上個月",
|
||||
"date_preset_this_month": "本月",
|
||||
"date_preset_this_quarter": "本季",
|
||||
"date_preset_this_year": "今年",
|
||||
"date_preset_today": "今天",
|
||||
"date_preset_yesterday": "昨天",
|
||||
"date_range": "日期範圍",
|
||||
"delete_chart_confirmation": "你確定要刪除此圖表嗎?",
|
||||
"dimensions": "維度",
|
||||
"dimensions_toggle_description": "依情感、問題類型及其他維度分組資料。",
|
||||
"edit_chart_description": "檢視並編輯你的圖表設定。",
|
||||
"edit_chart_title": "編輯圖表",
|
||||
"enable_time_dimension": "啟用時間維度",
|
||||
"end_date": "結束日期",
|
||||
"enter_a_name_for_your_chart": "請輸入圖表名稱以儲存。",
|
||||
"enter_value": "請輸入數值",
|
||||
"equals": "等於",
|
||||
"failed_to_add_chart_to_dashboard": "新增圖表到儀表板失敗",
|
||||
"failed_to_execute_query": "查詢執行失敗",
|
||||
"failed_to_load_chart": "載入圖表失敗",
|
||||
"failed_to_load_chart_data": "載入圖表資料失敗",
|
||||
"failed_to_save_chart": "儲存圖表失敗",
|
||||
"field": "欄位",
|
||||
"field_label_average_score": "平均分數",
|
||||
"field_label_collected_at": "收集時間",
|
||||
"field_label_count": "數量",
|
||||
"field_label_detractor_count": "批評者數量",
|
||||
"field_label_emotion": "情緒",
|
||||
"field_label_field_type": "欄位類型",
|
||||
"field_label_nps_score": "NPS 分數",
|
||||
"field_label_nps_value": "NPS 值",
|
||||
"field_label_passive_count": "中立者數量",
|
||||
"field_label_promoter_count": "推廣者數量",
|
||||
"field_label_response_id": "回應 ID",
|
||||
"field_label_sentiment": "情感",
|
||||
"field_label_source_name": "來源名稱",
|
||||
"field_label_source_type": "來源類型",
|
||||
"field_label_topic": "主題",
|
||||
"field_label_user_identifier": "使用者識別碼",
|
||||
"filter_data": "篩選資料",
|
||||
"filters": "篩選條件",
|
||||
"filters_toggle_description": "只包含符合下列條件的資料。",
|
||||
"go_to_feedback_record_directories": "Go to Feedback Record Directories",
|
||||
"granularity": "粒度",
|
||||
"granularity_day": "天",
|
||||
"granularity_hour": "小時",
|
||||
"granularity_month": "月",
|
||||
"granularity_quarter": "季",
|
||||
"granularity_week": "週",
|
||||
"granularity_year": "年",
|
||||
"greater_than": "大於",
|
||||
"greater_than_or_equal": "大於或等於",
|
||||
"group_by": "分組依據",
|
||||
"group_by_description": "依一個或多個維度細分你的資料。若選擇多個維度,順序很重要。",
|
||||
"group_data": "分組資料",
|
||||
"is_not_set": "未設定",
|
||||
"is_set": "已設定",
|
||||
"less_than": "小於",
|
||||
"less_than_or_equal": "小於或等於",
|
||||
"measures": "指標",
|
||||
"no_charts_found": "找不到圖表。",
|
||||
"no_dashboards_available": "沒有可用的儀表板",
|
||||
"no_dashboards_create_first": "請先建立儀表板,才能新增圖表。",
|
||||
"no_data_available": "沒有可用資料",
|
||||
"no_data_returned": "查詢沒有回傳資料",
|
||||
"no_data_returned_for_chart": "此圖表沒有回傳資料",
|
||||
"no_data_source_available": "No feedback record directory is assigned to this workspace.",
|
||||
"no_grouping": "無(僅篩選)",
|
||||
"no_valid_data_to_display": "沒有可顯示的有效資料",
|
||||
"not_contains": "不包含",
|
||||
"not_equals": "不等於",
|
||||
"open_chart": "開啟圖表 {{name}}",
|
||||
"open_options": "開啟圖表選項",
|
||||
"or_filter_logic": "或",
|
||||
"original": "原始",
|
||||
"please_enter_chart_name": "請輸入圖表名稱",
|
||||
"please_enter_filter_values": "請為所有篩選條件輸入值",
|
||||
"please_select_at_least_one_dimension": "請至少選擇一個維度,或停用分組功能",
|
||||
"please_select_at_least_one_measure": "請至少選擇一個指標",
|
||||
"please_select_dashboard": "請選擇一個儀表板",
|
||||
"predefined_measures": "預設指標",
|
||||
"preset": "預設",
|
||||
"query_executed_successfully": "查詢執行成功",
|
||||
"reset_to_ai_suggestion": "重設為 AI 建議",
|
||||
"save_chart": "儲存圖表",
|
||||
"save_chart_dialog_title": "儲存圖表",
|
||||
"select_data_source": "Select a data source",
|
||||
"select_data_source_first": "Please select a data source first",
|
||||
"select_dimensions": "選擇維度...",
|
||||
"select_field": "選擇欄位",
|
||||
"select_measures": "選擇指標...",
|
||||
"select_preset": "選擇預設",
|
||||
"showing_first_n_of": "顯示前 {{n}} 筆,共 {{count}} 筆資料",
|
||||
"start_date": "開始日期",
|
||||
"time_dimension": "時間維度",
|
||||
"time_dimension_title": "新增基於時間的分組",
|
||||
"time_dimension_toggle_description": "監控隨時間變化的趨勢。"
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "新增 {count} 個圖表",
|
||||
"charts_add_failed": "無法將圖表新增至儀表板",
|
||||
"charts_add_partial_failure": "無法新增 {count} 個圖表",
|
||||
"charts_added_to_dashboard": "圖表已新增至儀表板",
|
||||
"charts_load_failed": "無法載入圖表",
|
||||
"create_dashboard": "建立儀表板",
|
||||
"create_dashboard_description": "請輸入新儀表板的名稱。",
|
||||
"create_failed": "建立儀表板失敗",
|
||||
"create_success": "儀表板建立成功!",
|
||||
"dashboard": "儀表板",
|
||||
"dashboard_delete_confirmation": "確定要刪除此儀表板嗎?此操作無法復原。",
|
||||
"dashboard_name": "儀表板名稱",
|
||||
"dashboard_name_placeholder": "我的儀表板",
|
||||
"dashboard_name_required": "儀表板名稱為必填",
|
||||
"dashboard_save_failed": "儲存儀表板失敗",
|
||||
"dashboard_saved": "儀表板已成功儲存",
|
||||
"delete_confirmation": "你確定要刪除此儀表板嗎?此操作無法復原。",
|
||||
"delete_failed": "刪除儀表板失敗",
|
||||
"delete_success": "儀表板刪除成功",
|
||||
"duplicate_failed": "複製儀表板失敗",
|
||||
"duplicate_success": "儀表板複製成功!",
|
||||
"failed_to_load_chart_data": "載入圖表資料失敗",
|
||||
"no_charts_available_description": "目前沒有可以新增到此儀表板的圖表。可能是尚未建立任何圖表,或所有現有圖表都已新增。請前往圖表頁面建立新的圖表。",
|
||||
"no_charts_to_add_message": "沒有可新增到此儀表板的圖表。",
|
||||
"no_dashboards_found": "找不到儀表板。",
|
||||
"no_data_message": "無資料。目前沒有可顯示的資訊。請新增圖表來建立你的儀表板。",
|
||||
"please_enter_name": "請輸入儀表板名稱"
|
||||
}
|
||||
},
|
||||
"api_keys": {
|
||||
"add_api_key": "新增 API 金鑰",
|
||||
"api_key": "API 金鑰",
|
||||
|
||||
@@ -12,9 +12,7 @@ type HasFindMany =
|
||||
| Prisma.TeamFindManyArgs
|
||||
| Prisma.WorkspaceTeamFindManyArgs
|
||||
| Prisma.UserFindManyArgs
|
||||
| Prisma.ContactAttributeKeyFindManyArgs
|
||||
| Prisma.ChartFindManyArgs
|
||||
| Prisma.DashboardFindManyArgs;
|
||||
| Prisma.ContactAttributeKeyFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from "@/modules/ui/components/button";
|
||||
export const BackToLoginButton = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<Button variant="default" className="w-full justify-center">
|
||||
<Button variant="secondary" className="w-full justify-center">
|
||||
<Link href="/auth/login" className="h-full w-full">
|
||||
{t("auth.signup.log_in")}
|
||||
</Link>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockLoad = vi.fn();
|
||||
const mockTablePivot = vi.fn();
|
||||
|
||||
vi.mock("@cubejs-client/core", () => ({
|
||||
default: vi.fn(() => ({
|
||||
load: mockLoad,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("executeQuery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
const resultSet = { tablePivot: mockTablePivot };
|
||||
mockLoad.mockResolvedValue(resultSet);
|
||||
mockTablePivot.mockReturnValue([{ id: "1", count: 42 }]);
|
||||
});
|
||||
|
||||
test("loads query and returns tablePivot result", async () => {
|
||||
const { executeQuery } = await import("./cube-client");
|
||||
const query = { measures: ["FeedbackRecords.count"] };
|
||||
const result = await executeQuery(query);
|
||||
|
||||
expect(mockLoad).toHaveBeenCalledWith(query);
|
||||
expect(mockTablePivot).toHaveBeenCalled();
|
||||
expect(result).toEqual([{ id: "1", count: 42 }]);
|
||||
});
|
||||
|
||||
test("preserves API URL when it already contains /cubejs-api/v1", async () => {
|
||||
const fullUrl = "https://cube.example.com/cubejs-api/v1";
|
||||
vi.stubEnv("CUBEJS_API_URL", fullUrl);
|
||||
const { executeQuery } = await import("./cube-client");
|
||||
|
||||
await executeQuery({ measures: ["FeedbackRecords.count"] });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const cubejs = ((await vi.importMock("@cubejs-client/core")) as any).default;
|
||||
expect(cubejs).toHaveBeenCalledWith(expect.any(String), { apiUrl: fullUrl });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import cubejs, { type CubeApi, type Query } from "@cubejs-client/core";
|
||||
|
||||
const getApiUrl = (): string => {
|
||||
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
|
||||
if (baseUrl.includes("/cubejs-api/v1")) {
|
||||
return baseUrl;
|
||||
}
|
||||
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
|
||||
};
|
||||
|
||||
let cubeClient: CubeApi | null = null;
|
||||
|
||||
function getCubeClient(): CubeApi {
|
||||
if (!cubeClient) {
|
||||
// TODO: This will fail silently if the token is not set. We need to fix this before going to production.
|
||||
const token = process.env.CUBEJS_API_TOKEN ?? "";
|
||||
cubeClient = cubejs(token, { apiUrl: getApiUrl() });
|
||||
}
|
||||
return cubeClient;
|
||||
}
|
||||
|
||||
export async function executeQuery(query: Query) {
|
||||
const client = getCubeClient();
|
||||
const resultSet = await client.load(query);
|
||||
return resultSet.tablePivot();
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { Output, generateText } from "ai";
|
||||
import { z } from "zod";
|
||||
import { type TChartQuery, ZChartQuery } from "@formbricks/types/analysis";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
|
||||
import { injectTenantFilter, validateQueryMembers } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import {
|
||||
createChart,
|
||||
deleteChart,
|
||||
duplicateChart,
|
||||
getChart,
|
||||
getCharts,
|
||||
updateChart,
|
||||
} from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { checkWorkspaceAccess } from "@/modules/ee/analysis/lib/access";
|
||||
import { generateSchemaContext } from "@/modules/ee/analysis/lib/ai-schema-context";
|
||||
import { ZChartCreateInput, ZChartType, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
|
||||
/** Client-facing chart input (workspaceId and createdBy are resolved server-side) */
|
||||
const ZChartCreateInputClient = ZChartCreateInput.omit({ workspaceId: true, createdBy: true });
|
||||
|
||||
const ZCreateChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
chartInput: ZChartCreateInputClient,
|
||||
});
|
||||
|
||||
export const createChartAction = authenticatedActionClient.inputSchema(ZCreateChartAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateChartAction>;
|
||||
}) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const chart = await createChart({
|
||||
...parsedInput.chartInput,
|
||||
workspaceId,
|
||||
createdBy: ctx.user.id,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.chartId = chart.id;
|
||||
ctx.auditLoggingCtx.newObject = chart;
|
||||
return chart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
chartId: ZId,
|
||||
chartUpdateInput: ZChartUpdateInput,
|
||||
});
|
||||
|
||||
export const updateChartAction = authenticatedActionClient.inputSchema(ZUpdateChartAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateChartAction>;
|
||||
}) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const { chart, updatedChart } = await updateChart(
|
||||
parsedInput.chartId,
|
||||
workspaceId,
|
||||
parsedInput.chartUpdateInput
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
|
||||
ctx.auditLoggingCtx.oldObject = chart;
|
||||
ctx.auditLoggingCtx.newObject = updatedChart;
|
||||
return updatedChart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDuplicateChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const duplicateChartAction = authenticatedActionClient.inputSchema(ZDuplicateChartAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDuplicateChartAction>;
|
||||
}) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const duplicatedChart = await duplicateChart(parsedInput.chartId, workspaceId, ctx.user.id);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.chartId = duplicatedChart.id;
|
||||
ctx.auditLoggingCtx.newObject = duplicatedChart;
|
||||
return duplicatedChart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const deleteChartAction = authenticatedActionClient.inputSchema(ZDeleteChartAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDeleteChartAction>;
|
||||
}) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const chart = await deleteChart(parsedInput.chartId, workspaceId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
|
||||
ctx.auditLoggingCtx.oldObject = chart;
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const getChartAction = authenticatedActionClient
|
||||
.inputSchema(ZGetChartAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetChartAction>;
|
||||
}) => {
|
||||
const { workspaceId } = await checkWorkspaceAccess(ctx.user.id, parsedInput.workspaceId, "read");
|
||||
|
||||
return getChart(parsedInput.chartId, workspaceId);
|
||||
}
|
||||
);
|
||||
|
||||
const ZGetChartsAction = z.object({
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const getChartsAction = authenticatedActionClient
|
||||
.inputSchema(ZGetChartsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetChartsAction>;
|
||||
}) => {
|
||||
const { workspaceId } = await checkWorkspaceAccess(ctx.user.id, parsedInput.workspaceId, "read");
|
||||
const charts = await getCharts(workspaceId);
|
||||
return charts;
|
||||
}
|
||||
);
|
||||
|
||||
// ── Charts UI specific actions (query execution & AI generation) ─────────────
|
||||
|
||||
const ZExecuteQueryAction = z.object({
|
||||
workspaceId: ZId,
|
||||
query: ZChartQuery,
|
||||
feedbackRecordDirectoryId: ZId,
|
||||
});
|
||||
|
||||
export const executeQueryAction = authenticatedActionClient
|
||||
.inputSchema(ZExecuteQueryAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZExecuteQueryAction>;
|
||||
}) => {
|
||||
await checkWorkspaceAccess(ctx.user.id, parsedInput.workspaceId, "read");
|
||||
|
||||
validateQueryMembers(parsedInput.query);
|
||||
|
||||
const scopedQuery = injectTenantFilter(parsedInput.query, parsedInput.feedbackRecordDirectoryId);
|
||||
|
||||
try {
|
||||
return await executeQuery(scopedQuery as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error("Failed to execute query");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const CUBE_NAME = "FeedbackRecords";
|
||||
|
||||
const ZGenerateAIQueryResponse = z.object({
|
||||
measures: z.array(z.string()),
|
||||
dimensions: z.array(z.string()).nullable(),
|
||||
timeDimensions: z
|
||||
.array(
|
||||
z.object({
|
||||
dimension: z.string(),
|
||||
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).nullable(),
|
||||
dateRange: z.string().nullable(),
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
chartType: ZChartType,
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
member: z.string(),
|
||||
operator: z.enum([
|
||||
"equals",
|
||||
"notEquals",
|
||||
"contains",
|
||||
"notContains",
|
||||
"set",
|
||||
"notSet",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
]),
|
||||
values: z.array(z.string()).nullable(),
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const ZGenerateAIChartAction = z.object({
|
||||
workspaceId: ZId,
|
||||
prompt: z.string().min(1).max(2000),
|
||||
feedbackRecordDirectoryId: ZId,
|
||||
});
|
||||
|
||||
export const generateAIChartAction = authenticatedActionClient
|
||||
.inputSchema(ZGenerateAIChartAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGenerateAIChartAction>;
|
||||
}) => {
|
||||
await checkWorkspaceAccess(ctx.user.id, parsedInput.workspaceId, "read");
|
||||
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new Error("OPENAI_API_KEY is not configured");
|
||||
}
|
||||
|
||||
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
const schemaContext = generateSchemaContext();
|
||||
|
||||
const { output } = await generateText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
output: Output.object({ schema: ZGenerateAIQueryResponse }),
|
||||
system: schemaContext,
|
||||
prompt: `User request: "${parsedInput.prompt}"`,
|
||||
});
|
||||
|
||||
const measures = output.measures.length > 0 ? output.measures : [`${CUBE_NAME}.count`];
|
||||
|
||||
const { chartType, ...cubeQuery } = { ...output, measures };
|
||||
|
||||
// Strip nulls/empty arrays so Cube.js receives only present fields
|
||||
const cleanQuery: Record<string, unknown> = {
|
||||
measures: cubeQuery.measures,
|
||||
...(cubeQuery.dimensions?.length && { dimensions: cubeQuery.dimensions }),
|
||||
...(cubeQuery.filters?.length && {
|
||||
filters: cubeQuery.filters.map(({ member, operator, values }) => ({
|
||||
member,
|
||||
operator,
|
||||
...(values != null && { values }),
|
||||
})),
|
||||
}),
|
||||
...(cubeQuery.timeDimensions?.length && {
|
||||
timeDimensions: cubeQuery.timeDimensions.map(({ dimension, granularity, dateRange }) => ({
|
||||
dimension,
|
||||
...(granularity != null && { granularity }),
|
||||
...(dateRange != null && { dateRange }),
|
||||
})),
|
||||
}),
|
||||
};
|
||||
|
||||
validateQueryMembers(cleanQuery as TChartQuery);
|
||||
|
||||
const scopedQuery = injectTenantFilter(
|
||||
cleanQuery as TChartQuery,
|
||||
parsedInput.feedbackRecordDirectoryId
|
||||
);
|
||||
|
||||
const data = await executeQuery(scopedQuery as Record<string, unknown>);
|
||||
|
||||
return {
|
||||
query: cleanQuery,
|
||||
chartType,
|
||||
data: Array.isArray(data) ? data : [],
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface AddToDashboardDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
dashboards: Array<{ id: string; name: string }>;
|
||||
selectedDashboardId: string | undefined;
|
||||
onDashboardSelect: (id: string) => void;
|
||||
onConfirm: () => void;
|
||||
isSaving: boolean;
|
||||
showChartNameField?: boolean;
|
||||
}
|
||||
|
||||
export function AddToDashboardDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
chartName,
|
||||
onChartNameChange,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
onDashboardSelect,
|
||||
onConfirm,
|
||||
isSaving,
|
||||
showChartNameField = true,
|
||||
}: Readonly<AddToDashboardDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && onOpenChange(open)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.analysis.charts.add_chart_to_dashboard")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("workspace.analysis.charts.add_chart_to_dashboard_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
{showChartNameField && (
|
||||
<div>
|
||||
<Label htmlFor="chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="chart-name"
|
||||
className="mt-2"
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="dashboard-select">{t("workspace.analysis.charts.dashboard")}</Label>
|
||||
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
|
||||
<SelectTrigger
|
||||
id="dashboard-select"
|
||||
className="mt-2 w-full"
|
||||
disabled={dashboards.length === 0}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
dashboards.length === 0
|
||||
? t("workspace.analysis.charts.no_dashboards_available")
|
||||
: t("workspace.analysis.charts.dashboard_select_placeholder")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="max-h-[200px]">
|
||||
{dashboards.map((dashboard) => (
|
||||
<SelectItem key={dashboard.id} value={dashboard.id}>
|
||||
{dashboard.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{dashboards.length === 0 && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{t("workspace.analysis.charts.no_dashboards_create_first")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
loading={isSaving}
|
||||
disabled={!selectedDashboardId || (showChartNameField && !chartName.trim())}>
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { AdvancedChartPreview } from "@/modules/ee/analysis/charts/components/advanced-chart-preview";
|
||||
import { ChartTypeSelector } from "@/modules/ee/analysis/charts/components/chart-type-selector";
|
||||
import { DimensionsPanel } from "@/modules/ee/analysis/charts/components/dimensions-panel";
|
||||
import { FiltersPanel } from "@/modules/ee/analysis/charts/components/filters-panel";
|
||||
import { MeasuresPanel } from "@/modules/ee/analysis/charts/components/measures-panel";
|
||||
import { TimeDimensionPanel } from "@/modules/ee/analysis/charts/components/time-dimension-panel";
|
||||
import { useChartQuery } from "@/modules/ee/analysis/charts/hooks/use-chart-query";
|
||||
import {
|
||||
type ChartBuilderState,
|
||||
type FilterRow,
|
||||
type TimeDimensionConfig,
|
||||
buildCubeQuery,
|
||||
parseQueryToState,
|
||||
} from "@/modules/ee/analysis/lib/query-builder";
|
||||
import { FEEDBACK_FIELDS } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { AnalyticsResponse, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface AdvancedChartBuilderProps {
|
||||
workspaceId: string;
|
||||
chartType: TChartType;
|
||||
initialQuery?: TChartQuery;
|
||||
hidePreview?: boolean;
|
||||
onChartGenerated?: (data: AnalyticsResponse) => void;
|
||||
feedbackRecordDirectoryId: string | null;
|
||||
runQueryCtaLabel?: string;
|
||||
}
|
||||
|
||||
const ACTION = {
|
||||
SET_MEASURES: "SET_MEASURES",
|
||||
SET_DIMENSIONS: "SET_DIMENSIONS",
|
||||
SET_FILTERS: "SET_FILTERS",
|
||||
SET_FILTER_LOGIC: "SET_FILTER_LOGIC",
|
||||
SET_TIME_DIMENSION: "SET_TIME_DIMENSION",
|
||||
INIT_FROM_QUERY: "INIT_FROM_QUERY",
|
||||
} as const;
|
||||
|
||||
type Action =
|
||||
| { type: typeof ACTION.SET_MEASURES; payload: string[] }
|
||||
| { type: typeof ACTION.SET_DIMENSIONS; payload: string[] }
|
||||
| { type: typeof ACTION.SET_FILTERS; payload: FilterRow[] }
|
||||
| { type: typeof ACTION.SET_FILTER_LOGIC; payload: "and" | "or" }
|
||||
| { type: typeof ACTION.SET_TIME_DIMENSION; payload: TimeDimensionConfig | null }
|
||||
| { type: typeof ACTION.INIT_FROM_QUERY; payload: Partial<ChartBuilderState> };
|
||||
|
||||
const initialState: ChartBuilderState = {
|
||||
selectedMeasures: [],
|
||||
selectedDimensions: [],
|
||||
filters: [],
|
||||
filterLogic: "and",
|
||||
timeDimension: null,
|
||||
};
|
||||
|
||||
const chartBuilderReducer = (state: ChartBuilderState, action: Action): ChartBuilderState => {
|
||||
switch (action.type) {
|
||||
case ACTION.SET_MEASURES:
|
||||
return { ...state, selectedMeasures: action.payload };
|
||||
case ACTION.SET_DIMENSIONS:
|
||||
return { ...state, selectedDimensions: action.payload };
|
||||
case ACTION.SET_FILTERS:
|
||||
return { ...state, filters: action.payload };
|
||||
case ACTION.SET_FILTER_LOGIC:
|
||||
return { ...state, filterLogic: action.payload };
|
||||
case ACTION.SET_TIME_DIMENSION:
|
||||
return { ...state, timeDimension: action.payload };
|
||||
case ACTION.INIT_FROM_QUERY:
|
||||
return { ...state, ...action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export function AdvancedChartBuilder({
|
||||
workspaceId,
|
||||
chartType,
|
||||
initialQuery,
|
||||
hidePreview = false,
|
||||
onChartGenerated,
|
||||
feedbackRecordDirectoryId,
|
||||
runQueryCtaLabel,
|
||||
}: Readonly<AdvancedChartBuilderProps>) {
|
||||
const { t } = useTranslation();
|
||||
const parsedInitial = initialQuery ? parseQueryToState(initialQuery) : null;
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
chartBuilderReducer,
|
||||
initialQuery ? { ...initialState, ...parsedInitial } : initialState
|
||||
);
|
||||
|
||||
const { chartData, query, isLoading, error, runQuery } = useChartQuery(
|
||||
workspaceId,
|
||||
feedbackRecordDirectoryId,
|
||||
initialQuery
|
||||
);
|
||||
|
||||
const currentQuery = useMemo(() => buildCubeQuery(state), [state]);
|
||||
const hasConfigChanged = useMemo(() => {
|
||||
if (!query) return true;
|
||||
return JSON.stringify(currentQuery) !== JSON.stringify(query);
|
||||
}, [currentQuery, query]);
|
||||
|
||||
const appliedInitialQueryRef = useRef<TChartQuery | null>(null);
|
||||
useEffect(() => {
|
||||
if (!initialQuery) return;
|
||||
if (appliedInitialQueryRef.current === initialQuery) return;
|
||||
appliedInitialQueryRef.current = initialQuery;
|
||||
const parsed = parseQueryToState(initialQuery);
|
||||
dispatch({ type: ACTION.INIT_FROM_QUERY, payload: parsed });
|
||||
setDimensionsOpen((parsed.selectedDimensions?.length ?? 0) > 0);
|
||||
}, [initialQuery]);
|
||||
|
||||
const handleRunQuery = async () => {
|
||||
if (state.selectedMeasures.length === 0) {
|
||||
toast.error(t("workspace.analysis.charts.please_select_at_least_one_measure"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (dimensionsOpen && state.selectedDimensions.length === 0) {
|
||||
toast.error(t("workspace.analysis.charts.please_select_at_least_one_dimension"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (filtersOpen && state.filters.length > 0) {
|
||||
const hasEmptyFilterValue = state.filters.some(
|
||||
(f) => f.operator !== "set" && f.operator !== "notSet" && (f.values === null || f.values.length === 0)
|
||||
);
|
||||
if (hasEmptyFilterValue) {
|
||||
toast.error(t("workspace.analysis.charts.please_enter_filter_values"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runQuery(buildCubeQuery(state));
|
||||
if (result) {
|
||||
onChartGenerated?.({ ...result, chartType });
|
||||
}
|
||||
};
|
||||
|
||||
const [dimensionsOpen, setDimensionsOpen] = useState(
|
||||
() => (parsedInitial?.selectedDimensions?.length ?? 0) > 0
|
||||
);
|
||||
const timeDimensionOpen = state.timeDimension != null;
|
||||
const filtersOpen = state.filters.length > 0;
|
||||
|
||||
return (
|
||||
<div className={hidePreview ? "space-y-2" : "grid gap-4 lg:grid-cols-2"}>
|
||||
<div className="mx-1 space-y-2">
|
||||
{!hidePreview && <ChartTypeSelector selectedChartType={chartType} onChartTypeSelect={() => {}} />}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col gap-3 overflow-hidden rounded-lg border bg-slate-50 p-4">
|
||||
<MeasuresPanel
|
||||
selectedMeasures={state.selectedMeasures}
|
||||
onMeasuresChange={(measures) => dispatch({ type: ACTION.SET_MEASURES, payload: measures })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={dimensionsOpen}
|
||||
onToggle={(checked) => {
|
||||
setDimensionsOpen(checked);
|
||||
if (!checked) dispatch({ type: ACTION.SET_DIMENSIONS, payload: [] });
|
||||
}}
|
||||
htmlId="chart-dimensions-toggle"
|
||||
title={t("workspace.analysis.charts.group_data")}
|
||||
description={t("workspace.analysis.charts.dimensions_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<DimensionsPanel
|
||||
hideTitle
|
||||
selectedDimensions={state.selectedDimensions}
|
||||
onDimensionsChange={(dimensions) =>
|
||||
dispatch({ type: ACTION.SET_DIMENSIONS, payload: dimensions })
|
||||
}
|
||||
/>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={timeDimensionOpen}
|
||||
onToggle={() => {
|
||||
if (timeDimensionOpen) dispatch({ type: ACTION.SET_TIME_DIMENSION, payload: null });
|
||||
else if (!state.timeDimension) {
|
||||
dispatch({
|
||||
type: ACTION.SET_TIME_DIMENSION,
|
||||
payload: {
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
dateRange: "last 30 days",
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
htmlId="chart-time-dimension-toggle"
|
||||
title={t("workspace.analysis.charts.time_dimension_title")}
|
||||
description={t("workspace.analysis.charts.time_dimension_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<TimeDimensionPanel
|
||||
hideTitle
|
||||
timeDimension={state.timeDimension}
|
||||
onTimeDimensionChange={(config) => dispatch({ type: ACTION.SET_TIME_DIMENSION, payload: config })}
|
||||
/>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={filtersOpen}
|
||||
onToggle={() => {
|
||||
if (filtersOpen) {
|
||||
dispatch({ type: ACTION.SET_FILTERS, payload: [] });
|
||||
} else if (state.filters.length === 0) {
|
||||
const firstField = FEEDBACK_FIELDS.dimensions[0] ?? FEEDBACK_FIELDS.measures[0];
|
||||
dispatch({
|
||||
type: ACTION.SET_FILTERS,
|
||||
payload: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
field: firstField?.id ?? "",
|
||||
operator: "equals" as const,
|
||||
values: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
htmlId="chart-filters-toggle"
|
||||
title={t("workspace.analysis.charts.filter_data")}
|
||||
description={t("workspace.analysis.charts.filters_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<FiltersPanel
|
||||
hideTitle
|
||||
filters={state.filters}
|
||||
filterLogic={state.filterLogic}
|
||||
onFiltersChange={(filters) => dispatch({ type: ACTION.SET_FILTERS, payload: filters })}
|
||||
onFilterLogicChange={(logic) => dispatch({ type: ACTION.SET_FILTER_LOGIC, payload: logic })}
|
||||
/>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleRunQuery} disabled={isLoading || !hasConfigChanged}>
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
(runQueryCtaLabel ?? t("workspace.analysis.charts.create_chart"))
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hidePreview && (
|
||||
<AdvancedChartPreview
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
chartData={chartData}
|
||||
chartType={chartType}
|
||||
query={query}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { DatabaseIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
|
||||
import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface AdvancedChartPreviewProps {
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
chartData: TChartDataRow[] | null;
|
||||
chartType: TChartType;
|
||||
query: TChartQuery | null;
|
||||
}
|
||||
|
||||
export function AdvancedChartPreview({
|
||||
error,
|
||||
isLoading,
|
||||
chartData,
|
||||
chartType,
|
||||
query,
|
||||
}: Readonly<AdvancedChartPreviewProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [showData, setShowData] = useState(false);
|
||||
const hasData = chartData && chartData.length > 0 && !isLoading && chartType && query;
|
||||
const isEmpty = !chartData && !isLoading && !error;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-md font-semibold text-gray-900">{t("workspace.analysis.charts.chart_preview")}</h3>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">{error}</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasData && (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<ChartRenderer chartType={chartType} data={chartData} query={query} />
|
||||
</div>
|
||||
|
||||
<Collapsible.Root open={showData} onOpenChange={setShowData}>
|
||||
<Collapsible.CollapsibleTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<DatabaseIcon className="mr-2 h-4 w-4" />
|
||||
{showData ? t("common.hide") : t("common.view")} {t("workspace.analysis.charts.data_label")}
|
||||
</Button>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-2">
|
||||
<DataViewer data={chartData} />
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.advanced_chart_builder_config_prompt")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ActivityIcon, WandSparklesIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { generateAIChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import type { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface AIQuerySectionProps {
|
||||
workspaceId: string;
|
||||
onChartGenerated: (data: AnalyticsResponse) => void;
|
||||
feedbackRecordDirectoryId: string;
|
||||
}
|
||||
|
||||
export function AIQuerySection({
|
||||
workspaceId,
|
||||
onChartGenerated,
|
||||
feedbackRecordDirectoryId,
|
||||
}: Readonly<AIQuerySectionProps>) {
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!userQuery.trim()) return;
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const result = await generateAIChartAction({
|
||||
workspaceId,
|
||||
prompt: userQuery.trim(),
|
||||
feedbackRecordDirectoryId,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
onChartGenerated(result.data);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-brand-dark/10">
|
||||
<ActivityIcon className="h-5 w-5 text-brand-dark" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-gray-900">
|
||||
{t("workspace.analysis.charts.ai_query_section_title")}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">{t("workspace.analysis.charts.ai_query_section_description")}</p>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-3" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t("workspace.analysis.charts.ai_query_placeholder")}
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
maxLength={2000}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={!userQuery.trim() || isGenerating}
|
||||
loading={isGenerating}>
|
||||
<WandSparklesIcon className="h-4 w-4" />
|
||||
{t("workspace.analysis.charts.create_chart_with_ai")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type ElementType, type ReactNode, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
CHART_BRAND_DARK,
|
||||
formatCellValue,
|
||||
formatXAxisTick,
|
||||
} from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
|
||||
import type { ChartConfig } from "@/modules/ui/components/chart";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/modules/ui/components/chart";
|
||||
|
||||
const ChartTooltipRow = ({
|
||||
value,
|
||||
dataKey,
|
||||
color,
|
||||
}: Readonly<{ value: unknown; dataKey: string; color?: string }>) => {
|
||||
const { t } = useTranslation();
|
||||
const indicatorColor = color ?? CHART_BRAND_DARK;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border border-current"
|
||||
style={{
|
||||
backgroundColor: indicatorColor,
|
||||
borderColor: indicatorColor,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between leading-none">
|
||||
<span className="text-muted-foreground">{formatCubeColumnHeader(dataKey, t)}</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">{formatCellValue(value)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** Creates a tooltip formatter bound to dataKey for Cartesian charts. Defined at module level to avoid Sonar "component in parent" warnings. */
|
||||
const createTooltipFormatter = (dataKey: string) => {
|
||||
const Formatter = (value: unknown) => <ChartTooltipRow value={value} dataKey={dataKey} />;
|
||||
Formatter.displayName = "ChartTooltipFormatter";
|
||||
return Formatter;
|
||||
};
|
||||
|
||||
/** Tooltip content for single-measure Cartesian charts. */
|
||||
const SingleMeasureTooltip = ({ dataKey }: Readonly<{ dataKey: string }>) => {
|
||||
const formatter = useMemo(() => createTooltipFormatter(dataKey), [dataKey]);
|
||||
return <ChartTooltipContent labelFormatter={formatXAxisTick} formatter={formatter} />;
|
||||
};
|
||||
|
||||
/** Tooltip formatter for multi-measure charts; uses each payload item's dataKey and color. */
|
||||
const multiMeasureTooltipFormatter = (value: unknown, name: string | number, item: unknown) => {
|
||||
const itemObj = item as { dataKey?: string; color?: string; payload?: { fill?: string } } | undefined;
|
||||
const key = itemObj?.dataKey ?? String(name);
|
||||
const color = itemObj?.color ?? itemObj?.payload?.fill;
|
||||
return <ChartTooltipRow value={value} dataKey={key} color={color} />;
|
||||
};
|
||||
|
||||
export interface CartesianChartProps {
|
||||
data: TChartDataRow[];
|
||||
xAxisKey: string;
|
||||
dataKeys: string[];
|
||||
chartConfig: ChartConfig;
|
||||
chart: ElementType;
|
||||
children: ReactNode;
|
||||
showLegend?: boolean;
|
||||
chartProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Shared layout for bar, line, and area charts. Supports single or multiple measures. */
|
||||
export function CartesianChart({
|
||||
data,
|
||||
xAxisKey,
|
||||
dataKeys,
|
||||
chartConfig,
|
||||
chart: Chart,
|
||||
children,
|
||||
showLegend = false,
|
||||
chartProps = {},
|
||||
}: Readonly<CartesianChartProps>) {
|
||||
const isMultiMeasure = dataKeys.length > 1;
|
||||
const tooltipContent = isMultiMeasure ? (
|
||||
<ChartTooltipContent labelFormatter={formatXAxisTick} formatter={multiMeasureTooltipFormatter} />
|
||||
) : (
|
||||
<SingleMeasureTooltip dataKey={dataKeys[0]} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-64 w-full">
|
||||
<ChartContainer config={chartConfig} className="h-full w-full">
|
||||
<Chart data={data} {...chartProps}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey={xAxisKey}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={formatXAxisTick}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={tooltipContent} />
|
||||
{showLegend && <ChartLegend content={<ChartLegendContent />} verticalAlign="top" height={36} />}
|
||||
{children}
|
||||
</Chart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, SaveIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DialogFooter } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartDialogFooterProps {
|
||||
onSaveClick: () => void;
|
||||
onAddToDashboardClick?: () => void;
|
||||
isSaving: boolean;
|
||||
saveLabel?: string;
|
||||
showAddToDashboard?: boolean;
|
||||
}
|
||||
|
||||
export function ChartDialogFooter({
|
||||
onSaveClick,
|
||||
onAddToDashboardClick,
|
||||
isSaving,
|
||||
saveLabel,
|
||||
showAddToDashboard = true,
|
||||
}: Readonly<ChartDialogFooterProps>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DialogFooter>
|
||||
{showAddToDashboard && onAddToDashboardClick && (
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSaveClick} disabled={isSaving}>
|
||||
<SaveIcon className="mr-2 h-4 w-4" />
|
||||
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface ChartDialogLoadingViewProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChartDialogLoadingView({ open, onClose }: Readonly<ChartDialogLoadingViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent width="wide">
|
||||
<DialogTitle className="sr-only">{t("common.loading")}</DialogTitle>
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon, MoreVertical, PlusIcon, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteChartAction, duplicateChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface ChartDropdownMenuProps {
|
||||
workspaceId: string;
|
||||
chart: TChartWithCreator;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<ChartDropdownMenuProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||
const [isAddingToDashboard, setIsAddingToDashboard] = useState(false);
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!isAddToDashboardDialogOpen) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void getDashboardsAction({ workspaceId }).then((result) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.data) {
|
||||
setDashboards(result.data.map((dashboard) => ({ id: dashboard.id, name: dashboard.name })));
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAddToDashboardDialogOpen, workspaceId]);
|
||||
|
||||
const handleDeleteChart = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteChartAction({ workspaceId, chartId: chart.id });
|
||||
if (result?.data) {
|
||||
toast.success(t("workspace.analysis.charts.chart_deleted_successfully"));
|
||||
setIsDeleteDialogOpen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
const msg = getFormattedErrorMessage(result) || t("workspace.analysis.charts.chart_deletion_error");
|
||||
toast.error(msg);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateChart = async () => {
|
||||
setIsDuplicating(true);
|
||||
try {
|
||||
const result = await duplicateChartAction({ workspaceId, chartId: chart.id });
|
||||
if (result?.data) {
|
||||
toast.success(t("workspace.analysis.charts.chart_duplicated_successfully"));
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(
|
||||
getFormattedErrorMessage(result) || t("workspace.analysis.charts.chart_duplication_error")
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("workspace.analysis.charts.chart_duplication_error"));
|
||||
} finally {
|
||||
setIsDuplicating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddChartToDashboard = async () => {
|
||||
if (!selectedDashboardId) {
|
||||
toast.error(t("workspace.analysis.charts.please_select_dashboard"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddingToDashboard(true);
|
||||
|
||||
try {
|
||||
const result = await addChartToDashboardAction({
|
||||
workspaceId,
|
||||
chartId: chart.id,
|
||||
dashboardId: selectedDashboardId,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(
|
||||
getFormattedErrorMessage(result) || t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.analysis.charts.chart_added_to_dashboard"));
|
||||
setIsAddToDashboardDialogOpen(false);
|
||||
setSelectedDashboardId(undefined);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setIsAddingToDashboard(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={`chart-${chart.id}-actions`} data-testid="chart-dropdown-menu">
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenuTrigger className="z-10" asChild>
|
||||
<Button variant="outline" className="px-2" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="sr-only">{t("workspace.analysis.charts.open_options")}</span>
|
||||
<MoreVertical className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="inline-block w-auto min-w-max" align="end">
|
||||
<DropdownMenuGroup>
|
||||
{onEdit && (
|
||||
<DropdownMenuItem
|
||||
icon={<SquarePenIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
setIsDropDownOpen(false);
|
||||
onEdit();
|
||||
}}>
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<CopyIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
setIsDropDownOpen(false);
|
||||
handleDuplicateChart();
|
||||
}}
|
||||
disabled={isDuplicating}>
|
||||
{t("common.duplicate")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<PlusIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
setIsDropDownOpen(false);
|
||||
setIsAddToDashboardDialogOpen(true);
|
||||
}}>
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<TrashIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
setIsDropDownOpen(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
disabled={isDeleting}>
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat={t("common.chart")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
onDelete={handleDeleteChart}
|
||||
text={t("workspace.analysis.charts.delete_chart_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsAddToDashboardDialogOpen(open);
|
||||
if (!open) {
|
||||
setSelectedDashboardId(undefined);
|
||||
}
|
||||
}}
|
||||
chartName={chart.name}
|
||||
onChartNameChange={() => {}}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onConfirm={handleAddChartToDashboard}
|
||||
isSaving={isAddingToDashboard}
|
||||
showChartNameField={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
interface ChartErrorBoundaryProps {
|
||||
fallbackMessage: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ChartErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class ChartErrorBoundary extends Component<ChartErrorBoundaryProps, ChartErrorBoundaryState> {
|
||||
constructor(props: ChartErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): ChartErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error("ChartRenderer error:", error, info.componentStack);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 items-center justify-center text-sm">
|
||||
{this.props.fallbackMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, DatabaseIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChartErrorBoundary } from "@/modules/ee/analysis/charts/components/chart-error-boundary";
|
||||
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
|
||||
import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer";
|
||||
import { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
|
||||
interface ChartPreviewProps {
|
||||
chartData: AnalyticsResponse | null;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function ChartPreview({ chartData, isLoading = false, error }: Readonly<ChartPreviewProps>) {
|
||||
const [activeTab, setActiveTab] = useState<"chart" | "data">("chart");
|
||||
const { t } = useTranslation();
|
||||
|
||||
const data = chartData?.data ?? [];
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (value === "chart" || value === "data") {
|
||||
setActiveTab(value);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || chartData?.error) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-red-600">
|
||||
{error || chartData?.error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chartData) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.no_data_available")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<TabsList>
|
||||
<TabsTrigger value="chart" icon={<BarChart className="h-4 w-4" />}>
|
||||
{t("workspace.analysis.charts.chart")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="data" icon={<DatabaseIcon className="h-4 w-4" />}>
|
||||
{t("workspace.analysis.charts.chart_data_tab")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="chart" className="mt-0">
|
||||
<ChartErrorBoundary fallbackMessage={t("workspace.analysis.charts.chart_render_error")}>
|
||||
<ChartRenderer chartType={chartData.chartType} data={data} query={chartData.query} />
|
||||
</ChartErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="data" className="mt-0">
|
||||
<DataViewer data={data} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 font-semibold text-gray-900">{t("workspace.analysis.charts.chart_preview")}</h3>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Area, AreaChart, Bar, BarChart, Cell, Line, LineChart, Pie, PieChart } from "recharts";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesian-chart";
|
||||
import {
|
||||
CHART_BRAND_DARK,
|
||||
CHART_MEASURE_COLORS,
|
||||
formatXAxisTick,
|
||||
preparePieData,
|
||||
} from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import type { ChartConfig } from "@/modules/ui/components/chart";
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
|
||||
|
||||
const formatPieLabel = ({ name, percent }: { name: string; percent?: number }): string => {
|
||||
if (percent == null) return "";
|
||||
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
|
||||
};
|
||||
|
||||
interface ChartRendererProps {
|
||||
chartType: TChartType;
|
||||
data: TChartDataRow[];
|
||||
query: TChartQuery;
|
||||
}
|
||||
|
||||
export function ChartRenderer({ chartType, data, query }: Readonly<ChartRendererProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 items-center justify-center">
|
||||
{t("workspace.analysis.charts.no_data_available")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rowKeys = Object.keys(data[0] ?? {});
|
||||
const timeDim = query.timeDimensions?.[0];
|
||||
const timeDimKey = timeDim?.granularity
|
||||
? `${timeDim.dimension}.${timeDim.granularity}`
|
||||
: timeDim?.dimension;
|
||||
|
||||
const xAxisKey = query.dimensions?.[0] ?? timeDimKey ?? rowKeys[0] ?? "key";
|
||||
|
||||
const measureIds = query.measures?.filter((m) => rowKeys.includes(m)) ?? [];
|
||||
const dataKeys = measureIds.length > 0 ? measureIds : rowKeys.filter((k) => k !== xAxisKey);
|
||||
|
||||
if (dataKeys.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 items-center justify-center">
|
||||
{t("workspace.analysis.charts.no_data_available")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartConfig: ChartConfig = Object.fromEntries(
|
||||
dataKeys.map((key, i) => [
|
||||
key,
|
||||
{
|
||||
label: formatCubeColumnHeader(key, t),
|
||||
color: CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length],
|
||||
},
|
||||
])
|
||||
);
|
||||
const dataKey = dataKeys[0];
|
||||
const isMultiMeasure = dataKeys.length > 1;
|
||||
|
||||
switch (chartType) {
|
||||
case "bar":
|
||||
return (
|
||||
<CartesianChart
|
||||
chart={BarChart}
|
||||
data={data}
|
||||
xAxisKey={xAxisKey}
|
||||
dataKeys={dataKeys}
|
||||
chartConfig={chartConfig}
|
||||
showLegend
|
||||
chartProps={isMultiMeasure ? { barCategoryGap: "20%" } : {}}>
|
||||
{dataKeys.map((key, i) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
|
||||
radius={4}
|
||||
/>
|
||||
))}
|
||||
</CartesianChart>
|
||||
);
|
||||
case "line":
|
||||
return (
|
||||
<CartesianChart
|
||||
chart={LineChart}
|
||||
data={data}
|
||||
xAxisKey={xAxisKey}
|
||||
dataKeys={dataKeys}
|
||||
chartConfig={chartConfig}
|
||||
showLegend={isMultiMeasure}>
|
||||
{dataKeys.map((key, i) => {
|
||||
const color = chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length];
|
||||
return (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={color}
|
||||
strokeWidth={3}
|
||||
dot={{ fill: color, r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CartesianChart>
|
||||
);
|
||||
case "area":
|
||||
return (
|
||||
<CartesianChart
|
||||
chart={AreaChart}
|
||||
data={data}
|
||||
xAxisKey={xAxisKey}
|
||||
dataKeys={dataKeys}
|
||||
chartConfig={chartConfig}
|
||||
showLegend={isMultiMeasure}>
|
||||
{dataKeys.map((key, i) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
|
||||
fill={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
|
||||
fillOpacity={0.4}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</CartesianChart>
|
||||
);
|
||||
case "pie": {
|
||||
const pieResult = preparePieData(data, dataKey);
|
||||
if (!pieResult) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 items-center justify-center">
|
||||
{t("workspace.analysis.charts.no_valid_data_to_display")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { processedData, colors } = pieResult;
|
||||
|
||||
return (
|
||||
<div className="h-64 w-full min-w-0">
|
||||
<ChartContainer config={chartConfig} className="h-full w-full min-w-0">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={processedData}
|
||||
dataKey={dataKey}
|
||||
nameKey={xAxisKey}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
label={formatPieLabel}>
|
||||
{processedData.map((row, index) => {
|
||||
const rowKey = row[xAxisKey] ?? `row-${index}`;
|
||||
const uniqueKey = `${xAxisKey}-${String(rowKey)}-${index}`;
|
||||
return <Cell key={uniqueKey} fill={colors[index] || CHART_BRAND_DARK} />;
|
||||
})}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, name) => [String(value), formatCubeColumnHeader(String(name), t)]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "big_number": {
|
||||
const total =
|
||||
data.length === 1
|
||||
? Number(data[0]?.[dataKey]) || 0
|
||||
: data.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0);
|
||||
const formatted = total.toLocaleString();
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-foreground text-4xl font-bold">{formatted}</div>
|
||||
<div className="text-muted-foreground mt-2 text-sm">{formatCubeColumnHeader(dataKey, t)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 items-center justify-center">
|
||||
{t("workspace.analysis.charts.chart_type_not_supported", { chartType })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDate, timeSinceDate } from "@/lib/time";
|
||||
import { ChartDropdownMenu } from "@/modules/ee/analysis/charts/components/chart-dropdown-menu";
|
||||
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
|
||||
import { CHART_TYPE_ICONS } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartRowProps {
|
||||
chart: TChartWithCreator;
|
||||
workspaceId: string;
|
||||
isReadOnly: boolean;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function ChartRow({ chart, workspaceId, isReadOnly, directories }: Readonly<ChartRowProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const IconComponent = CHART_TYPE_ICONS[chart.type] ?? BarChart3Icon;
|
||||
|
||||
const handleChartClick = () => {
|
||||
if (!isReadOnly) {
|
||||
setIsEditDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleChartClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Cannot use native <button>; row contains dropdown trigger (nested interactive invalid) */}
|
||||
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
role={isReadOnly ? undefined : "button"}
|
||||
tabIndex={isReadOnly ? undefined : 0}
|
||||
onClick={isReadOnly ? undefined : handleChartClick}
|
||||
onKeyDown={isReadOnly ? undefined : handleRowKeyDown}
|
||||
aria-label={isReadOnly ? undefined : t("workspace.analysis.charts.open_chart", { name: chart.name })}
|
||||
className={`grid h-12 w-full grid-cols-7 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100 ${isReadOnly ? "" : "cursor-pointer"}`}>
|
||||
<div className="col-span-6 grid grid-cols-6 content-center">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{chart.creator?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{formatDate(new Date(chart.createdAt))}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{timeSinceDate(new Date(chart.updatedAt))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div // NOSONAR - stopPropagation wrapper to prevent row click when interacting with dropdown
|
||||
className="col-span-1 my-auto flex items-center justify-end pr-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}>
|
||||
{!isReadOnly && (
|
||||
<ChartDropdownMenu
|
||||
workspaceId={workspaceId}
|
||||
chart={chart}
|
||||
onEdit={() => setIsEditDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<CreateChartDialog
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
workspaceId={workspaceId}
|
||||
chartId={chart.id}
|
||||
initialChart={chart}
|
||||
onSuccess={() => setIsEditDialogOpen(false)}
|
||||
directories={directories}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartTypeSelectorProps {
|
||||
selectedChartType: TChartType;
|
||||
onChartTypeSelect: (chartType: TChartType) => void;
|
||||
}
|
||||
|
||||
export function ChartTypeSelector({
|
||||
selectedChartType,
|
||||
onChartTypeSelect,
|
||||
}: Readonly<ChartTypeSelectorProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartTypes = getChartTypes(t);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-md font-semibold text-gray-900">
|
||||
{t("workspace.analysis.charts.chart_builder_choose_chart_type")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{chartTypes.map((chart) => {
|
||||
const isSelected = selectedChartType === chart.id;
|
||||
return (
|
||||
<button
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onChartTypeSelect(chart.id)}
|
||||
className={`rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 ${
|
||||
isSelected
|
||||
? "border-brand-dark bg-brand-dark/5 ring-1 ring-brand-dark"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { use } from "react";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
|
||||
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
|
||||
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
|
||||
import { hasFeedbackRecordsInDirectories } from "@/modules/ee/analysis/lib/feedback-records";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
interface ChartsListContentProps {
|
||||
chartsPromise: Promise<TChartWithCreator[]>;
|
||||
workspaceId: string;
|
||||
isReadOnly: boolean;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const ChartsListContent = ({
|
||||
chartsPromise,
|
||||
workspaceId,
|
||||
isReadOnly,
|
||||
directories,
|
||||
}: Readonly<ChartsListContentProps>) => {
|
||||
const charts = use(chartsPromise);
|
||||
|
||||
return (
|
||||
<ChartsList charts={charts} workspaceId={workspaceId} isReadOnly={isReadOnly} directories={directories} />
|
||||
);
|
||||
};
|
||||
|
||||
interface ChartsListPageProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPageProps>) {
|
||||
const t = await getTranslate();
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
|
||||
directories.map((directory) => directory.id)
|
||||
);
|
||||
const chartsPromise = hasFeedbackRecords ? getChartsWithCreator(workspaceId) : null;
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.dashboards")}
|
||||
workspaceId={workspaceId}
|
||||
cta={
|
||||
isReadOnly ? undefined : (
|
||||
<CreateChartButton
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
buttonProps={{ disabled: !hasFeedbackRecords }}
|
||||
/>
|
||||
)
|
||||
}>
|
||||
{hasFeedbackRecords && chartsPromise ? (
|
||||
<ChartsListContent
|
||||
chartsPromise={chartsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
/>
|
||||
) : (
|
||||
<NoFeedbackRecordsState workspaceId={workspaceId} />
|
||||
)}
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
const SKELETON_ROWS = 3;
|
||||
|
||||
const SkeletonRow = () => {
|
||||
return (
|
||||
<div className="grid h-12 w-full animate-pulse grid-cols-7 content-center p-2">
|
||||
<div className="col-span-3 flex items-center gap-4 pl-6">
|
||||
<div className="h-5 w-5 rounded bg-gray-200" />
|
||||
<div className="h-4 w-36 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-20 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-20 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChartsListSkeletonProps {
|
||||
columnHeaders: [string, string, string, string];
|
||||
}
|
||||
|
||||
export const ChartsListSkeleton = ({ columnHeaders }: Readonly<ChartsListSkeletonProps>) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{columnHeaders[0]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[1]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[2]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[3]}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
|
||||
<SkeletonRow key={`skeleton-row-${String(i)}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ChartRow } from "@/modules/ee/analysis/charts/components/chart-row";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartsListProps {
|
||||
charts: TChartWithCreator[];
|
||||
workspaceId: string;
|
||||
isReadOnly: boolean;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export const ChartsList = async ({
|
||||
charts,
|
||||
workspaceId,
|
||||
isReadOnly,
|
||||
directories,
|
||||
}: Readonly<ChartsListProps>) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{t("common.title")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{charts.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
{t("workspace.analysis.charts.no_charts_found")}
|
||||
</p>
|
||||
) : (
|
||||
charts.map((chart) => (
|
||||
<ChartRow
|
||||
key={chart.id}
|
||||
chart={chart}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ConfigureChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentChartType: TChartType;
|
||||
configuredChartType: TChartType | null;
|
||||
onChartTypeSelect: (type: TChartType) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ConfigureChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentChartType,
|
||||
configuredChartType,
|
||||
onChartTypeSelect,
|
||||
onReset,
|
||||
}: Readonly<ConfigureChartDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartTypes = getChartTypes(t);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.analysis.charts.configure_title")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.analysis.charts.configure_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-md mb-3 font-semibold text-gray-900">
|
||||
{t("workspace.analysis.charts.configure_type_label")}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{chartTypes.map((chart) => {
|
||||
const isSelected = (configuredChartType || currentChartType) === chart.id;
|
||||
return (
|
||||
<button
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onChartTypeSelect(chart.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 rounded-lg border p-4 transition-all hover:bg-gray-50",
|
||||
isSelected
|
||||
? "border-brand-dark bg-brand-dark/5 ring-2 ring-brand-dark"
|
||||
: "border-gray-200"
|
||||
)}
|
||||
aria-label={chart.label}>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-5 w-5 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onReset} className="text-sm">
|
||||
{t("workspace.analysis.charts.reset_to_ai_suggestion")}
|
||||
</Button>
|
||||
{configuredChartType && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.original")}:{" "}
|
||||
{chartTypes.find((c) => c.id === currentChartType)?.label ?? currentChartType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>{t("workspace.analysis.charts.apply_changes")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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, 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,
|
||||
autoAddToDashboardId,
|
||||
label,
|
||||
onSuccess,
|
||||
showIcon = true,
|
||||
buttonProps,
|
||||
}: Readonly<CreateChartButtonProps>) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<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,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface CreateChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId?: string;
|
||||
autoAddToDashboardId?: string;
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function CreateChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
autoAddToDashboardId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<CreateChartDialogProps>) {
|
||||
return (
|
||||
<CreateChartView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
workspaceId={workspaceId}
|
||||
chartId={chartId}
|
||||
initialChart={initialChart}
|
||||
autoAddToDashboardId={autoAddToDashboardId}
|
||||
onSuccess={onSuccess}
|
||||
directories={directories}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { 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 { Alert } from "@/modules/ui/components/alert";
|
||||
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 CreateChartViewProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId?: string;
|
||||
initialChart?: TChartWithCreator;
|
||||
autoAddToDashboardId?: string;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
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,
|
||||
handleSaveChart,
|
||||
isSaving,
|
||||
selectedDirectoryId,
|
||||
handleClose,
|
||||
} = useChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
initialChart,
|
||||
autoAddToDashboardId,
|
||||
onSuccess,
|
||||
directories,
|
||||
});
|
||||
|
||||
const chartPreviewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartData) {
|
||||
chartPreviewRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}, [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={!isEditing}>
|
||||
<DialogHeader>
|
||||
<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">
|
||||
{hasSelectedDirectory ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
|
||||
|
||||
{chartType && (
|
||||
<AdvancedChartBuilder
|
||||
workspaceId={workspaceId}
|
||||
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")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isEditing || chartData) && (
|
||||
<div ref={chartPreviewRef}>
|
||||
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.analysis.charts.no_data_source_available")}</p>
|
||||
<a
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.analysis.charts.go_to_feedback_record_directories")}
|
||||
</a>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
{chartData && (
|
||||
<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,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { DatabaseIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatCellValue } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
const MAX_DISPLAY_ROWS = 50;
|
||||
interface DataViewerProps {
|
||||
data: TChartDataRow[];
|
||||
}
|
||||
|
||||
export function DataViewer({ data }: Readonly<DataViewerProps>) {
|
||||
const { t } = useTranslation();
|
||||
if (!data || data.length === 0 || Object.keys(data[0]).length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<p className="text-sm text-gray-500">{t("workspace.analysis.charts.no_data_available")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = Object.keys(data[0]);
|
||||
const displayData = data.slice(0, MAX_DISPLAY_ROWS);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<DatabaseIcon className="h-4 w-4 text-gray-600" />
|
||||
<h4 className="text-sm font-semibold text-gray-900">{t("workspace.analysis.charts.chart_data")}</h4>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-auto rounded bg-white">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
{columns.map((key) => (
|
||||
<th
|
||||
key={key}
|
||||
scope="col"
|
||||
className="border-b border-gray-200 px-3 py-2 text-left font-semibold">
|
||||
{formatCubeColumnHeader(key, t)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayData.map((row, index) => {
|
||||
const firstValue = Object.values(row)[0];
|
||||
const rowKey = firstValue ? String(firstValue) : `row-${index}`;
|
||||
return (
|
||||
<tr key={`data-row-${rowKey}-${index}`} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
{Object.entries(row).map(([key, value]) => (
|
||||
<td key={`cell-${key}-${rowKey}`} className="px-3 py-2">
|
||||
{formatCellValue(value)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{data.length > MAX_DISPLAY_ROWS && (
|
||||
<div className="px-3 py-2 text-xs text-gray-500">
|
||||
{t("workspace.analysis.charts.showing_first_n_of", {
|
||||
n: MAX_DISPLAY_ROWS,
|
||||
count: data.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FEEDBACK_FIELDS, getTranslatedFieldLabel } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
|
||||
interface DimensionsPanelProps {
|
||||
selectedDimensions: string[];
|
||||
onDimensionsChange: (dimensions: string[]) => void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function DimensionsPanel({
|
||||
selectedDimensions,
|
||||
onDimensionsChange,
|
||||
hideTitle = false,
|
||||
}: Readonly<DimensionsPanelProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dimensionOptions = FEEDBACK_FIELDS.dimensions.map((d) => ({
|
||||
value: d.id,
|
||||
label: [getTranslatedFieldLabel(d.id, t), d.description].filter(Boolean).join(" - "),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">{t("workspace.analysis.charts.dimensions")}</h3>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">{t("workspace.analysis.charts.group_by")}</Label>
|
||||
<MultiSelect
|
||||
options={dimensionOptions}
|
||||
value={selectedDimensions}
|
||||
onChange={onDimensionsChange}
|
||||
placeholder={t("workspace.analysis.charts.select_dimensions")}
|
||||
/>
|
||||
<Alert variant="info" size="small">
|
||||
<AlertTitle>{t("workspace.analysis.charts.group_by_description")}</AlertTitle>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FilterRow, TFilterFieldType } from "@/modules/ee/analysis/lib/query-builder";
|
||||
import {
|
||||
FEEDBACK_FIELDS,
|
||||
getFieldById,
|
||||
getFilterOperatorsForType,
|
||||
getTranslatedFieldLabel,
|
||||
} from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface FiltersPanelProps {
|
||||
filters: FilterRow[];
|
||||
filterLogic: "and" | "or";
|
||||
onFiltersChange: (filters: FilterRow[]) => void;
|
||||
onFilterLogicChange: (logic: "and" | "or") => void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function FiltersPanel({
|
||||
filters,
|
||||
filterLogic,
|
||||
onFiltersChange,
|
||||
onFilterLogicChange,
|
||||
hideTitle = false,
|
||||
}: Readonly<FiltersPanelProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fieldOptions = [
|
||||
...FEEDBACK_FIELDS.dimensions.map((d) => ({
|
||||
value: d.id,
|
||||
label: getTranslatedFieldLabel(d.id, t),
|
||||
type: d.type,
|
||||
})),
|
||||
...FEEDBACK_FIELDS.measures.map((m) => ({
|
||||
value: m.id,
|
||||
label: getTranslatedFieldLabel(m.id, t),
|
||||
type: "number" as TFilterFieldType,
|
||||
})),
|
||||
];
|
||||
|
||||
const handleAddFilter = () => {
|
||||
const firstField = fieldOptions[0];
|
||||
onFiltersChange([
|
||||
...filters,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
field: firstField?.value || "",
|
||||
operator: "equals",
|
||||
values: null,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveFilter = (index: number) => {
|
||||
onFiltersChange(filters.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpdateFilter = (index: number, updates: Partial<FilterRow>) => {
|
||||
const updated = [...filters];
|
||||
updated[index] = { ...updated[index], ...updates };
|
||||
if (updates.operator && (updates.operator === "set" || updates.operator === "notSet")) {
|
||||
updated[index].values = null;
|
||||
}
|
||||
onFiltersChange(updated);
|
||||
};
|
||||
|
||||
const getValueInput = (filter: FilterRow, index: number) => {
|
||||
const field = getFieldById(filter.field);
|
||||
const fieldType = (field?.type || "string") as TFilterFieldType;
|
||||
|
||||
if (filter.operator === "set" || filter.operator === "notSet") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isNumericInput =
|
||||
fieldType === "number" &&
|
||||
(filter.operator === "gt" ||
|
||||
filter.operator === "gte" ||
|
||||
filter.operator === "lt" ||
|
||||
filter.operator === "lte");
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={isNumericInput ? "number" : "text"}
|
||||
placeholder={t("workspace.analysis.charts.enter_value")}
|
||||
value={filter.values?.[0] ?? ""}
|
||||
onChange={(e) => {
|
||||
let values: string[] | number[] | null = null;
|
||||
if (e.target.value) {
|
||||
values = isNumericInput ? [Number(e.target.value)] : [e.target.value];
|
||||
}
|
||||
handleUpdateFilter(index, { values });
|
||||
}}
|
||||
className={isNumericInput ? "w-[150px] bg-white" : "w-[200px] bg-white"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const hasFilters = filters.length > 0;
|
||||
const hasMultipleFilters = filters.length > 1;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{hasMultipleFilters && (
|
||||
<div className={`flex items-center ${hideTitle ? "justify-start" : "justify-between"}`}>
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">{t("workspace.analysis.charts.filters")}</h3>
|
||||
)}
|
||||
<Select value={filterLogic} onValueChange={(value) => onFilterLogicChange(value as "and" | "or")}>
|
||||
<SelectTrigger className="w-[100px] bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="and">{t("workspace.analysis.charts.and_filter_logic")}</SelectItem>
|
||||
<SelectItem value="or">{t("workspace.analysis.charts.or_filter_logic")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-gray-200 bg-white p-3">
|
||||
{filters.map((filter, index) => {
|
||||
const field = getFieldById(filter.field);
|
||||
const fieldType = (field?.type || "string") as "string" | "number" | "time";
|
||||
const operators = getFilterOperatorsForType(fieldType);
|
||||
|
||||
return (
|
||||
<div key={filter.id} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={filter.field}
|
||||
onValueChange={(value) => {
|
||||
const newField = getFieldById(value);
|
||||
const newType = (newField?.type || "string") as TFilterFieldType;
|
||||
const newOperators = getFilterOperatorsForType(newType);
|
||||
handleUpdateFilter(index, {
|
||||
field: value,
|
||||
operator: newOperators[0] || "equals",
|
||||
values: null,
|
||||
});
|
||||
}}>
|
||||
<SelectTrigger className="w-[200px] bg-white">
|
||||
<SelectValue placeholder={t("workspace.analysis.charts.select_field")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(value) =>
|
||||
handleUpdateFilter(index, {
|
||||
operator: value,
|
||||
})
|
||||
}>
|
||||
<SelectTrigger className="w-[150px] bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{operators.map((op) => (
|
||||
<SelectItem key={op} value={op}>
|
||||
{op === "equals" && t("workspace.analysis.charts.equals")}
|
||||
{op === "notEquals" && t("workspace.analysis.charts.not_equals")}
|
||||
{op === "contains" && t("workspace.analysis.charts.contains")}
|
||||
{op === "notContains" && t("workspace.analysis.charts.not_contains")}
|
||||
{op === "set" && t("workspace.analysis.charts.is_set")}
|
||||
{op === "notSet" && t("workspace.analysis.charts.is_not_set")}
|
||||
{op === "gt" && t("workspace.analysis.charts.greater_than")}
|
||||
{op === "gte" && t("workspace.analysis.charts.greater_than_or_equal")}
|
||||
{op === "lt" && t("workspace.analysis.charts.less_than")}
|
||||
{op === "lte" && t("workspace.analysis.charts.less_than_or_equal")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{getValueInput(filter, index)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveFilter(index)}
|
||||
className="h-8 w-8">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasFilters && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddFilter} className="h-8">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("workspace.analysis.charts.add_filter")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ManualChartBuilderProps {
|
||||
selectedChartType?: TChartType;
|
||||
onChartTypeSelect: (type: TChartType) => void;
|
||||
}
|
||||
|
||||
export function ManualChartBuilder({
|
||||
selectedChartType,
|
||||
onChartTypeSelect,
|
||||
}: Readonly<ManualChartBuilderProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartTypes = getChartTypes(t);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{chartTypes.map((chart) => {
|
||||
const isSelected = selectedChartType === chart.id;
|
||||
return (
|
||||
<button
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onChartTypeSelect(chart.id)}
|
||||
className={cn(
|
||||
"focus:ring-brand-dark rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||
isSelected
|
||||
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
)}>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FEEDBACK_FIELDS, getTranslatedFieldLabel } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
|
||||
interface MeasuresPanelProps {
|
||||
selectedMeasures: string[];
|
||||
onMeasuresChange: (measures: string[]) => void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function MeasuresPanel({
|
||||
selectedMeasures,
|
||||
onMeasuresChange,
|
||||
hideTitle = false,
|
||||
}: Readonly<MeasuresPanelProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const measureOptions = FEEDBACK_FIELDS.measures.map((m) => ({
|
||||
value: m.id,
|
||||
label: [getTranslatedFieldLabel(m.id, t), m.description].filter(Boolean).join(" - "),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">{t("workspace.analysis.charts.measures")}</h3>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("workspace.analysis.charts.predefined_measures")}</label>
|
||||
<MultiSelect
|
||||
options={measureOptions}
|
||||
value={selectedMeasures}
|
||||
onChange={(selected) => onMeasuresChange(selected)}
|
||||
placeholder={t("workspace.analysis.charts.select_measures")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface SaveChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function SaveChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
chartName,
|
||||
onChartNameChange,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: Readonly<SaveChartDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.analysis.charts.save_chart_dialog_title")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.analysis.charts.enter_a_name_for_your_chart")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<label htmlFor="save-chart-name" className="sr-only">
|
||||
{t("workspace.analysis.charts.chart_name")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
maxLength={255}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && chartName.trim() && !isSaving) {
|
||||
onSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onSave} loading={isSaving} disabled={!chartName.trim()}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Calendar from "react-calendar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TimeDimensionConfig } from "@/modules/ee/analysis/lib/query-builder";
|
||||
import {
|
||||
DATE_PRESETS,
|
||||
FEEDBACK_FIELDS,
|
||||
TIME_GRANULARITIES,
|
||||
getTranslatedDatePresetLabel,
|
||||
getTranslatedFieldLabel,
|
||||
getTranslatedGranularityLabel,
|
||||
} from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import "@/modules/ui/components/date-picker/styles.css";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
const TIME_FIELD_OPTIONS = FEEDBACK_FIELDS.dimensions.filter((d) => d.type === "time");
|
||||
|
||||
interface TimeDimensionPanelProps {
|
||||
timeDimension: TimeDimensionConfig | null;
|
||||
onTimeDimensionChange: (config: TimeDimensionConfig | null) => void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function TimeDimensionPanel({
|
||||
timeDimension,
|
||||
onTimeDimensionChange,
|
||||
hideTitle = false,
|
||||
}: Readonly<TimeDimensionPanelProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [dateRangeType, setDateRangeType] = useState<"preset" | "custom">(
|
||||
timeDimension && typeof timeDimension.dateRange === "string" ? "preset" : "custom"
|
||||
);
|
||||
const [customStartDate, setCustomStartDate] = useState<Date | null>(
|
||||
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[0] : null
|
||||
);
|
||||
const [customEndDate, setCustomEndDate] = useState<Date | null>(
|
||||
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[1] : null
|
||||
);
|
||||
const [presetValue, setPresetValue] = useState<string>(
|
||||
timeDimension && typeof timeDimension.dateRange === "string" ? timeDimension.dateRange : ""
|
||||
);
|
||||
|
||||
const handleEnableTimeDimension = () => {
|
||||
if (!timeDimension) {
|
||||
onTimeDimensionChange({
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
dateRange: "last 30 days",
|
||||
});
|
||||
setPresetValue("last 30 days");
|
||||
setDateRangeType("preset");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionChange = (dimension: string) => {
|
||||
if (timeDimension) {
|
||||
onTimeDimensionChange({ ...timeDimension, dimension });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGranularityChange = (value: string) => {
|
||||
if (timeDimension) {
|
||||
const granularity = value === "none" ? undefined : (value as TimeDimensionConfig["granularity"]);
|
||||
onTimeDimensionChange({ ...timeDimension, granularity });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetChange = (preset: string) => {
|
||||
setPresetValue(preset);
|
||||
if (timeDimension) {
|
||||
onTimeDimensionChange({ ...timeDimension, dateRange: preset });
|
||||
}
|
||||
};
|
||||
|
||||
if (!timeDimension) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">
|
||||
{t("workspace.analysis.charts.time_dimension")}
|
||||
</h3>
|
||||
)}
|
||||
<div>
|
||||
<Button type="button" variant="outline" onClick={handleEnableTimeDimension}>
|
||||
{t("workspace.analysis.charts.enable_time_dimension")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">
|
||||
{t("workspace.analysis.charts.time_dimension")}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Field Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("workspace.analysis.charts.field")}</label>
|
||||
<Select value={timeDimension.dimension} onValueChange={handleDimensionChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_FIELD_OPTIONS.map((field) => (
|
||||
<SelectItem key={field.id} value={field.id}>
|
||||
{getTranslatedFieldLabel(field.id, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Granularity Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("workspace.analysis.charts.granularity")}</label>
|
||||
<Select value={timeDimension.granularity ?? "none"} onValueChange={handleGranularityChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t("workspace.analysis.charts.no_grouping")}</SelectItem>
|
||||
{TIME_GRANULARITIES.map((gran) => (
|
||||
<SelectItem key={gran} value={gran}>
|
||||
{getTranslatedGranularityLabel(gran, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("workspace.analysis.charts.date_range")}</label>
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={dateRangeType}
|
||||
onValueChange={(value) => setDateRangeType(value as "preset" | "custom")}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="preset">{t("workspace.analysis.charts.preset")}</SelectItem>
|
||||
<SelectItem value="custom">{t("workspace.analysis.charts.custom_range")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{dateRangeType === "preset" ? (
|
||||
<Select value={presetValue} onValueChange={handlePresetChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue placeholder={t("workspace.analysis.charts.select_preset")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{getTranslatedDatePresetLabel(preset.value, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{presetValue && !DATE_PRESETS.some((p) => p.value === presetValue) && (
|
||||
<SelectItem key={presetValue} value={presetValue}>
|
||||
{presetValue}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start bg-white text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{customStartDate
|
||||
? format(customStartDate, "MMM dd, yyyy")
|
||||
: t("workspace.analysis.charts.start_date")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
onChange={(value) => {
|
||||
const date = value instanceof Date ? value : new Date();
|
||||
setCustomStartDate(date);
|
||||
const end = customEndDate ?? date;
|
||||
if (timeDimension) {
|
||||
onTimeDimensionChange({
|
||||
...timeDimension,
|
||||
dateRange: [date, end],
|
||||
});
|
||||
}
|
||||
if (!customEndDate) setCustomEndDate(end);
|
||||
}}
|
||||
value={customStartDate || undefined}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start bg-white text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{customEndDate
|
||||
? format(customEndDate, "MMM dd, yyyy")
|
||||
: t("workspace.analysis.charts.end_date")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
onChange={(value) => {
|
||||
const date = value instanceof Date ? value : new Date();
|
||||
setCustomEndDate(date);
|
||||
const start = customStartDate ?? date;
|
||||
if (timeDimension) {
|
||||
onTimeDimensionChange({
|
||||
...timeDimension,
|
||||
dateRange: [start, date],
|
||||
});
|
||||
}
|
||||
if (!customStartDate) setCustomStartDate(start);
|
||||
}}
|
||||
value={customEndDate || undefined}
|
||||
minDate={customStartDate || undefined}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
createChartAction,
|
||||
deleteChartAction,
|
||||
executeQueryAction,
|
||||
getChartAction,
|
||||
updateChartAction,
|
||||
} from "@/modules/ee/analysis/charts/actions";
|
||||
import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import type {
|
||||
AnalyticsResponse,
|
||||
TChart,
|
||||
TChartType,
|
||||
TChartWithCreator,
|
||||
} from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface UseChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId?: string;
|
||||
autoAddToDashboardId?: string;
|
||||
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
directories?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function useChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
autoAddToDashboardId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<UseChartDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [selectedChartType, setSelectedChartType] = useState<TChartType | undefined>();
|
||||
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
|
||||
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||
const [chartName, setChartName] = useState("");
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string | undefined>();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
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?.[0]?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (isAddToDashboardDialogOpen) {
|
||||
getDashboardsAction({ workspaceId }).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result?.data) {
|
||||
setDashboards(result.data.map((d) => ({ id: d.id, name: d.name })));
|
||||
} else if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAddToDashboardDialogOpen, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!open) return;
|
||||
|
||||
if (!chartId) {
|
||||
setChartData(null);
|
||||
setChartName("");
|
||||
setSelectedChartType(undefined);
|
||||
setCurrentChartId(undefined);
|
||||
setSelectedDirectoryId(directories?.[0]?.id ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchChartById = async (id: string): Promise<TChart> => {
|
||||
const result = await getChartAction({ workspaceId, chartId: id });
|
||||
if (!result?.data) {
|
||||
throw new Error(
|
||||
getFormattedErrorMessage(result) || t("workspace.analysis.charts.failed_to_load_chart")
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setIsLoadingChart(true);
|
||||
setChartLoadError(null);
|
||||
|
||||
try {
|
||||
const chart = initialChart?.id === chartId ? initialChart : await fetchChartById(chartId);
|
||||
if (cancelled) return;
|
||||
|
||||
setChartName(chart.name);
|
||||
setSelectedChartType(resolveChartType(chart.type));
|
||||
setCurrentChartId(chart.id);
|
||||
setSelectedDirectoryId(chart.feedbackRecordDirectoryId);
|
||||
|
||||
const queryResult = await executeQueryAction({
|
||||
workspaceId,
|
||||
query: chart.query,
|
||||
feedbackRecordDirectoryId: chart.feedbackRecordDirectoryId,
|
||||
});
|
||||
if (cancelled) return;
|
||||
|
||||
if (queryResult?.serverError) {
|
||||
const errorMsg =
|
||||
getFormattedErrorMessage(queryResult) || t("workspace.analysis.charts.failed_to_load_chart_data");
|
||||
toast.error(errorMsg);
|
||||
setChartLoadError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(queryResult?.data)) {
|
||||
const errorMsg = t("workspace.analysis.charts.no_data_returned_for_chart");
|
||||
toast.error(errorMsg);
|
||||
setChartLoadError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setChartData({
|
||||
query: chart.query,
|
||||
chartType: resolveChartType(chart.type),
|
||||
data: queryResult.data,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("workspace.analysis.charts.failed_to_load_chart");
|
||||
toast.error(message);
|
||||
setChartLoadError(message);
|
||||
} finally {
|
||||
if (!cancelled) setIsLoadingChart(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, chartId, workspaceId, initialChart]);
|
||||
|
||||
const handleChartGenerated = (data: AnalyticsResponse) => {
|
||||
setChartData(data);
|
||||
setSelectedChartType(data.chartType);
|
||||
};
|
||||
|
||||
const handleSaveChart = async () => {
|
||||
if (!chartData || !chartName.trim()) {
|
||||
toast.error(t("workspace.analysis.charts.please_enter_chart_name"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedDirectoryId) {
|
||||
toast.error(t("workspace.analysis.charts.select_data_source_first"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let savedChartId = currentChartId;
|
||||
|
||||
if (currentChartId) {
|
||||
const result = await updateChartAction({
|
||||
workspaceId,
|
||||
chartId: currentChartId,
|
||||
chartUpdateInput: {
|
||||
name: chartName.trim(),
|
||||
type: chartData.chartType,
|
||||
query: chartData.query,
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.analysis.charts.chart_updated_successfully"));
|
||||
} else {
|
||||
const result = await createChartAction({
|
||||
workspaceId,
|
||||
chartInput: {
|
||||
name: chartName.trim(),
|
||||
type: chartData.chartType,
|
||||
query: chartData.query,
|
||||
config: {},
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentChartId(result.data.id);
|
||||
savedChartId = result.data.id;
|
||||
toast.success(t("workspace.analysis.charts.chart_saved_successfully"));
|
||||
}
|
||||
|
||||
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) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("workspace.analysis.charts.failed_to_save_chart");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupOrphanChart = async (orphanChartId: string) => {
|
||||
await deleteChartAction({ workspaceId, chartId: orphanChartId }).catch(() => {});
|
||||
setCurrentChartId(undefined);
|
||||
};
|
||||
|
||||
/** Returns the chart ID to use (existing or newly created), or null on failure. */
|
||||
const ensureChartForDashboard = async (data: AnalyticsResponse): Promise<string | null> => {
|
||||
if (currentChartId) return currentChartId;
|
||||
|
||||
if (!selectedDirectoryId) {
|
||||
toast.error(t("workspace.analysis.charts.select_data_source_first"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const chartResult = await createChartAction({
|
||||
workspaceId,
|
||||
chartInput: {
|
||||
name: chartName.trim(),
|
||||
type: data.chartType,
|
||||
query: data.query,
|
||||
config: {},
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!chartResult?.data) {
|
||||
toast.error(
|
||||
(chartResult && getFormattedErrorMessage(chartResult)) ||
|
||||
t("workspace.analysis.charts.failed_to_save_chart")
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
setCurrentChartId(chartResult.data.id);
|
||||
return chartResult.data.id;
|
||||
};
|
||||
|
||||
const handleAddToDashboard = async () => {
|
||||
if (!chartData || !selectedDashboardId) {
|
||||
toast.error(t("workspace.analysis.charts.please_select_dashboard"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentChartId && !chartName.trim()) {
|
||||
toast.error(t("workspace.analysis.charts.please_enter_chart_name"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
let newlyCreatedChartId: string | null = null;
|
||||
try {
|
||||
const chartIdToUse = await ensureChartForDashboard(chartData);
|
||||
if (!chartIdToUse) return;
|
||||
if (!currentChartId) newlyCreatedChartId = chartIdToUse;
|
||||
|
||||
const widgetResult = await addChartToDashboardAction({
|
||||
workspaceId,
|
||||
chartId: chartIdToUse,
|
||||
dashboardId: selectedDashboardId,
|
||||
});
|
||||
|
||||
if (!widgetResult?.data) {
|
||||
toast.error(
|
||||
(widgetResult && getFormattedErrorMessage(widgetResult)) ||
|
||||
t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
|
||||
);
|
||||
if (newlyCreatedChartId) await cleanupOrphanChart(newlyCreatedChartId);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.analysis.charts.chart_added_to_dashboard"));
|
||||
setIsAddToDashboardDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
onSuccess?.();
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("workspace.analysis.charts.failed_to_add_chart_to_dashboard");
|
||||
toast.error(message);
|
||||
if (newlyCreatedChartId) await cleanupOrphanChart(newlyCreatedChartId);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isSaving) {
|
||||
setChartData(null);
|
||||
setChartName("");
|
||||
setSelectedChartType(undefined);
|
||||
setCurrentChartId(undefined);
|
||||
setChartLoadError(null);
|
||||
setSelectedDirectoryId(directories?.[0]?.id ?? null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChartTypeChange = (type: TChartType) => {
|
||||
setSelectedChartType(type);
|
||||
setChartData((prev) => (prev ? { ...prev, chartType: type } : null));
|
||||
};
|
||||
|
||||
const initialQuery = initialChart && initialChart.id === chartId ? initialChart.query : undefined;
|
||||
|
||||
return {
|
||||
chartData,
|
||||
chartName,
|
||||
setChartName,
|
||||
selectedChartType,
|
||||
initialQuery,
|
||||
setSelectedChartType,
|
||||
currentChartId,
|
||||
setCurrentChartId,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
isSaving,
|
||||
isLoadingChart,
|
||||
chartLoadError,
|
||||
selectedDirectoryId,
|
||||
setSelectedDirectoryId,
|
||||
handleChartGenerated,
|
||||
handleSaveChart,
|
||||
handleAddToDashboard,
|
||||
handleClose,
|
||||
handleChartTypeChange,
|
||||
};
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { executeQueryAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface QueryResult {
|
||||
query: TChartQuery;
|
||||
data: TChartDataRow[];
|
||||
}
|
||||
|
||||
export function useChartQuery(
|
||||
workspaceId: string,
|
||||
feedbackRecordDirectoryId: string | null,
|
||||
initialQuery?: TChartQuery
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [chartData, setChartData] = useState<TChartDataRow[] | null>(null);
|
||||
const [query, setQuery] = useState<TChartQuery | null>(initialQuery ?? null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const runQuery = async (cubeQuery: TChartQuery): Promise<QueryResult | null> => {
|
||||
if (!feedbackRecordDirectoryId) {
|
||||
const msg = t("workspace.analysis.charts.select_data_source_first");
|
||||
toast.error(msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await executeQueryAction({
|
||||
workspaceId,
|
||||
query: cubeQuery,
|
||||
feedbackRecordDirectoryId,
|
||||
});
|
||||
|
||||
if (result?.serverError) {
|
||||
const msg = getFormattedErrorMessage(result);
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = Array.isArray(result?.data) ? result.data : [];
|
||||
if (data.length === 0) {
|
||||
const msg = t("workspace.analysis.charts.no_data_returned");
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
setChartData(data);
|
||||
setQuery(cubeQuery);
|
||||
toast.success(t("workspace.analysis.charts.query_executed_successfully"));
|
||||
return { query: cubeQuery, data };
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : t("workspace.analysis.charts.failed_to_execute_query");
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { chartData, query, isLoading, error, runQuery };
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { CHART_TYPE_ICONS, getChartTypes } from "./chart-types";
|
||||
|
||||
describe("chart-types", () => {
|
||||
test("CHART_TYPE_ICONS has all chart types", () => {
|
||||
expect(Object.keys(CHART_TYPE_ICONS)).toEqual(["area", "bar", "line", "pie", "big_number"]);
|
||||
});
|
||||
|
||||
test("getChartTypes returns chart types with translated labels", () => {
|
||||
const t = vi.fn((key: string) => key) as unknown as Parameters<typeof getChartTypes>[0];
|
||||
const result = getChartTypes(t);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.map((r) => r.id)).toEqual(["area", "bar", "line", "pie", "big_number"]);
|
||||
expect(t).toHaveBeenCalledWith("workspace.analysis.charts.chart_type_area");
|
||||
expect(result[0].label).toBe("workspace.analysis.charts.chart_type_area");
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { ActivityIcon, AreaChartIcon, BarChart3Icon, LineChartIcon, PieChartIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export const DEFAULT_CHART_TYPE: TChartType = "area";
|
||||
|
||||
export const CHART_TYPE_ICONS: Record<
|
||||
TChartType,
|
||||
React.ComponentType<{ className?: string; strokeWidth?: number }>
|
||||
> = {
|
||||
area: AreaChartIcon,
|
||||
bar: BarChart3Icon,
|
||||
line: LineChartIcon,
|
||||
pie: PieChartIcon,
|
||||
big_number: ActivityIcon,
|
||||
};
|
||||
|
||||
export function getChartTypes(t: TFunction): readonly {
|
||||
id: TChartType;
|
||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
label: string;
|
||||
}[] {
|
||||
return [
|
||||
{ id: "area", icon: CHART_TYPE_ICONS.area, label: t("workspace.analysis.charts.chart_type_area") },
|
||||
{ id: "bar", icon: CHART_TYPE_ICONS.bar, label: t("workspace.analysis.charts.chart_type_bar") },
|
||||
{ id: "line", icon: CHART_TYPE_ICONS.line, label: t("workspace.analysis.charts.chart_type_line") },
|
||||
{ id: "pie", icon: CHART_TYPE_ICONS.pie, label: t("workspace.analysis.charts.chart_type_pie") },
|
||||
{
|
||||
id: "big_number",
|
||||
icon: CHART_TYPE_ICONS.big_number,
|
||||
label: t("workspace.analysis.charts.chart_type_big_number"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
CHART_BRAND_DARK,
|
||||
CHART_MEASURE_COLORS,
|
||||
formatCellValue,
|
||||
formatXAxisTick,
|
||||
injectTenantFilter,
|
||||
preparePieData,
|
||||
resolveChartType,
|
||||
validateQueryMembers,
|
||||
} from "./chart-utils";
|
||||
|
||||
describe("chart-utils", () => {
|
||||
describe("resolveChartType", () => {
|
||||
test("returns valid chart types", () => {
|
||||
expect(resolveChartType("area")).toBe("area");
|
||||
expect(resolveChartType("bar")).toBe("bar");
|
||||
expect(resolveChartType("line")).toBe("line");
|
||||
expect(resolveChartType("pie")).toBe("pie");
|
||||
expect(resolveChartType("big_number")).toBe("big_number");
|
||||
});
|
||||
|
||||
test("defaults to bar for invalid type", () => {
|
||||
expect(resolveChartType("invalid")).toBe("bar");
|
||||
expect(resolveChartType("")).toBe("bar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("preparePieData", () => {
|
||||
test("returns null for empty or no valid numeric data", () => {
|
||||
expect(preparePieData([], "count")).toBeNull();
|
||||
expect(preparePieData([{ label: "A", count: "text" }], "count")).toBeNull();
|
||||
expect(preparePieData([{ label: "A", count: null }], "count")).toBeNull();
|
||||
});
|
||||
|
||||
test("filters to numeric rows and returns processedData with colors", () => {
|
||||
const data = [
|
||||
{ sentiment: "positive", count: 10 },
|
||||
{ sentiment: "negative", count: 5 },
|
||||
{ sentiment: "skip", count: "n/a" },
|
||||
];
|
||||
const result = preparePieData(data, "count");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.processedData).toHaveLength(2);
|
||||
expect(result!.processedData[0].count).toBe(10);
|
||||
expect(result!.colors[0]).toBe(CHART_BRAND_DARK);
|
||||
expect(result!.colors[1]).toBe("#00E6CA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatXAxisTick", () => {
|
||||
test("returns empty for null/undefined", () => {
|
||||
expect(formatXAxisTick(null)).toBe("");
|
||||
expect(formatXAxisTick(undefined)).toBe("");
|
||||
});
|
||||
|
||||
test("formats ISO date string", () => {
|
||||
expect(formatXAxisTick("2024-06-15")).toMatch(/Jun \d+, 2024/);
|
||||
});
|
||||
|
||||
test("passes through non-date string", () => {
|
||||
expect(formatXAxisTick("hello")).toBe("hello");
|
||||
});
|
||||
|
||||
test("formats number as string when it parses as date, else passes through", () => {
|
||||
expect(formatXAxisTick(1.5)).toBe("1.5");
|
||||
});
|
||||
|
||||
test("returns empty for boolean", () => {
|
||||
expect(formatXAxisTick(true)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCellValue", () => {
|
||||
test("returns empty for null/undefined", () => {
|
||||
expect(formatCellValue(null)).toBe("");
|
||||
expect(formatCellValue(undefined)).toBe("");
|
||||
});
|
||||
|
||||
test("formats number with locale", () => {
|
||||
expect(formatCellValue(1000)).toBe("1,000");
|
||||
expect(formatCellValue(3.14)).toBe("3.14");
|
||||
});
|
||||
|
||||
test("formats ISO date string", () => {
|
||||
expect(formatCellValue("2024-01-15")).toMatch(/Jan \d+, 2024/);
|
||||
});
|
||||
|
||||
test("returns string as-is when not date", () => {
|
||||
expect(formatCellValue("hello")).toBe("hello");
|
||||
});
|
||||
|
||||
test("stringifies object", () => {
|
||||
expect(formatCellValue({ a: 1 })).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
test("converts boolean and bigint", () => {
|
||||
expect(formatCellValue(true)).toBe("true");
|
||||
expect(formatCellValue(123n)).toBe("123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateQueryMembers", () => {
|
||||
test("does not throw for valid query", () => {
|
||||
expect(() =>
|
||||
validateQueryMembers({
|
||||
measures: ["FeedbackRecords.count"],
|
||||
dimensions: ["FeedbackRecords.sentiment"],
|
||||
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt" }],
|
||||
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for invalid measure", () => {
|
||||
expect(() => validateQueryMembers({ measures: ["Other.count"] })).toThrow(
|
||||
/Invalid query members.*Other\.count/
|
||||
);
|
||||
});
|
||||
|
||||
test("allows TopicsUnnested dimensions (joined cube)", () => {
|
||||
expect(() => validateQueryMembers({ dimensions: ["TopicsUnnested.topic"] })).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for invalid dimension", () => {
|
||||
expect(() => validateQueryMembers({ dimensions: ["OtherCube.field"] })).toThrow(
|
||||
/Invalid query members.*OtherCube\.field/
|
||||
);
|
||||
});
|
||||
|
||||
test("throws for invalid filter member", () => {
|
||||
expect(() =>
|
||||
validateQueryMembers({
|
||||
filters: [{ member: "Invalid.field", operator: "equals", values: ["x"] }],
|
||||
})
|
||||
).toThrow(/Invalid query members/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectTenantFilter", () => {
|
||||
test("appends tenant filter to query with no existing filters", () => {
|
||||
const query = { measures: ["FeedbackRecords.count"] };
|
||||
const result = injectTenantFilter(query, "tenant-123");
|
||||
|
||||
expect(result.filters).toHaveLength(1);
|
||||
expect(result.filters![0]).toEqual({
|
||||
member: "FeedbackRecords.tenantId",
|
||||
operator: "equals",
|
||||
values: ["tenant-123"],
|
||||
});
|
||||
expect(result.measures).toEqual(["FeedbackRecords.count"]);
|
||||
});
|
||||
|
||||
test("appends tenant filter to query with existing filters", () => {
|
||||
const query = {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals" as const, values: ["positive"] }],
|
||||
};
|
||||
const result = injectTenantFilter(query, "tenant-456");
|
||||
|
||||
expect(result.filters).toHaveLength(2);
|
||||
expect(result.filters![0]).toEqual({
|
||||
member: "FeedbackRecords.sentiment",
|
||||
operator: "equals",
|
||||
values: ["positive"],
|
||||
});
|
||||
expect(result.filters![1]).toEqual({
|
||||
member: "FeedbackRecords.tenantId",
|
||||
operator: "equals",
|
||||
values: ["tenant-456"],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not mutate the original query", () => {
|
||||
const query = {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals" as const, values: ["positive"] }],
|
||||
};
|
||||
const result = injectTenantFilter(query, "tenant-789");
|
||||
|
||||
expect(query.filters).toHaveLength(1);
|
||||
expect(result.filters).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constants", () => {
|
||||
test("CHART_MEASURE_COLORS has expected length", () => {
|
||||
expect(CHART_MEASURE_COLORS).toHaveLength(6);
|
||||
expect(CHART_MEASURE_COLORS[0]).toBe(CHART_BRAND_DARK);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { format, isValid, parseISO } from "date-fns";
|
||||
import type { TChartQuery, TCubeFilter } from "@formbricks/types/analysis";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { ZChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export const CHART_BRAND_DARK = "#00C4B8";
|
||||
export const CHART_BRAND_LIGHT = "#00E6CA";
|
||||
|
||||
/** Palette for multi-measure charts (grouped/stacked bars, multi-series line/area). */
|
||||
export const CHART_MEASURE_COLORS = [
|
||||
CHART_BRAND_DARK,
|
||||
"#6366f1", // indigo
|
||||
"#f59e0b", // amber
|
||||
"#ef4444", // red
|
||||
"#8b5cf6", // violet
|
||||
"#14b8a6", // teal
|
||||
];
|
||||
|
||||
/** Validate a chart type string, defaulting to "bar" if unrecognised. */
|
||||
export const resolveChartType = (raw: string): TChartType => {
|
||||
const parsed = ZChartType.safeParse(raw);
|
||||
return parsed.success ? parsed.data : "bar";
|
||||
};
|
||||
|
||||
const isNumericValue = (val: TChartDataRow[string]): boolean => {
|
||||
if (val === null || val === undefined || val === "") return false;
|
||||
const num = Number(val);
|
||||
return !Number.isNaN(num) && Number.isFinite(num);
|
||||
};
|
||||
|
||||
export const preparePieData = (
|
||||
data: TChartDataRow[],
|
||||
dataKey: string
|
||||
): { processedData: TChartDataRow[]; colors: string[] } | null => {
|
||||
const validData = data.filter((row) => isNumericValue(row[dataKey]));
|
||||
const processedData = validData.map((row) => ({ ...row, [dataKey]: Number(row[dataKey]) }));
|
||||
if (processedData.length === 0) return null;
|
||||
|
||||
const colors = processedData.map((_, i) => {
|
||||
const sat = 70 + (i % 3) * 10;
|
||||
const light = 45 + (i % 2) * 15;
|
||||
return `hsl(180, ${sat}%, ${light}%)`;
|
||||
});
|
||||
if (colors.length > 0) colors[0] = CHART_BRAND_DARK;
|
||||
if (colors.length > 1) colors[1] = CHART_BRAND_LIGHT;
|
||||
return { processedData, colors };
|
||||
};
|
||||
|
||||
/** Format a value for x-axis ticks; ISO date strings become "MMM d, yyyy", others pass through. */
|
||||
export function formatXAxisTick(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
let str: string;
|
||||
if (typeof value === "string") str = value;
|
||||
else if (typeof value === "number") str = String(value);
|
||||
else return "";
|
||||
const date = parseISO(str);
|
||||
if (isValid(date)) return format(date, "MMM d, yyyy");
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a cell value for display in tables and tooltips.
|
||||
* ISO date strings become "MMM d, yyyy"; numbers stay as-is (formatted); objects are stringified.
|
||||
*/
|
||||
export function formatCellValue(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "number") return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
if (typeof value === "string") {
|
||||
const date = parseISO(value);
|
||||
if (isValid(date)) return format(date, "MMM d, yyyy");
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
if (typeof value === "boolean" || typeof value === "bigint") return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
const ALLOWED_CUBE_PREFIXES = ["FeedbackRecords.", "TopicsUnnested."];
|
||||
|
||||
function validateMember(member: string): boolean {
|
||||
return ALLOWED_CUBE_PREFIXES.some((prefix) => member.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all measures, dimensions, segments, timeDimensions, and filters
|
||||
* use only members from FeedbackRecords or joined cubes (e.g. TopicsUnnested).
|
||||
* @throws Error if any member is invalid
|
||||
*/
|
||||
export function validateQueryMembers(query: TChartQuery): void {
|
||||
const invalid: string[] = [];
|
||||
for (const m of query.measures ?? []) {
|
||||
if (!validateMember(m)) invalid.push(m);
|
||||
}
|
||||
for (const d of query.dimensions ?? []) {
|
||||
if (!validateMember(d)) invalid.push(d);
|
||||
}
|
||||
for (const s of query.segments ?? []) {
|
||||
if (!validateMember(s)) invalid.push(s);
|
||||
}
|
||||
for (const td of query.timeDimensions ?? []) {
|
||||
if (!validateMember(td.dimension)) invalid.push(td.dimension);
|
||||
}
|
||||
const checkFilters = (f: TCubeFilter[]): void => {
|
||||
for (const item of f) {
|
||||
if ("member" in item && typeof item.member === "string" && !validateMember(item.member)) {
|
||||
invalid.push(item.member);
|
||||
}
|
||||
if ("and" in item && Array.isArray(item.and)) checkFilters(item.and);
|
||||
if ("or" in item && Array.isArray(item.or)) checkFilters(item.or);
|
||||
}
|
||||
};
|
||||
if (query.filters) checkFilters(query.filters);
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid query members (must start with FeedbackRecords. or TopicsUnnested.): ${invalid.join(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a tenant_id filter into a Cube.js query to scope results to a specific
|
||||
* FeedbackRecordDirectory. Called server-side before every query execution.
|
||||
*/
|
||||
export function injectTenantFilter(query: TChartQuery, tenantId: string): TChartQuery {
|
||||
const tenantFilter: TCubeFilter = {
|
||||
member: "FeedbackRecords.tenantId",
|
||||
operator: "equals",
|
||||
values: [tenantId],
|
||||
};
|
||||
return {
|
||||
...query,
|
||||
filters: [...(query.filters ?? []), tenantFilter],
|
||||
};
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
var mockTxChart: {
|
||||
// NOSONAR S1135 - var required for vi.mock hoisting
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
vi.mock("@formbricks/database", () => {
|
||||
const tx = {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
mockTxChart = tx;
|
||||
return {
|
||||
prisma: {
|
||||
chart: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((cb: any) => cb({ chart: tx })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockChartId = "chart-abc-123";
|
||||
const mockWorkspaceId = "workspace-abc-123";
|
||||
const mockUserId = "user-abc-123";
|
||||
|
||||
const selectChart = {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
};
|
||||
|
||||
const mockFeedbackRecordDirectoryId = "frd-abc-123";
|
||||
|
||||
const mockChart = {
|
||||
id: mockChartId,
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
|
||||
createdAt: new Date("2025-01-01"),
|
||||
updatedAt: new Date("2025-01-01"),
|
||||
};
|
||||
|
||||
const makePrismaError = (code: string) =>
|
||||
new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" });
|
||||
|
||||
describe("Chart Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createChart", () => {
|
||||
test("creates a chart successfully", async () => {
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue(mockChart as any);
|
||||
const { createChart } = await import("./charts");
|
||||
|
||||
const result = await createChart({
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockChart);
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
workspaceId: mockWorkspaceId,
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
|
||||
createdBy: mockUserId,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
vi.mocked(prisma.chart.create).mockRejectedValue(
|
||||
makePrismaError(PrismaErrorType.UniqueConstraintViolation)
|
||||
);
|
||||
const { createChart } = await import("./charts");
|
||||
|
||||
await expect(
|
||||
createChart({
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "Duplicate",
|
||||
type: "bar",
|
||||
query: {},
|
||||
config: {},
|
||||
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.create).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { createChart } = await import("./charts");
|
||||
|
||||
await expect(
|
||||
createChart({
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "Test",
|
||||
type: "bar",
|
||||
query: {},
|
||||
config: {},
|
||||
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateChart", () => {
|
||||
test("updates a chart successfully", async () => {
|
||||
const updatedChart = { ...mockChart, name: "Updated Chart" };
|
||||
mockTxChart.findFirst.mockResolvedValue(mockChart);
|
||||
mockTxChart.update.mockResolvedValue(updatedChart);
|
||||
const { updateChart } = await import("./charts");
|
||||
|
||||
const result = await updateChart(mockChartId, mockWorkspaceId, { name: "Updated Chart" });
|
||||
|
||||
expect(result).toEqual({ chart: mockChart, updatedChart });
|
||||
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, workspaceId: mockWorkspaceId },
|
||||
select: selectChart,
|
||||
});
|
||||
expect(mockTxChart.update).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId },
|
||||
data: { name: "Updated Chart", type: undefined, query: undefined, config: undefined },
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(null);
|
||||
const { updateChart } = await import("./charts");
|
||||
|
||||
await expect(updateChart(mockChartId, mockWorkspaceId, { name: "Updated" })).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
expect(mockTxChart.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(mockChart);
|
||||
mockTxChart.update.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
|
||||
const { updateChart } = await import("./charts");
|
||||
|
||||
await expect(updateChart(mockChartId, mockWorkspaceId, { name: "Taken Name" })).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateChart", () => {
|
||||
test("duplicates a chart with '(copy)' suffix", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({ ...mockChart, name: "Test Chart (copy)" } as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockWorkspaceId, mockUserId);
|
||||
|
||||
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, workspaceId: mockWorkspaceId },
|
||||
select: selectChart,
|
||||
});
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Chart (copy)" }),
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("increments copy number when '(copy)' already exists", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({
|
||||
...mockChart,
|
||||
name: "Test Chart (copy 2)",
|
||||
} as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockWorkspaceId, mockUserId);
|
||||
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Chart (copy 2)" }),
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("finds next available copy number", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([
|
||||
{ name: "Test Chart (copy)" },
|
||||
{ name: "Test Chart (copy 2)" },
|
||||
] as any);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({
|
||||
...mockChart,
|
||||
name: "Test Chart (copy 3)",
|
||||
} as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockWorkspaceId, mockUserId);
|
||||
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Chart (copy 3)" }),
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("strips existing copy suffix before generating new name", async () => {
|
||||
const chartWithCopy = { ...mockChart, name: "Test Chart (copy)" };
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(chartWithCopy as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({
|
||||
...mockChart,
|
||||
name: "Test Chart (copy 2)",
|
||||
} as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockWorkspaceId, mockUserId);
|
||||
|
||||
expect(prisma.chart.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId, name: { startsWith: "Test Chart (copy" } },
|
||||
select: { name: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when source chart does not exist", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await expect(duplicateChart(mockChartId, mockWorkspaceId, mockUserId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteChart", () => {
|
||||
test("deletes a chart successfully", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(mockChart);
|
||||
mockTxChart.delete.mockResolvedValue(undefined);
|
||||
const { deleteChart } = await import("./charts");
|
||||
|
||||
const result = await deleteChart(mockChartId, mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockChart);
|
||||
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, workspaceId: mockWorkspaceId },
|
||||
select: selectChart,
|
||||
});
|
||||
expect(mockTxChart.delete).toHaveBeenCalledWith({ where: { id: mockChartId } });
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(null);
|
||||
const { deleteChart } = await import("./charts");
|
||||
|
||||
await expect(deleteChart(mockChartId, mockWorkspaceId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
expect(mockTxChart.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
mockTxChart.findFirst.mockRejectedValue(makePrismaError("P9999"));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
|
||||
const { deleteChart } = await import("./charts");
|
||||
|
||||
await expect(deleteChart(mockChartId, mockWorkspaceId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChart", () => {
|
||||
test("returns a chart successfully", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
const { getChart } = await import("./charts");
|
||||
|
||||
const result = await getChart(mockChartId, mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockChart);
|
||||
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, workspaceId: mockWorkspaceId },
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
|
||||
const { getChart } = await import("./charts");
|
||||
|
||||
await expect(getChart(mockChartId, mockWorkspaceId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getChart } = await import("./charts");
|
||||
|
||||
await expect(getChart(mockChartId, mockWorkspaceId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCharts", () => {
|
||||
test("returns all charts for a project", async () => {
|
||||
const chartsFromDb = [
|
||||
{ ...mockChart, creator: { name: "User 1" } },
|
||||
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: { name: null } },
|
||||
];
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue(chartsFromDb as any);
|
||||
const { getCharts } = await import("./charts");
|
||||
|
||||
const result = await getCharts(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ ...mockChart, creator: { name: "User 1" } },
|
||||
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: { name: null } },
|
||||
]);
|
||||
expect(prisma.chart.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
creator: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no charts exist", async () => {
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
|
||||
const { getCharts } = await import("./charts");
|
||||
|
||||
const result = await getCharts(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getCharts } = await import("./charts");
|
||||
|
||||
await expect(getCharts(mockWorkspaceId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChartsWithCreator", () => {
|
||||
test("returns charts with creator info", async () => {
|
||||
const chartsWithCreator = [
|
||||
{ ...mockChart, creator: { name: "Alice" } },
|
||||
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: null },
|
||||
];
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue(chartsWithCreator as any);
|
||||
const { getChartsWithCreator } = await import("./charts");
|
||||
|
||||
const result = await getChartsWithCreator(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(chartsWithCreator);
|
||||
expect(prisma.chart.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: expect.objectContaining({
|
||||
creator: { select: { name: true } },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getChartsWithCreator } = await import("./charts");
|
||||
|
||||
await expect(getChartsWithCreator(mockWorkspaceId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,273 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZChartConfig, ZChartQuery } from "@formbricks/types/analysis";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import {
|
||||
TChart,
|
||||
TChartCreateInput,
|
||||
TChartUpdateInput,
|
||||
TChartWithCreator,
|
||||
ZChartCreateInput,
|
||||
ZChartType,
|
||||
ZChartUpdateInput,
|
||||
} from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export const selectChart = {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
feedbackRecordDirectoryId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const;
|
||||
|
||||
export const createChart = async (data: TChartCreateInput): Promise<TChart> => {
|
||||
validateInputs([data, ZChartCreateInput]);
|
||||
|
||||
try {
|
||||
return await prisma.chart.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
workspaceId: data.workspaceId,
|
||||
query: data.query,
|
||||
config: data.config,
|
||||
createdBy: data.createdBy,
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A chart with this name already exists");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateChart = async (
|
||||
chartId: string,
|
||||
workspaceId: string,
|
||||
data: TChartUpdateInput
|
||||
): Promise<{ chart: TChart; updatedChart: TChart }> => {
|
||||
validateInputs([chartId, ZId], [workspaceId, ZId], [data, ZChartUpdateInput]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const chart = await tx.chart.findFirst({
|
||||
where: { id: chartId, workspaceId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
const updatedChart = await tx.chart.update({
|
||||
where: { id: chartId },
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
query: data.query,
|
||||
config: data.config,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
return { chart, updatedChart };
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A chart with this name already exists");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getUniqueCopyName = async (baseName: string, workspaceId: string): Promise<string> => {
|
||||
const stripped = baseName.replace(/ \(copy(?: \d+)?\)$/, "");
|
||||
|
||||
try {
|
||||
const existing = await prisma.chart.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
name: { startsWith: `${stripped} (copy` },
|
||||
},
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
const existingNames = new Set(existing.map((c) => c.name));
|
||||
|
||||
const firstCandidate = `${stripped} (copy)`;
|
||||
if (!existingNames.has(firstCandidate)) {
|
||||
return firstCandidate;
|
||||
}
|
||||
|
||||
let n = 2;
|
||||
while (existingNames.has(`${stripped} (copy ${n})`)) {
|
||||
n++;
|
||||
}
|
||||
return `${stripped} (copy ${n})`;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const duplicateChart = async (
|
||||
chartId: string,
|
||||
workspaceId: string,
|
||||
createdBy: string
|
||||
): Promise<TChart> => {
|
||||
validateInputs([chartId, ZId], [workspaceId, ZId], [createdBy, ZId]);
|
||||
|
||||
try {
|
||||
const sourceChart = await prisma.chart.findFirst({
|
||||
where: { id: chartId, workspaceId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!sourceChart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
const uniqueName = await getUniqueCopyName(sourceChart.name, workspaceId);
|
||||
|
||||
return await createChart({
|
||||
workspaceId,
|
||||
name: uniqueName,
|
||||
type: ZChartType.parse(sourceChart.type),
|
||||
query: ZChartQuery.parse(sourceChart.query),
|
||||
config: ZChartConfig.parse(sourceChart.config ?? {}),
|
||||
feedbackRecordDirectoryId: sourceChart.feedbackRecordDirectoryId,
|
||||
createdBy,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError || error instanceof InvalidInputError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteChart = async (chartId: string, workspaceId: string): Promise<TChart> => {
|
||||
validateInputs([chartId, ZId], [workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const chart = await tx.chart.findFirst({
|
||||
where: { id: chartId, workspaceId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
await tx.chart.delete({
|
||||
where: { id: chartId },
|
||||
});
|
||||
|
||||
return chart;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getChart = async (chartId: string, workspaceId: string): Promise<TChart> => {
|
||||
validateInputs([chartId, ZId], [workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
const chart = await prisma.chart.findFirst({
|
||||
where: { id: chartId, workspaceId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
return chart;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCharts = async (workspaceId: string): Promise<TChartWithCreator[]> => {
|
||||
validateInputs([workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
const charts = await prisma.chart.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
...selectChart,
|
||||
creator: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
return charts;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getChartsWithCreator = async (workspaceId: string): Promise<TChartWithCreator[]> => {
|
||||
validateInputs([workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.chart.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
...selectChart,
|
||||
creator: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { AnalysisSecondaryNavigation } from "./analysis-secondary-navigation";
|
||||
|
||||
interface AnalysisPageLayoutProps {
|
||||
pageTitle: string;
|
||||
workspaceId: string;
|
||||
cta?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AnalysisPageLayout({
|
||||
pageTitle,
|
||||
workspaceId,
|
||||
cta,
|
||||
children,
|
||||
}: Readonly<AnalysisPageLayoutProps>) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} cta={cta}>
|
||||
<AnalysisSecondaryNavigation workspaceId={workspaceId} />
|
||||
</PageHeader>
|
||||
{children}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user