Compare commits

..

31 Commits

Author SHA1 Message Date
Matti Nannt 8c7112e559 feat: remove FeedbackRecordDirectory entity, use workspace.id as Hub tenant_id
Drops FRD as a separate org-level entity in favour of using workspace.id
directly as the Hub tenant_id. This eliminates the dual-auth model, removes
the implicit cascade where workspace read granted access to XM data, and
simplifies the connector/chart/API-key permission surfaces.

Key changes:
- Schema: drop FeedbackRecordDirectory, FeedbackRecordDirectoryWorkspace and
  ApiKeyFeedbackRecordDirectory models; remove FKs from Chart/Connector/ApiKey
- Connector pipeline, CSV import and import now pass connector.workspaceId as
  tenant_id instead of feedbackRecordDirectoryId
- Chart actions: injectTenantFilter now receives workspaceId
- API key create/list: FRD permission section removed entirely
- Workspace create: no longer auto-creates/links a default FRD
- Feedback records page: single workspace-scoped Hub query replaces multi-FRD loop
- Delete entire modules/ee/feedback-record-directory module
- All tests updated; pnpm test (4570), build and lint pass clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 06:43:33 +02:00
Anshuman Pandey f59e9f13ec feat: refresh analysis charts and dashboard feedback gating (#7915)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-29 16:29:14 +04:00
Anshuman Pandey 5169dec510 feat: wire workspace settings to feedback record directories (#7910)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 14:49:50 +04:00
Anshuman Pandey 8442dedf9c fix: removes project references (#7907) 2026-04-29 14:17:42 +04:00
Johannes fbe2a31133 refactor: align connector enum with formbricks_survey (#7825)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-29 10:57:20 +04:00
Anshuman Pandey 89eb04f813 fix: adds submission id to csv connector (#7898) 2026-04-29 10:36:16 +04:00
Dhruwang Jariwala a862b739f7 fix: consistent enabled/disabled wording for connector status (#7897) 2026-04-28 15:11:44 +05:30
Dhruwang 4e5df85538 fix: make pipeline dispatch fire-and-forget in management responses route
Pipeline errors (snapshot loading or dispatch) should not prevent the
201 response from being returned. Dispatch pipeline events without
awaiting so the response is returned immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 14:31:24 +05:30
Dhruwang 727b349086 fix: resolve pre-existing build errors on epic/v5
- Add optional chaining for organization.billing in response pipeline
- Add missing feedbackRecordDirectoryId to Chart seed data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 13:47:20 +05:30
Dhruwang f75db6b1d0 fix: translations 2026-04-28 12:39:12 +05:30
Dhruwang 7ffca53577 fix: use consistent enabled/disabled wording for connector status badges
The dropdown actions say "Enable"/"Disable" but the status badges showed
"In Progress"/"Paused". Now both use "Enabled"/"Disabled" for consistency.

Resolves ENG-769

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 12:24:49 +05:30
Dhruwang Jariwala 25614b23fc chore: remove legacy styling fields (questionColor, inputColor) (#7783) 2026-04-28 11:22:03 +05:30
Johannes 016e14d0f1 fix: (Depr Env QA) Surface legacy env var on Connection page (#7773) 2026-04-27 14:20:25 +00:00
Dhruwang 5e76ebdfc1 fix: treat JSON null as absent in legacy styling migration
The `?` operator only checks key existence — if the form layer saved
`{"elementHeadlineColor": null}` (JSON null = "use default"), the
migration skipped the copy and then removed the legacy key, losing
the color value. Switch to COALESCE(styling->'field', 'null'::jsonb)
= 'null'::jsonb which catches both missing keys (SQL NULL) and JSON
null values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 17:07:05 +05:30
Bhagya Amarasinghe 150f256721 fix: decouple pipeline from response ingestion (#7651) 2026-04-27 12:59:27 +05:30
pandeymangg da7971328c little cleanup 2026-04-27 12:58:42 +05:30
Bhagya Amarasinghe a6cd56b196 fix: migrate response pipeline to BullMQ (#7651) 2026-04-27 12:58:42 +05:30
pandeymangg 7c81cf119e adds test for schedulePipelineDrain when env vars are not set 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 8d29b24352 fix: address latest pipeline review comments (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe a1ae849496 fix: address CodeRabbit pipeline findings (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 4d0a686e89 fix: address pipeline PR checks (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 364915e4c8 fix: decouple pipeline from response ingestion (#1487) 2026-04-27 12:56:22 +05:30
Tiago 817b299436 chore: rename gcp ai provider to google (#7815) 2026-04-24 10:10:58 +00:00
Tiago Farto c140dae872 Merge branch 'epic/v5' into chore/rename_google 2026-04-24 09:51:39 +00:00
Tiago Farto 21ed383a46 chore: address PR concerns 2026-04-23 13:45:09 +00:00
Tiago Farto 7aa12a4f0c chore: rename google ai things 2026-04-23 12:27:39 +00:00
Dhruwang 8edef8aede refactor: replace repeated union type with TDimension alias in TBaseStyling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:22:13 +05:30
Dhruwang 54fb202285 fix: add jsonb_typeof guard to legacy styling migration
Ensures the UPDATE only processes JSONB objects, preventing errors
on unexpected scalar or array values in the styling column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:16:50 +05:30
Dhruwang c720a462a7 fix: rename inputColor to inputTextColor in survey-ui storybook files
Aligns storybook story helpers and element stories with the legacy
field removal — inputColor → inputTextColor, mapping to
--fb-input-text-color CSS variable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:12:53 +05:30
Dhruwang 730ab6a609 fix: use valid hex colors in styles unit tests
Replace invalid fake hex values (e.g. "#btn-bg", "#headline-color") with
valid hex colors so isLight() and mixColor() don't throw. Add missing
inputTextColor to the survey styling test so --fb-placeholder-color is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 17:41:19 +05:30
Dhruwang 4e75a57692 chore: remove legacy styling fields (questionColor, inputColor) for v5
Add a SQL migration that copies legacy coarse-grained styling fields
to granular equivalents (e.g. questionColor → elementHeadlineColor,
inputColor → inputBgColor) and strips the legacy keys from the JSONB.

Remove the runtime deriveNewFieldsFromLegacy() shim, all fallback
chains in CSS variable generation, and update types, schemas, tests,
and OpenAPI spec to reflect the new canonical field names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 17:00:20 +05:30
189 changed files with 3462 additions and 5879 deletions
+7 -7
View File
@@ -167,16 +167,16 @@ AZUREAD_TENANT_ID=
# Configure Formbricks AI at the instance level
# Set the provider used for AI features on this instance.
# Accepted values for AI_PROVIDER: aws, gcp, azure
# Accepted values for AI_PROVIDER: aws, google, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
# AI_PROVIDER=gcp
# AI_PROVIDER=google
# AI_MODEL=gemini-2.5-flash
# Google Vertex AI credentials
# AI_GCP_PROJECT=
# AI_GCP_LOCATION=
# AI_GCP_CREDENTIALS_JSON=
# AI_GCP_APPLICATION_CREDENTIALS=
# Google Cloud credentials for Gemini models
# AI_GOOGLE_CLOUD_PROJECT=
# AI_GOOGLE_CLOUD_LOCATION=
# AI_GOOGLE_CLOUD_CREDENTIALS_JSON=
# AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS=
# Amazon Bedrock credentials
# AI_AWS_REGION=
@@ -60,8 +60,8 @@ const mockTemplate: TXMTemplate = {
],
styling: {
brandColor: { light: "#0000FF" },
questionColor: { light: "#00FF00" },
inputColor: { light: "#FF0000" },
elementHeadlineColor: { light: "#00FF00" },
inputBgColor: { light: "#FF0000" },
},
};
@@ -1 +1 @@
export { WorkspaceSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
export { WorkspaceFeedbackSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
@@ -23,9 +23,7 @@ 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 ZCreateWorkspaceInput = ZWorkspaceUpdateInput;
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
@@ -121,7 +119,7 @@ const ZGetWorkspacesForSwitcherAction = z.object({
});
/**
* Fetches projects list for switcher dropdown.
* Fetches workspaces list for switcher dropdown.
* Called on-demand when user opens the workspace switcher.
*/
export const getWorkspacesForSwitcherAction = authenticatedActionClient
@@ -150,7 +150,7 @@ export const MainNavigation = ({
() => [
{
id: "ask",
name: "Ask",
name: t("common.ask"),
items: [
{
name: t("common.surveys"),
@@ -337,15 +337,6 @@ export const MainNavigation = ({
href: `/workspaces/${workspace.id}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.title"),
href: `/workspaces/${workspace.id}/settings/feedback-record-directories`,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
];
const loadWorkspaces = useCallback(async () => {
@@ -179,15 +179,6 @@ export const OrganizationBreadcrumb = ({
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.title"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
];
return (
@@ -42,13 +42,6 @@ export const OrganizationSettingsNavbar = ({
href: `${workspaceBasePath}/settings/teams`,
current: pathname?.includes("/teams"),
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.nav_label"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
current: pathname?.includes("/feedback-record-directories"),
hidden: isMember,
},
{
id: "api-keys",
label: t("common.api_keys"),
@@ -1 +0,0 @@
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";
@@ -1,83 +1,19 @@
"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,
});
import {
TCreateFeedbackRecordAction,
TRetrieveFeedbackRecordAction,
TUpdateFeedbackRecordAction,
ZCreateFeedbackRecordAction,
ZRetrieveFeedbackRecordAction,
ZUpdateFeedbackRecordAction,
} from "./types";
const ensureAccess = async (
userId: string,
@@ -102,14 +38,10 @@ const ensureAccess = async (
});
};
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");
const assertRecordBelongsToWorkspace = (workspaceId: string, tenantId: string): void => {
if (tenantId !== workspaceId) {
// Throw a generic error indistinguishable from "not found" to prevent IDOR
throw new Error("Feedback record not found");
}
};
@@ -121,17 +53,16 @@ export const retrieveFeedbackRecordAction = authenticatedActionClient
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZRetrieveFeedbackRecordAction>;
parsedInput: TRetrieveFeedbackRecordAction;
}) => {
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");
throw new Error("Feedback record not found");
}
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, recordResult.data.tenant_id);
assertRecordBelongsToWorkspace(parsedInput.workspaceId, recordResult.data.tenant_id);
return recordResult.data;
}
@@ -145,16 +76,35 @@ export const createFeedbackRecordAction = authenticatedActionClient
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateFeedbackRecordAction>;
parsedInput: TCreateFeedbackRecordAction;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
assertRecordBelongsToWorkspace(parsedInput.workspaceId, parsedInput.recordInput.tenant_id);
const createResult = await createFeedbackRecord(
parsedInput.recordInput as unknown as FeedbackRecordCreateParams
);
const { recordInput } = parsedInput;
const createParams: FeedbackRecordCreateParams = {
submission_id: recordInput.submission_id,
tenant_id: recordInput.tenant_id,
source_type: recordInput.source_type,
field_id: recordInput.field_id,
field_type: recordInput.field_type,
collected_at: recordInput.collected_at,
source_id: recordInput.source_id,
source_name: recordInput.source_name,
field_label: recordInput.field_label,
field_group_id: recordInput.field_group_id,
field_group_label: recordInput.field_group_label,
value_text: recordInput.value_text,
value_number: recordInput.value_number,
value_boolean: recordInput.value_boolean,
value_date: recordInput.value_date,
metadata: recordInput.metadata,
language: recordInput.language,
user_identifier: recordInput.user_identifier,
};
const createResult = await createFeedbackRecord(createParams);
if (!createResult.data || createResult.error) {
throw new Error(createResult.error?.message || "Failed to create feedback record");
}
@@ -171,23 +121,35 @@ export const updateFeedbackRecordAction = authenticatedActionClient
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateFeedbackRecordAction>;
parsedInput: TUpdateFeedbackRecordAction;
}) => {
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");
throw new Error("Feedback record not found");
}
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
assertRecordBelongsToWorkspace(parsedInput.workspaceId, currentRecordResult.data.tenant_id);
const updatePayload = Object.fromEntries(
Object.entries(parsedInput.updateInput).filter(([, value]) => value !== undefined)
) as unknown as FeedbackRecordUpdateParams;
const { updateInput } = parsedInput;
const updateParams: FeedbackRecordUpdateParams = {
...(updateInput.value_text !== undefined && { value_text: updateInput.value_text ?? undefined }),
...(updateInput.value_number !== undefined && {
value_number: updateInput.value_number ?? undefined,
}),
...(updateInput.value_boolean !== undefined && {
value_boolean: updateInput.value_boolean ?? undefined,
}),
...(updateInput.value_date !== undefined && { value_date: updateInput.value_date ?? undefined }),
...(updateInput.language !== undefined && { language: updateInput.language ?? undefined }),
...(updateInput.metadata !== undefined && { metadata: updateInput.metadata }),
...(updateInput.user_identifier !== undefined && {
user_identifier: updateInput.user_identifier ?? undefined,
}),
};
const updateResult = await updateFeedbackRecord(parsedInput.recordId, updatePayload);
const updateResult = await updateFeedbackRecord(parsedInput.recordId, updateParams);
if (!updateResult.data || updateResult.error) {
throw new Error(updateResult.error?.message || "Failed to update feedback record");
}
@@ -6,8 +6,6 @@ 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";
@@ -41,7 +39,25 @@ import {
createFeedbackRecordAction,
retrieveFeedbackRecordAction,
updateFeedbackRecordAction,
} from "./actions";
} from "../actions";
import {
FIELD_TYPE_OPTIONS,
SOURCE_TYPE_CUSTOM_VALUE,
SOURCE_TYPE_PRESET_OPTIONS,
type TFeedbackRecordFormValues,
ZFeedbackRecordFormValues,
} from "../lib/types";
import {
formatSourceType,
getCreateDefaults,
getReadOnlyMetadataEntries,
getValueFieldByType,
isPresetSourceType,
mapRecordToValues,
parseNumberValue,
toISOOrUndefined,
} from "../lib/utils";
import { type TFeedbackRecordUpdateInput } from "../types";
type FeedbackRecordDrawerMode = "create" | "edit";
@@ -50,210 +66,16 @@ interface FeedbackRecordFormDrawerProps {
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,
@@ -264,7 +86,7 @@ export const FeedbackRecordFormDrawer = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
const defaultValues = useMemo(() => getCreateDefaults(workspaceId), [workspaceId]);
const form = useForm<TFeedbackRecordFormValues>({
resolver: zodResolver(ZFeedbackRecordFormValues),
@@ -287,12 +109,12 @@ export const FeedbackRecordFormDrawer = ({
const readOnlyMetadataEntries = useMemo(() => (record ? getReadOnlyMetadataEntries(record) : []), [record]);
const resetForCreate = useCallback(() => {
const nextDefaults = getCreateDefaults(directories);
const nextDefaults = getCreateDefaults(workspaceId);
form.reset(nextDefaults);
setRecord(null);
setSourceTypeMode(nextDefaults.source_type);
setCustomSourceType("");
}, [directories, form]);
}, [workspaceId, form]);
useEffect(() => {
if (!open) return;
@@ -311,24 +133,20 @@ export const FeedbackRecordFormDrawer = ({
if (!result?.data) {
toast.error(getFormattedErrorMessage(result) || t("workspace.unify.failed_to_load_feedback_records"));
setIsLoadingRecord(false);
onOpenChange(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
);
const isPreset = isPresetSourceType(result.data.source_type);
setSourceTypeMode(isPreset ? result.data.source_type : SOURCE_TYPE_CUSTOM_VALUE);
setCustomSourceType(isPreset ? "" : result.data.source_type);
setIsLoadingRecord(false);
};
void loadRecord();
}, [form, mode, open, recordId, resetForCreate, t, workspaceId]);
}, [form, mode, onOpenChange, open, recordId, resetForCreate, t, workspaceId]);
const requestClose = useCallback(() => {
if (form.formState.isDirty && !isSubmitting) {
@@ -360,116 +178,124 @@ export const FeedbackRecordFormDrawer = ({
form.setError(selectedValueField, { type: "manual", message });
};
const handleSubmit = form.handleSubmit(async (values) => {
form.clearErrors();
const isCreateValueFieldValid = (values: TFeedbackRecordFormValues): boolean => {
if (selectedValueField === "value_text") return Boolean(values.value_text?.trim());
if (selectedValueField === "value_number") return parseNumberValue(values.value_number ?? "") != null;
if (selectedValueField === "value_boolean") return values.value_boolean !== undefined;
if (selectedValueField === "value_date") return Boolean(toISOOrUndefined(values.value_date));
return true;
};
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(
const buildMetadataMap = (values: TFeedbackRecordFormValues): Record<string, string> =>
Object.fromEntries(
values.metadataEntries
.map((entry) => ({
key: entry.key.trim(),
value: entry.value,
}))
.map((entry) => ({ key: entry.key.trim(), value: entry.value }))
.filter((entry) => entry.key.length > 0)
.map((entry) => [entry.key, entry.value])
);
const buildCreateValueFields = (values: TFeedbackRecordFormValues) => ({
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,
});
const getUpdateValueField = (
values: TFeedbackRecordFormValues
): Pick<TFeedbackRecordUpdateInput, "value_text" | "value_number" | "value_boolean" | "value_date"> => {
if (selectedValueField === "value_text") return { value_text: values.value_text?.trim() ?? "" };
if (selectedValueField === "value_number") {
return { value_number: parseNumberValue(values.value_number ?? "") };
}
if (selectedValueField === "value_boolean") return { value_boolean: values.value_boolean ?? null };
if (selectedValueField === "value_date") {
return { value_date: toISOOrUndefined(values.value_date) ?? null };
}
return {};
};
const submitCreate = async (
values: TFeedbackRecordFormValues,
metadata: Record<string, string>
): Promise<boolean> => {
const sourceTypeValue =
sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE ? customSourceType.trim() : values.source_type;
const result = 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),
...buildCreateValueFields(values),
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
language: values.language?.trim() || undefined,
user_identifier: values.user_identifier?.trim() || undefined,
},
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return false;
}
return true;
};
const submitUpdate = async (
values: TFeedbackRecordFormValues,
metadata: Record<string, string>
): Promise<boolean> => {
if (!recordId) return false;
const preservedMetadata = Object.fromEntries(
Object.entries(record?.metadata ?? {}).filter(([, value]) => typeof value !== "string")
);
const result = await updateFeedbackRecordAction({
workspaceId,
recordId,
updateInput: {
language: values.language?.trim() || null,
user_identifier: values.user_identifier?.trim() || null,
metadata: { ...preservedMetadata, ...metadata },
...getUpdateValueField(values),
},
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return false;
}
return true;
};
const handleSubmit = form.handleSubmit(async (values) => {
form.clearErrors();
if (mode === "create" && !isCreateValueFieldValid(values)) {
setStrictValueValidationError(t("workspace.unify.feedback_record_value_required"));
return;
}
const metadata = buildMetadataMap(values);
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;
}
}
const ok =
mode === "create" ? await submitCreate(values, metadata) : await submitUpdate(values, metadata);
if (!ok) return;
toast.success(
mode === "create"
@@ -533,22 +359,9 @@ export const FeedbackRecordFormDrawer = ({
name="tenant_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
<FormLabel>{t("workspace.unify.workspace")}</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>
<Input {...field} disabled />
</FormControl>
<FormError />
</FormItem>
@@ -643,7 +456,7 @@ export const FeedbackRecordFormDrawer = ({
<SelectContent>
{SOURCE_TYPE_PRESET_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
{formatSourceType(option, t)}
</SelectItem>
))}
<SelectItem value={SOURCE_TYPE_CUSTOM_VALUE}>
@@ -4,13 +4,12 @@ import { useTranslation } from "react-i18next";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UnifyConfigNavigation } from "../components/UnifyConfigNavigation";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
import { FeedbackRecordsTable } from "./feedback-records-table";
interface FeedbackRecordsPageClientProps {
workspaceId: string;
initialRecords: FeedbackRecordData[];
frdMap: Record<string, string>;
csvSources: { id: string; name: string }[];
canWrite: boolean;
}
@@ -18,7 +17,6 @@ interface FeedbackRecordsPageClientProps {
export function FeedbackRecordsPageClient({
workspaceId,
initialRecords,
frdMap,
csvSources,
canWrite,
}: Readonly<FeedbackRecordsPageClientProps>) {
@@ -33,7 +31,6 @@ export function FeedbackRecordsPageClient({
<FeedbackRecordsTable
workspaceId={workspaceId}
initialRecords={initialRecords}
frdMap={frdMap}
csvSources={csvSources}
canWrite={canWrite}
/>
@@ -12,7 +12,7 @@ import {
TypeIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
@@ -29,7 +29,8 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { CsvImportModal } from "../sources/components/csv-import-modal";
import { CsvImportModal } from "../../sources/components/csv-import-modal";
import { formatSourceType } from "../lib/utils";
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
const RECORDS_PER_PAGE = 50;
@@ -54,18 +55,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) + "…";
@@ -74,7 +63,6 @@ function truncate(str: string, maxLen: number): string {
interface FeedbackRecordsTableProps {
workspaceId: string;
initialRecords: FeedbackRecordData[];
frdMap: Record<string, string>;
csvSources: { id: string; name: string }[];
canWrite: boolean;
}
@@ -82,7 +70,6 @@ interface FeedbackRecordsTableProps {
export const FeedbackRecordsTable = ({
workspaceId,
initialRecords,
frdMap,
csvSources,
canWrite,
}: Readonly<FeedbackRecordsTableProps>) => {
@@ -95,51 +82,19 @@ export const FeedbackRecordsTable = ({
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
const directories = useMemo(
() =>
Object.entries(frdMap)
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name)),
[frdMap]
);
const feedbackDirectoryName = useMemo(() => {
const directoryNames = Array.from(
new Set(
records
.map((record) => frdMap[record.tenant_id])
.filter((directoryName): directoryName is string => Boolean(directoryName))
)
);
if (directoryNames.length > 0) {
return directoryNames.join(", ");
}
return directories[0]?.name ?? "—";
}, [directories, frdMap, records]);
const handleRefresh = async () => {
if (isRefreshing) return;
setIsRefreshing(true);
setError(null);
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
const directoryIds = Object.keys(frdMap);
const results = await Promise.all(
directoryIds.map((frdId) =>
listFeedbackRecordsAction({
workspaceId,
frdId,
limit: RECORDS_PER_PAGE,
})
)
);
const result = await listFeedbackRecordsAction({
workspaceId,
limit: RECORDS_PER_PAGE,
});
const successfulRecords = results.flatMap((result) => result?.data?.data ?? []);
if (directoryIds.length > 0 && successfulRecords.length === 0) {
const firstErrorResult = results.find((result) => !result?.data);
const errorMessage = firstErrorResult ? getFormattedErrorMessage(firstErrorResult) : undefined;
if (!result?.data) {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage ?? t("workspace.unify.failed_to_load_feedback_records"), {
id: toastId,
});
@@ -147,8 +102,8 @@ export const FeedbackRecordsTable = ({
return;
}
const mergedRecords = successfulRecords
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
const mergedRecords = (result.data.data ?? [])
.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.slice(0, RECORDS_PER_PAGE);
setRecords(mergedRecords);
setIsRefreshing(false);
@@ -195,7 +150,6 @@ export const FeedbackRecordsTable = ({
<p className="text-sm text-slate-500">
{t("workspace.unify.showing_count_loaded", {
count: records.length,
directoryName: feedbackDirectoryName,
})}
</p>
)}
@@ -296,7 +250,6 @@ export const FeedbackRecordsTable = ({
open={isDrawerOpen}
onOpenChange={setIsDrawerOpen}
workspaceId={workspaceId}
directories={directories}
canWrite={canWrite}
recordId={drawerMode === "edit" ? drawerRecordId : undefined}
onSuccess={handleRefresh}
@@ -339,8 +292,17 @@ const FeedbackRecordRow = ({
return (
<tr
className="cursor-pointer text-sm text-slate-700 transition-colors hover:bg-slate-50"
onClick={onClick}>
className="cursor-pointer text-sm text-slate-700 transition-colors focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
tabIndex={0}
role="button"
aria-label={record.field_label ?? record.field_id}
onClick={onClick}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
}}>
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
</td>
@@ -0,0 +1,57 @@
import { z } from "zod";
export const FIELD_TYPE_OPTIONS = [
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
] as const;
export const SOURCE_TYPE_PRESET_OPTIONS = [
"survey",
"review",
"feedback_form",
"support",
"social",
"interview",
"usability_test",
"nps_campaign",
] as const;
export const SOURCE_TYPE_CUSTOM_VALUE = "__custom__";
const ZMetadataEntry = z.object({
key: z.string().trim(),
value: z.string(),
});
export const ZFeedbackRecordFormValues = z.object({
id: z.string().optional(),
tenant_id: z.string().min(1),
submission_id: z.string().min(1),
collected_at: z.string().min(1),
created_at: z.string().optional(),
updated_at: z.string().optional(),
source_type: z.string().min(1),
source_id: z.string().optional(),
source_name: z.string().optional(),
field_id: z.string().min(1),
field_label: z.string().optional(),
field_type: z.enum(FIELD_TYPE_OPTIONS),
field_group_id: z.string().optional(),
field_group_label: z.string().optional(),
value_text: z.string().optional(),
value_number: z.string().optional(),
value_boolean: z.boolean().optional(),
value_date: z.string().optional(),
language: z.string().optional(),
user_identifier: z.string().optional(),
metadataEntries: z.array(ZMetadataEntry),
});
export type TFeedbackRecordFormValues = z.infer<typeof ZFeedbackRecordFormValues>;
@@ -0,0 +1,168 @@
import { describe, expect, test, vi } from "vitest";
import type { FeedbackRecordData } from "@/modules/hub/types";
import {
formatSourceType,
getCreateDefaults,
getReadOnlyMetadataEntries,
getValueFieldByType,
isPresetSourceType,
mapRecordToValues,
parseNumberValue,
toISOOrUndefined,
toLocalDateTimeInput,
} from "./utils";
vi.mock("uuid", () => ({ v7: () => "mock-uuid-v7" }));
const makeRecord = (overrides: Partial<FeedbackRecordData> = {}): FeedbackRecordData => ({
id: "rec-1",
tenant_id: "tenant-1",
submission_id: "sub-1",
collected_at: "2026-03-15T12:00:00.000Z",
created_at: "2026-03-15T12:00:00.000Z",
updated_at: "2026-03-15T12:00:00.000Z",
source_type: "survey",
field_id: "f1",
field_type: "text",
...overrides,
});
describe("getValueFieldByType", () => {
test.each([
["boolean", "value_boolean"],
["date", "value_date"],
["nps", "value_number"],
["csat", "value_number"],
["ces", "value_number"],
["rating", "value_number"],
["number", "value_number"],
["text", "value_text"],
["categorical", "value_text"],
] as const)("returns %s → %s", (input, expected) => {
expect(getValueFieldByType(input)).toBe(expected);
});
});
describe("toLocalDateTimeInput", () => {
test("formats valid ISO date", () => {
const result = toLocalDateTimeInput("2026-03-15T14:30:00.000Z");
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
});
test("returns empty string for invalid date", () => {
expect(toLocalDateTimeInput("not-a-date")).toBe("");
});
});
describe("toISOOrUndefined", () => {
test("returns ISO string for valid input", () => {
expect(toISOOrUndefined("2026-03-15T14:30")).toMatch(/2026-03-15/);
});
test("returns undefined for empty string", () => {
expect(toISOOrUndefined("")).toBeUndefined();
});
test("returns undefined for undefined", () => {
expect(toISOOrUndefined(undefined)).toBeUndefined();
});
test("returns undefined for invalid date", () => {
expect(toISOOrUndefined("not-a-date")).toBeUndefined();
});
});
describe("getCreateDefaults", () => {
test("uses workspaceId as tenant_id", () => {
const result = getCreateDefaults("ws-1");
expect(result.tenant_id).toBe("ws-1");
expect(result.submission_id).toBe("mock-uuid-v7");
expect(result.field_type).toBe("text");
expect(result.metadataEntries).toEqual([]);
});
test("returns empty string for empty workspaceId", () => {
const result = getCreateDefaults("");
expect(result.tenant_id).toBe("");
});
});
describe("mapRecordToValues", () => {
test("maps a full record", () => {
const record = makeRecord({
value_text: "hello",
value_number: 42,
source_id: "s1",
source_name: "Survey",
metadata: { tag: "vip", nested: { a: 1 } },
});
const result = mapRecordToValues(record);
expect(result.id).toBe("rec-1");
expect(result.value_text).toBe("hello");
expect(result.value_number).toBe("42");
expect(result.source_id).toBe("s1");
expect(result.metadataEntries).toEqual([{ key: "tag", value: "vip" }]);
});
test("handles nullish optional fields", () => {
const record = makeRecord({ value_number: undefined, source_id: undefined });
const result = mapRecordToValues(record);
expect(result.value_number).toBe("");
expect(result.source_id).toBe("");
});
});
describe("getReadOnlyMetadataEntries", () => {
test("returns only non-string metadata values", () => {
const record = makeRecord({ metadata: { tag: "vip", count: 5, nested: { a: 1 } } });
const result = getReadOnlyMetadataEntries(record);
expect(result).toEqual([
{ key: "count", value: "5" },
{ key: "nested", value: '{"a":1}' },
]);
});
test("returns empty array when no metadata", () => {
expect(getReadOnlyMetadataEntries(makeRecord())).toEqual([]);
});
});
describe("parseNumberValue", () => {
test.each([
["42", 42],
["3.14", 3.14],
["-1", -1],
["", null],
[" ", null],
["abc", null],
["Infinity", null],
])("parseNumberValue(%s) → %s", (input, expected) => {
expect(parseNumberValue(input)).toBe(expected);
});
});
describe("isPresetSourceType", () => {
test("returns true for preset values", () => {
expect(isPresetSourceType("survey")).toBe(true);
expect(isPresetSourceType("nps_campaign")).toBe(true);
});
test("returns false for custom values", () => {
expect(isPresetSourceType("custom_type")).toBe(false);
expect(isPresetSourceType("")).toBe(false);
});
});
describe("formatSourceType", () => {
const t = ((key: string) => key) as any;
test("maps known source types", () => {
expect(formatSourceType("formbricks", t)).toBe("workspace.unify.formbricks_surveys");
expect(formatSourceType("formbricks_survey", t)).toBe("workspace.unify.formbricks_surveys");
expect(formatSourceType("csv", t)).toBe("workspace.unify.csv_import");
});
test("returns raw value for unknown types", () => {
expect(formatSourceType("custom", t)).toBe("custom");
});
});
@@ -0,0 +1,158 @@
import { TFunction } from "i18next";
import { v7 as uuidv7 } from "uuid";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { SOURCE_TYPE_PRESET_OPTIONS, type TFeedbackRecordFormValues } from "./types";
export const getValueFieldByType = (
fieldType: TFeedbackRecordFormValues["field_type"]
): "value_text" | "value_number" | "value_boolean" | "value_date" => {
switch (fieldType) {
case "boolean":
return "value_boolean";
case "date":
return "value_date";
case "nps":
case "csat":
case "ces":
case "rating":
case "number":
return "value_number";
default:
return "value_text";
}
};
export const toLocalDateTimeInput = (isoDate: string): string => {
const date = new Date(isoDate);
if (!Number.isFinite(date.getTime())) {
return "";
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
export const toISOOrUndefined = (dateTimeValue: string | undefined): string | undefined => {
if (!dateTimeValue) {
return undefined;
}
const parsed = new Date(dateTimeValue);
if (!Number.isFinite(parsed.getTime())) {
return undefined;
}
return parsed.toISOString();
};
export const getCreateDefaults = (workspaceId: string): TFeedbackRecordFormValues => {
const now = new Date();
return {
id: "",
tenant_id: workspaceId,
submission_id: uuidv7(),
collected_at: toLocalDateTimeInput(now.toISOString()),
created_at: "",
updated_at: "",
source_type: "survey",
source_id: "",
source_name: "",
field_id: "",
field_label: "",
field_type: "text",
field_group_id: "",
field_group_label: "",
value_text: "",
value_number: "",
value_boolean: undefined,
value_date: "",
language: "",
user_identifier: "",
metadataEntries: [],
};
};
export const mapRecordToValues = (record: FeedbackRecordData): TFeedbackRecordFormValues => {
const metadataEntries = Object.entries(record.metadata ?? {})
.filter(([, value]) => typeof value === "string")
.map(([key, value]) => ({
key,
value: value as string,
}));
return {
id: record.id,
tenant_id: record.tenant_id,
submission_id: record.submission_id,
collected_at: toLocalDateTimeInput(record.collected_at),
created_at: record.created_at ? toLocalDateTimeInput(record.created_at) : "",
updated_at: record.updated_at ? toLocalDateTimeInput(record.updated_at) : "",
source_type: record.source_type,
source_id: record.source_id ?? "",
source_name: record.source_name ?? "",
field_id: record.field_id,
field_label: record.field_label ?? "",
field_type: record.field_type,
field_group_id: record.field_group_id ?? "",
field_group_label: record.field_group_label ?? "",
value_text: record.value_text ?? "",
value_number: record.value_number == null ? "" : String(record.value_number),
value_boolean: record.value_boolean,
value_date: record.value_date ? toLocalDateTimeInput(record.value_date) : "",
language: record.language ?? "",
user_identifier: record.user_identifier ?? "",
metadataEntries,
};
};
export const getReadOnlyMetadataEntries = (record: FeedbackRecordData): { key: string; value: string }[] => {
return Object.entries(record.metadata ?? {})
.filter(([, value]) => typeof value !== "string")
.map(([key, value]) => ({
key,
value: JSON.stringify(value),
}));
};
export const parseNumberValue = (value: string): number | null => {
if (value.trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
export const isPresetSourceType = (value: string): value is (typeof SOURCE_TYPE_PRESET_OPTIONS)[number] =>
(SOURCE_TYPE_PRESET_OPTIONS as readonly string[]).includes(value);
export const formatSourceType = (sourceType: string, t: TFunction): string => {
switch (sourceType) {
case "formbricks":
case "formbricks_survey":
return t("workspace.unify.formbricks_surveys");
case "csv":
return t("workspace.unify.csv_import");
case "survey":
return t("workspace.unify.source_type_label_survey");
case "review":
return t("workspace.unify.source_type_label_review");
case "feedback_form":
return t("workspace.unify.source_type_label_feedback_form");
case "support":
return t("workspace.unify.source_type_label_support");
case "social":
return t("workspace.unify.source_type_label_social");
case "interview":
return t("workspace.unify.source_type_label_interview");
case "usability_test":
return t("workspace.unify.source_type_label_usability_test");
case "nps_campaign":
return t("workspace.unify.source_type_label_nps_campaign");
default:
return sourceType;
}
};
@@ -1,10 +1,9 @@
import { notFound } from "next/navigation";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getTranslate } from "@/lingodotdev/server";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { listFeedbackRecords } from "@/modules/hub/service";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { FeedbackRecordsPageClient } from "./feedback-records-page-client";
import { FeedbackRecordsPageClient } from "./components/feedback-records-page-client";
const INITIAL_PAGE_SIZE = 50;
@@ -27,24 +26,14 @@ export default async function UnifyFeedbackRecordsPage(
return notFound();
}
const [frds, connectors] = await Promise.all([
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
const [recordsResult, connectors] = await Promise.all([
listFeedbackRecords({ tenant_id: params.workspaceId, limit: INITIAL_PAGE_SIZE }),
getConnectorsWithMappings(params.workspaceId),
]);
const results = await Promise.all(
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
);
// Don't crash if Hub is unreachable — show empty state
const successfulResults = results.filter((r) => !r.error);
const initialRecords = recordsResult.error ? [] : (recordsResult.data?.data ?? []);
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 }));
@@ -52,8 +41,7 @@ export default async function UnifyFeedbackRecordsPage(
return (
<FeedbackRecordsPageClient
workspaceId={params.workspaceId}
initialRecords={merged}
frdMap={frdMap}
initialRecords={initialRecords}
csvSources={csvSources}
canWrite={canWrite}
/>
@@ -0,0 +1,80 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
export const ZFeedbackRecordId = z.uuid();
export const ZFeedbackRecordFieldType = z.enum([
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
]);
export const ZFeedbackRecordMetadata = z.record(z.string(), z.unknown());
export 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(),
});
export type TFeedbackRecordCreateInput = z.infer<typeof ZFeedbackRecordCreateInput>;
export 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"
);
export type TFeedbackRecordUpdateInput = z.infer<typeof ZFeedbackRecordUpdateInput>;
export const ZRetrieveFeedbackRecordAction = z.object({
workspaceId: ZId,
recordId: ZFeedbackRecordId,
});
export type TRetrieveFeedbackRecordAction = z.infer<typeof ZRetrieveFeedbackRecordAction>;
export const ZCreateFeedbackRecordAction = z.object({
workspaceId: ZId,
recordInput: ZFeedbackRecordCreateInput,
});
export type TCreateFeedbackRecordAction = z.infer<typeof ZCreateFeedbackRecordAction>;
export const ZUpdateFeedbackRecordAction = z.object({
workspaceId: ZId,
recordId: ZFeedbackRecordId,
updateInput: ZFeedbackRecordUpdateInput,
});
export type TUpdateFeedbackRecordAction = z.infer<typeof ZUpdateFeedbackRecordAction>;
@@ -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;
};
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
@@ -14,7 +13,6 @@ 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";
@@ -28,34 +26,22 @@ interface ConnectorsSectionProps {
workspaceId: string;
initialConnectors: TConnectorWithMappings[];
initialSurveys: TUnifySurvey[];
directories: { id: string; name: string }[];
}
export function ConnectorsSection({
workspaceId,
initialConnectors,
initialSurveys,
directories,
}: Readonly<ConnectorsSectionProps>) {
const { t } = useTranslation();
const router = useRouter();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingConnector, setEditingConnector] = useState<TConnectorWithMappings | null>(null);
const [csvImportConnector, setCsvImportConnector] = useState<TConnectorWithMappings | null>(null);
const directoryNames = directories.map((directory) => directory.name).join(", ");
const feedbackDirectoryAccessText =
directories.length === 1
? t("workspace.unify.feedback_sources_directory_access_single", {
directoryNames,
})
: t("workspace.unify.feedback_sources_directory_access_multiple", {
directoryNames,
});
const handleCreateConnector = async (data: {
name: string;
type: TConnectorType;
feedbackRecordDirectoryId: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}): Promise<string | undefined> => {
@@ -64,7 +50,6 @@ export function ConnectorsSection({
connectorInput: {
name: data.name,
type: data.type,
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
},
formbricksMappings:
data.type === "formbricks_survey" && data.surveyMappings?.length ? data.surveyMappings : undefined,
@@ -187,16 +172,6 @@ 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>
<CreateConnectorModal
@@ -205,7 +180,6 @@ export function ConnectorsSection({
onCreateConnector={handleCreateConnector}
surveys={initialSurveys}
workspaceId={workspaceId}
directories={directories}
showTrigger={false}
/>
@@ -71,7 +71,7 @@ export function ConnectorsTableDataRow({
}
return t("workspace.unify.status_live_sync");
case "paused":
return t("workspace.unify.status_paused");
return t("common.disabled");
case "error":
return t("workspace.unify.status_error");
}
@@ -27,7 +27,7 @@ export function ConnectorsTable({
const { t } = useTranslation();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">{t("common.type")}</div>
<div className="col-span-5">{t("common.name")}</div>
@@ -7,7 +7,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TConnectorType, UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
import {
getResponseCountAction,
@@ -34,6 +33,7 @@ import {
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
@@ -42,14 +42,23 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { Switch } from "@/modules/ui/components/switch";
import { TCreateConnectorStep, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
import {
TCreateConnectorStep,
TFieldMapping,
TFormbricksConnectorForm,
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
} from "../types";
import {
TConnectorOptionId,
TEnumValidationError,
areAllRequiredFieldsMapped,
isConnectorNameValid,
parseCSVColumnsToFields,
toggleQuestionId,
validateEnumMappings,
} from "../utils";
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
import { ConnectorTypeSelector } from "./connector-type-selector";
import { CsvConnectorUI } from "./csv-connector-ui";
import { FormbricksQuestionList } from "./formbricks-question-list";
@@ -61,13 +70,11 @@ interface CreateConnectorModalProps {
onCreateConnector: (data: {
name: string;
type: TConnectorType;
feedbackRecordDirectoryId: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<string | undefined>;
surveys: TUnifySurvey[];
workspaceId: string;
directories: { id: string; name: string }[];
}
const getDialogTitle = (
@@ -100,15 +107,6 @@ const getNextStepButtonLabel = (type: TConnectorOptionId | null, t: (key: string
return t("workspace.unify.create_mapping");
};
const ZFormbricksConnectorForm = z.object({
sourceName: z.string().trim().min(1),
surveyId: z.string().min(1),
selectedQuestionIds: z.array(z.string()).min(1),
importHistorical: z.boolean(),
});
type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
const getSelectableQuestionIds = (survey: TUnifySurvey): string[] =>
survey.elements
.filter((element) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(element.type))
@@ -121,7 +119,6 @@ export const CreateConnectorModal = ({
onCreateConnector,
surveys,
workspaceId,
directories,
}: CreateConnectorModalProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -155,7 +152,6 @@ export const CreateConnectorModal = ({
const [responseCountBySurvey, setResponseCountBySurvey] = useState<Record<string, number | null>>({});
const [isImporting, setIsImporting] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories[0]?.id ?? null);
const formbricksValues = formbricksForm.watch();
const selectedSurveyId = formbricksValues.surveyId;
@@ -226,7 +222,6 @@ export const CreateConnectorModal = ({
setCsvConnectorName("");
setIsImporting(false);
setIsCreating(false);
setSelectedDirectoryId(directories[0]?.id ?? null);
};
const handleOpenChange = (newOpen: boolean) => {
@@ -321,11 +316,7 @@ 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];
const nextSelection = toggleQuestionId(formbricksForm.getValues("selectedQuestionIds"), questionId);
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
shouldDirty: true,
shouldValidate: true,
@@ -333,13 +324,11 @@ export const CreateConnectorModal = ({
};
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 }],
});
@@ -353,7 +342,7 @@ export const CreateConnectorModal = ({
};
const handleCreateCsvConnector = async () => {
if (!selectedDirectoryId || !isConnectorNameValid(csvConnectorName)) return;
if (!isConnectorNameValid(csvConnectorName)) return;
if (csvParsedData.length > 0) {
const errors = validateEnumMappings(mappings, csvParsedData);
if (errors.length > 0) {
@@ -368,7 +357,6 @@ export const CreateConnectorModal = ({
const connectorId = await onCreateConnector({
name: csvConnectorName.trim(),
type: "csv",
feedbackRecordDirectoryId: selectedDirectoryId,
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
@@ -444,10 +432,6 @@ export const CreateConnectorModal = ({
)}
/>
{directories.length === 0 && (
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
)}
<FormField
control={formbricksForm.control}
name="surveyId"
@@ -519,9 +503,7 @@ export const CreateConnectorModal = ({
{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}
@@ -530,10 +512,6 @@ export const CreateConnectorModal = ({
/>
</div>
{directories.length === 0 && (
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
)}
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
<CsvConnectorUI
sourceFields={sourceFields}
@@ -601,7 +579,6 @@ export const CreateConnectorModal = ({
disabled={
isCreating ||
isImporting ||
!selectedDirectoryId ||
(selectedType === "formbricks_survey"
? !isConnectorNameValid(formbricksValues.sourceName ?? "") ||
!formbricksValues.surveyId ||
@@ -618,23 +595,3 @@ export const CreateConnectorModal = ({
</>
);
};
interface NoFeedbackRecordDirectoryAlertProps {
workspaceId: string;
t: (key: string) => string;
}
const NoFeedbackRecordDirectoryAlert = ({ workspaceId, t }: NoFeedbackRecordDirectoryAlertProps) => {
return (
<Alert variant="error" size="small">
<div>
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
<a
className="mt-1 inline-block font-medium underline"
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.unify.go_to_feedback_record_directories")}
</a>
</div>
</Alert>
);
};
@@ -4,7 +4,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { Button } from "@/modules/ui/components/button";
import {
@@ -24,6 +23,7 @@ import {
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
@@ -31,10 +31,21 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
import { parseCSVColumnsToFields } from "../utils";
import {
SAMPLE_CSV_COLUMNS,
TFieldMapping,
TFormbricksConnectorForm,
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
} from "../types";
import {
areAllRequiredFieldsMapped,
isConnectorNameValid,
parseCSVColumnsToFields,
toggleQuestionId,
} from "../utils";
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
import { FormbricksQuestionList } from "./formbricks-question-list";
import { MappingUI } from "./mapping-ui";
@@ -53,15 +64,6 @@ interface EditConnectorModalProps {
onOpenCsvImport?: () => void;
}
const ZFormbricksEditConnectorForm = z.object({
sourceName: z.string().trim().min(1),
surveyId: z.string().min(1),
selectedQuestionIds: z.array(z.string()).min(1),
importHistorical: z.boolean(),
});
type TFormbricksEditConnectorForm = z.infer<typeof ZFormbricksEditConnectorForm>;
export const EditConnectorModal = ({
connector,
open,
@@ -76,8 +78,8 @@ export const EditConnectorModal = ({
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [isUpdating, setIsUpdating] = useState(false);
const formbricksForm = useForm<TFormbricksEditConnectorForm>({
resolver: zodResolver(ZFormbricksEditConnectorForm),
const formbricksForm = useForm<TFormbricksConnectorForm>({
resolver: zodResolver(ZFormbricksConnectorForm),
defaultValues: {
sourceName: "",
surveyId: "",
@@ -169,7 +171,7 @@ export const EditConnectorModal = ({
onOpenChange(newOpen);
};
const handleUpdateFormbricksConnector = async (values: TFormbricksEditConnectorForm) => {
const handleUpdateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
if (connector?.type !== "formbricks_survey") return;
setIsUpdating(true);
await onUpdateConnector({
@@ -198,11 +200,7 @@ export const EditConnectorModal = ({
};
const handleFormbricksQuestionToggle = (questionId: string) => {
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
const isSelected = currentSelection.includes(questionId);
const nextSelection = isSelected
? currentSelection.filter((id) => id !== questionId)
: [...currentSelection, questionId];
const nextSelection = toggleQuestionId(formbricksForm.getValues("selectedQuestionIds"), questionId);
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
shouldDirty: true,
shouldValidate: true,
@@ -324,9 +322,7 @@ export const EditConnectorModal = ({
</div>
<div className="space-y-2">
<label htmlFor="editConnectorName" className="text-sm font-medium text-slate-700">
{t("workspace.unify.source_name")}
</label>
<Label htmlFor="editConnectorName">{t("workspace.unify.source_name")}</Label>
<Input
id="editConnectorName"
value={csvConnectorName}
@@ -80,6 +80,14 @@ export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
required: false,
description: "Tenant/organization identifier for multi-tenant deployments",
},
{
id: "submission_id",
name: "Submission ID",
type: "string",
required: false,
description:
"Optional. Map to a stable column (e.g. order_id, ticket_id) to enable idempotent re-imports. Auto-generated UUID per row if unmapped.",
},
{
id: "source_id",
name: "Source ID",
@@ -210,3 +218,12 @@ export const createFeedbackCSVDataSchema = (t: TFunction) =>
export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSchema>>;
export type TCreateConnectorStep = "selectType" | "mapping";
export const ZFormbricksConnectorForm = z.object({
sourceName: z.string().trim().min(1),
surveyId: z.string().min(1),
selectedQuestionIds: z.array(z.string()).min(1),
importHistorical: z.boolean(),
});
export type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
@@ -1,6 +1,13 @@
import { describe, expect, test } from "vitest";
import { MAX_CSV_VALUES, TSourceField } from "./types";
import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } from "./utils";
import { MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
import {
areAllRequiredFieldsMapped,
getConnectorOptions,
isConnectorNameValid,
parseCSVColumnsToFields,
toggleQuestionId,
validateCsvFile,
} from "./utils";
const mockT = (key: string) => key;
@@ -115,3 +122,111 @@ describe("validateCsvFile", () => {
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_files_only" });
});
});
describe("isConnectorNameValid", () => {
test("returns true for non-empty name", () => {
expect(isConnectorNameValid("My Connector")).toBe(true);
});
test("returns false for empty string", () => {
expect(isConnectorNameValid("")).toBe(false);
});
test("returns false for whitespace-only string", () => {
expect(isConnectorNameValid(" ")).toBe(false);
expect(isConnectorNameValid("\t\n ")).toBe(false);
});
test("returns true for name with surrounding whitespace", () => {
expect(isConnectorNameValid(" name ")).toBe(true);
});
test("returns true for single character", () => {
expect(isConnectorNameValid("a")).toBe(true);
});
});
describe("areAllRequiredFieldsMapped", () => {
const requiredMappings: TFieldMapping[] = [
{ targetFieldId: "collected_at", sourceFieldId: "ts" },
{ targetFieldId: "source_type", staticValue: "csv" },
{ targetFieldId: "field_id", sourceFieldId: "qid" },
{ targetFieldId: "field_type", staticValue: "text" },
];
test("returns true when all required fields have a sourceFieldId or staticValue", () => {
expect(areAllRequiredFieldsMapped(requiredMappings)).toBe(true);
});
test("returns false when a required field is missing entirely", () => {
const missing = requiredMappings.slice(0, 3);
expect(areAllRequiredFieldsMapped(missing)).toBe(false);
});
test("returns false when a required mapping has neither sourceFieldId nor staticValue", () => {
const incomplete: TFieldMapping[] = [...requiredMappings.slice(0, 3), { targetFieldId: "field_type" }];
expect(areAllRequiredFieldsMapped(incomplete)).toBe(false);
});
test("ignores mappings for non-required target fields", () => {
const withOptionals: TFieldMapping[] = [
...requiredMappings,
{ targetFieldId: "tenant_id", sourceFieldId: "tenant" },
{ targetFieldId: "unknown_field", sourceFieldId: "anything" },
];
expect(areAllRequiredFieldsMapped(withOptionals)).toBe(true);
});
test("returns false for empty mappings array", () => {
expect(areAllRequiredFieldsMapped([])).toBe(false);
});
test("treats empty staticValue and missing sourceFieldId as unmapped", () => {
const incomplete: TFieldMapping[] = [
{ targetFieldId: "collected_at", sourceFieldId: "ts" },
{ targetFieldId: "source_type", sourceFieldId: "", staticValue: "" },
{ targetFieldId: "field_id", sourceFieldId: "qid" },
{ targetFieldId: "field_type", staticValue: "text" },
];
expect(areAllRequiredFieldsMapped(incomplete)).toBe(false);
});
test("counts required field as mapped when only staticValue is set", () => {
const onlyStatic: TFieldMapping[] = [
{ targetFieldId: "collected_at", staticValue: "2026-01-01" },
{ targetFieldId: "source_type", staticValue: "csv" },
{ targetFieldId: "field_id", staticValue: "id" },
{ targetFieldId: "field_type", staticValue: "text" },
];
expect(areAllRequiredFieldsMapped(onlyStatic)).toBe(true);
});
});
describe("toggleQuestionId", () => {
test("adds id when not present", () => {
expect(toggleQuestionId(["a", "b"], "c")).toEqual(["a", "b", "c"]);
});
test("removes id when present", () => {
expect(toggleQuestionId(["a", "b", "c"], "b")).toEqual(["a", "c"]);
});
test("adds to empty selection", () => {
expect(toggleQuestionId([], "x")).toEqual(["x"]);
});
test("returns empty when removing the only id", () => {
expect(toggleQuestionId(["only"], "only")).toEqual([]);
});
test("does not mutate the input array", () => {
const input = ["a", "b"];
const result = toggleQuestionId(input, "c");
expect(input).toEqual(["a", "b"]);
expect(result).not.toBe(input);
});
test("removes only the matching id when duplicates exist", () => {
expect(toggleQuestionId(["a", "b", "a"], "a")).toEqual(["b"]);
});
});
@@ -90,6 +90,32 @@ export const validateEnumMappings = (
return errors;
};
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
const requiredFieldIds = new Set(
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
);
for (const mapping of mappings) {
if (!requiredFieldIds.has(mapping.targetFieldId)) {
continue;
}
if (mapping.sourceFieldId || mapping.staticValue) {
requiredFieldIds.delete(mapping.targetFieldId);
}
}
return requiredFieldIds.size === 0;
};
export const toggleQuestionId = (currentSelection: string[], questionId: string): string[] => {
return currentSelection.includes(questionId)
? currentSelection.filter((id) => id !== questionId)
: [...currentSelection, questionId];
};
export const validateCsvFile = (
file: File,
t: TFunction
@@ -1,318 +0,0 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { v7 as uuidv7 } from "uuid";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { handleConnectorPipeline } from "@/lib/connector/pipeline-handler";
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganization } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
import { sendResponseFinishedEmail } from "@/modules/email";
import { handleIntegrations } from "@/modules/response-pipeline/lib/handle-integrations";
import { captureSurveyResponsePostHogEvent } from "@/modules/response-pipeline/lib/posthog";
import { sendTelemetryEvents } from "@/modules/response-pipeline/lib/telemetry";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
export const POST = async (request: Request) => {
const requestHeaders = await headers();
// Check authentication
if (requestHeaders.get("x-api-key") !== CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
const jsonInput = await request.json();
const convertedJsonInput = convertDatesInObject(
jsonInput,
new Set(["contactAttributes", "variables", "data", "meta"])
);
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
if (!inputValidation.success) {
logger.error(
{ error: inputValidation.error, url: request.url },
"Error in POST /api/(internal)/pipeline"
);
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { workspaceId, surveyId, event, response } = inputValidation.data;
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", "Organization not found");
}
// Fetch survey for webhook payload
const survey = await getSurvey(surveyId);
if (!survey) {
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
return responses.notFoundResponse("Survey", surveyId, true);
}
if (survey.workspaceId !== workspaceId) {
logger.error(
{ url: request.url, surveyId, workspaceId, surveyWorkspaceId: survey.workspaceId },
`Survey ${surveyId} does not belong to workspace ${workspaceId}`
);
return responses.badRequestResponse("Survey not found in this workspace");
}
// Fetch webhooks
const getWebhooksForPipeline = async (workspaceId: string, event: PipelineTriggers, surveyId: string) => {
const webhooks = await prisma.webhook.findMany({
where: {
workspaceId,
triggers: { has: event },
OR: [{ surveyIds: { has: surveyId } }, { surveyIds: { isEmpty: true } }],
},
});
return webhooks;
};
const webhooks: Webhook[] = await getWebhooksForPipeline(workspaceId, event, surveyId);
// Prepare webhook and email promises
// Fetch with timeout of 5 seconds to prevent hanging
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
return Promise.race([
fetch(url, options),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
data: resolvedResponseData,
survey: {
title: survey.name,
type: survey.type,
status: survey.status,
createdAt: survey.createdAt,
updatedAt: survey.updatedAt,
},
},
});
// Generate Standard Webhooks headers
const webhookMessageId = uuidv7();
const webhookTimestamp = Math.floor(Date.now() / 1000);
const requestHeaders: Record<string, string> = {
"content-type": "application/json",
"webhook-id": webhookMessageId,
"webhook-timestamp": webhookTimestamp.toString(),
};
// Add signature if webhook has a secret configured
if (webhook.secret) {
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
webhookMessageId,
webhookTimestamp,
body,
webhook.secret
);
}
return validateWebhookUrl(webhook.url)
.then(() =>
fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
})
)
.catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
});
if (event === "responseFinished") {
// Handle connector pipeline for Hub integration (only on responseFinished to avoid duplicates)
// This sends response data to the Hub for configured connectors
try {
await handleConnectorPipeline(response, survey, workspaceId);
} catch (error) {
// Log but don't throw - connector failures shouldn't break the main pipeline
logger.error({ error, surveyId, responseId: response.id }, "Connector pipeline failed");
}
// Fetch integrations and responseCount in parallel
const [integrations, responseCount] = await Promise.all([
getIntegrations(workspaceId),
getResponseCountBySurveyId(surveyId),
]);
if (integrations.length > 0) {
await handleIntegrations(integrations, inputValidation.data, survey);
}
// Fetch users with notifications in a single query
const usersWithNotifications = await prisma.user.findMany({
where: {
memberships: {
some: {
organization: {
workspaces: {
some: {
id: workspaceId,
},
},
},
},
},
OR: [
{
memberships: {
every: {
role: {
in: ["owner", "manager"],
},
},
},
},
{
teamUsers: {
some: {
team: {
workspaceTeams: {
some: {
workspace: {
id: workspaceId,
},
},
},
},
},
},
},
],
notificationSettings: {
path: ["alert", surveyId],
equals: true,
},
},
select: { email: true, locale: true },
});
if (survey.followUps?.length > 0) {
// send follow up emails
const followUpsResult = await sendFollowUpsForResponse(response.id);
if (!followUpsResult.ok) {
const { error: followUpsError } = followUpsResult;
if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) {
logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`);
}
}
}
const emailPromises = usersWithNotifications.map((user) =>
sendResponseFinishedEmail(user.email, user.locale, workspaceId, survey, response, responseCount).catch(
(error) => {
logger.error(
{ error, url: request.url, userEmail: user.email },
`Failed to send email to ${user.email}`
);
}
)
);
// Update survey status if necessary
if (survey.autoComplete && responseCount >= survey.autoComplete) {
let logStatus: TAuditStatus = "success";
try {
await updateSurvey({
...survey,
status: "completed",
});
} catch (error) {
logStatus = "failure";
logger.error(
{ error, url: request.url, surveyId },
`Failed to update survey ${surveyId} status to completed`
);
} finally {
await queueAuditEvent({
status: logStatus,
action: "updated",
targetType: "survey",
userId: UNKNOWN_DATA,
userType: "system",
targetId: survey.id,
organizationId: organization.id,
newObject: {
status: "completed",
},
});
}
}
// Await webhook and email promises with allSettled to prevent early rejection
const results = await Promise.allSettled([...webhookPromises, ...emailPromises]);
results.forEach((result) => {
if (result.status === "rejected") {
logger.error({ error: result.reason, url: request.url }, "Promise rejected");
}
});
} else {
// Await webhook promises if no emails are sent (with allSettled to prevent early rejection)
const results = await Promise.allSettled(webhookPromises);
results.forEach((result) => {
if (result.status === "rejected") {
logger.error({ error: result.reason, url: request.url }, "Promise rejected");
}
});
}
if (event === "responseCreated") {
recordResponseCreatedMeterEvent({
stripeCustomerId: organization.billing.stripeCustomerId,
responseId: response.id,
createdAt: response.createdAt,
}).catch((error) => {
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
});
if (POSTHOG_KEY) {
const responseCount = await getResponseCountBySurveyId(surveyId);
captureSurveyResponsePostHogEvent({
organizationId: organization.id,
surveyId,
surveyType: survey.type,
workspaceId,
responseCount,
});
}
// Send telemetry events
await sendTelemetryEvents();
}
return Response.json({ data: {} });
};
@@ -1,12 +0,0 @@
import { z } from "zod";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
import { ZResponse } from "@formbricks/types/responses";
export const ZPipelineInput = z.object({
event: ZWebhook.shape.triggers.element,
response: ZResponse,
workspaceId: z.string(),
surveyId: z.string(),
});
export type TPipelineInput = z.infer<typeof ZPipelineInput>;
@@ -254,7 +254,7 @@ export const putResponseHandler = async ({
const { quotaFull, ...responseData } = updatedResponse;
sendToPipeline({
await sendToPipeline({
event: "responseUpdated",
workspaceId: survey.workspaceId,
surveyId: survey.id,
@@ -262,7 +262,7 @@ export const putResponseHandler = async ({
});
if (updatedResponse.finished) {
sendToPipeline({
await sendToPipeline({
event: "responseFinished",
workspaceId: survey.workspaceId,
surveyId: survey.id,
@@ -186,7 +186,7 @@ export const POST = withV1ApiWrapper({
const { quotaFull, ...responseData } = response;
sendToPipeline({
await sendToPipeline({
event: "responseCreated",
workspaceId,
surveyId: responseData.surveyId,
@@ -194,7 +194,7 @@ export const POST = withV1ApiWrapper({
});
if (responseInput.finished) {
sendToPipeline({
await sendToPipeline({
event: "responseFinished",
workspaceId,
surveyId: responseData.surveyId,
@@ -169,7 +169,7 @@ export const PUT = withV1ApiWrapper({
auditLog.newObject = updated;
}
sendToPipeline({
await sendToPipeline({
event: "responseUpdated",
workspaceId: result.survey.workspaceId,
surveyId: result.survey.id,
@@ -177,7 +177,7 @@ export const PUT = withV1ApiWrapper({
});
if (updated.finished) {
sendToPipeline({
await sendToPipeline({
event: "responseFinished",
workspaceId: result.survey.workspaceId,
surveyId: result.survey.id,
@@ -165,7 +165,7 @@ export const POST = withV1ApiWrapper({
auditLog.newObject = response;
}
sendToPipeline({
await sendToPipeline({
event: "responseCreated",
workspaceId: surveyResult.survey.workspaceId,
surveyId: response.surveyId,
@@ -173,7 +173,7 @@ export const POST = withV1ApiWrapper({
});
if (response.finished) {
sendToPipeline({
await sendToPipeline({
event: "responseFinished",
workspaceId: surveyResult.survey.workspaceId,
surveyId: response.surveyId,
@@ -237,7 +237,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
}
const { quotaFull, ...responseData } = createdResponse;
sendToPipeline({
await sendToPipeline({
event: "responseCreated",
workspaceId,
surveyId: responseData.surveyId,
@@ -245,7 +245,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
});
if (responseData.finished) {
sendToPipeline({
await sendToPipeline({
event: "responseFinished",
workspaceId,
surveyId: responseData.surveyId,
+58 -87
View File
@@ -1,113 +1,84 @@
import { PipelineTriggers } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TResponsePipelineJobData, getBackgroundJobProducer } from "@formbricks/jobs";
import { logger } from "@formbricks/logger";
import { TResponse } from "@formbricks/types/responses";
import { TPipelineInput } from "@/app/lib/types/pipelines";
import { sendToPipeline } from "./pipelines";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getJobsQueueingConfig } from "@/lib/jobs/config";
// Mock the constants module
vi.mock("@/lib/constants", () => ({
CRON_SECRET: "mocked-cron-secret",
WEBAPP_URL: "https://test.formbricks.com",
const mockEnqueueResponsePipeline = vi.fn();
vi.mock("@formbricks/jobs", () => ({
getBackgroundJobProducer: vi.fn(() => ({
enqueueResponsePipeline: mockEnqueueResponsePipeline,
})),
}));
vi.mock("@/lib/jobs/config", () => ({
getJobsQueueingConfig: vi.fn(),
}));
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("sendToPipeline", () => {
const testData: TResponsePipelineJobData = {
event: PipelineTriggers.responseCreated,
surveyId: "cm8ckvchx000008lb710n0gdn",
workspaceId: "cm8cmp9hp000008jf7l570ml2",
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
};
describe("pipelines", () => {
// Reset mocks before each test
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getJobsQueueingConfig).mockReturnValue({
enabled: true,
redisUrl: "redis://localhost:6379",
});
});
// Clean up after each test
afterEach(() => {
vi.clearAllMocks();
});
test("sendToPipeline should call fetch with correct parameters", async () => {
// Mock the fetch implementation to return a successful response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
test("enqueues the pipeline job through the BullMQ producer", async () => {
mockEnqueueResponsePipeline.mockResolvedValue({
jobId: "job-1",
jobName: "response-pipeline.process",
queueName: "background-jobs",
});
// Create sample data for testing
const testData: TPipelineInput = {
event: PipelineTriggers.responseCreated,
surveyId: "cm8ckvchx000008lb710n0gdn",
workspaceId: "cm8cnq2hp000008jf7l570abc",
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
};
// Call the function with test data
await sendToPipeline(testData);
// Check that fetch was called with the correct arguments
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith("https://test.formbricks.com/api/pipeline", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": "mocked-cron-secret",
},
body: JSON.stringify({
workspaceId: testData.workspaceId,
surveyId: testData.surveyId,
expect(getBackgroundJobProducer).toHaveBeenCalledTimes(1);
expect(mockEnqueueResponsePipeline).toHaveBeenCalledWith(testData);
});
test("logs enqueue failures and rethrows", async () => {
const testError = new Error("Redis unavailable");
mockEnqueueResponsePipeline.mockRejectedValue(testError);
await expect(sendToPipeline(testData)).rejects.toThrow(testError);
expect(logger.error).toHaveBeenCalledWith(
{
error: testError,
event: testData.event,
response: testData.response,
}),
surveyId: testData.surveyId,
workspaceId: testData.workspaceId,
},
"Error queueing pipeline event"
);
});
test("throws when BullMQ queueing is disabled", async () => {
vi.mocked(getJobsQueueingConfig).mockReturnValue({
enabled: false,
redisUrl: null,
});
});
test("sendToPipeline should handle fetch errors", async () => {
// Mock fetch to throw an error
const testError = new Error("Network error");
mockFetch.mockRejectedValueOnce(testError);
// Create sample data for testing
const testData: TPipelineInput = {
event: PipelineTriggers.responseCreated,
surveyId: "cm8ckvchx000008lb710n0gdn",
workspaceId: "cm8cnq2hp000008jf7l570abc",
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
};
// Call the function
await sendToPipeline(testData);
// Check that the error was logged using logger
expect(logger.error).toHaveBeenCalledWith(testError, "Error sending event to pipeline");
});
test("sendToPipeline should throw error if CRON_SECRET is not set", async () => {
// For this test, we need to mock CRON_SECRET as undefined
// Let's use a more compatible approach to reset the mocks
const originalModule = await import("@/lib/constants");
const mockConstants = { ...originalModule, CRON_SECRET: undefined };
vi.doMock("@/lib/constants", () => mockConstants);
// Re-import the module to get the new mocked values
const { sendToPipeline: sendToPipelineNoSecret } = await import("./pipelines");
// Create sample data for testing
const testData: TPipelineInput = {
event: PipelineTriggers.responseCreated,
surveyId: "cm8ckvchx000008lb710n0gdn",
workspaceId: "cm8cnq2hp000008jf7l570abc",
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
};
// Expect the function to throw an error
await expect(sendToPipelineNoSecret(testData)).rejects.toThrow("CRON_SECRET is not set");
await expect(sendToPipeline(testData)).rejects.toThrow(
"BullMQ response pipeline queueing is not enabled"
);
expect(getBackgroundJobProducer).not.toHaveBeenCalled();
});
});
+17 -21
View File
@@ -1,25 +1,21 @@
import { TResponsePipelineJobData, getBackgroundJobProducer } from "@formbricks/jobs";
import { logger } from "@formbricks/logger";
import { TPipelineInput } from "@/app/lib/types/pipelines";
import { CRON_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getJobsQueueingConfig } from "@/lib/jobs/config";
export const sendToPipeline = async ({ event, surveyId, workspaceId, response }: TPipelineInput) => {
if (!CRON_SECRET) {
throw new Error("CRON_SECRET is not set");
export const sendToPipeline = async (job: TResponsePipelineJobData): Promise<void> => {
try {
const jobsQueueingConfig = getJobsQueueingConfig();
if (!jobsQueueingConfig.enabled) {
throw new Error("BullMQ response pipeline queueing is not enabled");
}
const producer = getBackgroundJobProducer();
await producer.enqueueResponsePipeline(job);
} catch (error) {
logger.error(
{ error, event: job.event, surveyId: job.surveyId, workspaceId: job.workspaceId },
"Error queueing pipeline event"
);
throw error;
}
return fetch(`${WEBAPP_URL}/api/pipeline`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": CRON_SECRET,
},
body: JSON.stringify({
workspaceId,
surveyId,
event,
response,
}),
}).catch((error) => {
logger.error(error, "Error sending event to pipeline");
});
};
-9
View File
@@ -1,9 +0,0 @@
import { PipelineTriggers } from "@prisma/client";
import { TResponse } from "@formbricks/types/responses";
export interface TPipelineInput {
event: PipelineTriggers;
response: TResponse;
workspaceId: string;
surveyId: string;
}
+3
View File
@@ -21,6 +21,9 @@ export const PostHogIdentify = ({ posthogKey, userId, email, name }: PostHogIden
defaults: "2026-01-30",
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
session_recording: {
blockSelector: "#chatwoot_live_chat_widget",
},
});
}
+15 -1
View File
@@ -121,6 +121,7 @@ checksums:
common/apply_filters: 6543c1e80038b3da0f4a42848d08d4d1
common/archived: cf5127ecfd7e43a35466a1ba5fe16450
common/are_you_sure: 6d5cd13628a7887711fd0c29f1123652
common/ask: 24150ae04c60dcd8688d93a8a3a2d238
common/attributes: 86d0ae6fea0fbb119722ed3841f8385a
common/back: f541015a827e37cb3b1234e56bc2aa3c
common/billing: b01dbdd049ebbd4a349fa64d6ce65a3b
@@ -186,6 +187,7 @@ checksums:
common/delete_what: 718ddfcc1dec7f3e8b67856fba838267
common/description: e17686a22ffad04cc7bb70524ed4478b
common/disable: 81b754fd7962e0bd9b6ba87f3972e7fc
common/disabled: 0889a3dfd914a7ef638611796b17bf72
common/disallow: 01c8ed3ce545ed836d3ccffc562c8a0c
common/discard: de83a114a79d086e372c43dbfe9f47b4
common/dismissed: f0e21b3fe28726c577a7238a63cc29c2
@@ -1659,6 +1661,7 @@ checksums:
workspace/analysis/charts/failed_to_execute_query: d1153133aa4cd3d1cd02e39942413168
workspace/analysis/charts/failed_to_load_chart: abea098fbf8e728f95414d3ae8bb63a4
workspace/analysis/charts/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
workspace/analysis/charts/failed_to_load_dashboards: 876c54d9cc69ceda6f808231e2557eb2
workspace/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
workspace/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
workspace/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
@@ -1796,6 +1799,9 @@ checksums:
workspace/app-connection/app_connection_description: dde226414bd2265cbd0daf6635efcfdd
workspace/app-connection/cache_update_delay_description: 3368e4a8090b7684117a16c94f0c409c
workspace/app-connection/cache_update_delay_title: 60e4a0fcfbd8850bddf29b5c3f59550c
workspace/app-connection/environment_id_legacy: d5c701874d34b4591e780755f7ac7a58
workspace/app-connection/environment_id_legacy_alert: 09ac96821ff99fad4590c661503fa0cd
workspace/app-connection/environment_id_legacy_alert_link: 25c529078a115d1ff044a321dd8ee01b
workspace/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
workspace/app-connection/formbricks_sdk_not_connected: 557c534e665750978ba6edb0eacb428e
workspace/app-connection/formbricks_sdk_not_connected_description: 4ddbacae084238bd0cefeded0fe9dbb9
@@ -2448,6 +2454,7 @@ checksums:
workspace/settings/feedback_record_directories/error_directory_name_duplicate: 349d650f562cff96b084787126323ca2
workspace/settings/feedback_record_directories/error_directory_name_required: 0f42d7292979006a1069063ab213b8e3
workspace/settings/feedback_record_directories/error_directory_workspaces_invalid_org: 477b5c1a466c4194668544ffd42ec9bf
workspace/settings/feedback_record_directories/error_workspace_already_assigned: 6f851ad28a4e91e48fe13da917ea1ae0
workspace/settings/feedback_record_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
workspace/settings/feedback_record_directories/no_access: cc3385cd01a11e3949003a2cc6fb5b31
workspace/settings/feedback_record_directories/no_connectors: b1becb4fe4e2ba7c5d277db149f092ff
@@ -3589,9 +3596,16 @@ checksums:
workspace/unify/source_name: 157675beca12efcd8ec512c5256b1a61
workspace/unify/source_type: d1ff69af76c687eb189db72030717570
workspace/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
workspace/unify/source_type_label_feedback_form: 65e0f65a81cca1c9034943ee6a95c3f4
workspace/unify/source_type_label_interview: 4c58354a7ef4293327d14e9e97d6f694
workspace/unify/source_type_label_nps_campaign: 9f4638404242468f67cdb1a1fe656383
workspace/unify/source_type_label_review: 299f75db25382980b2895622d7712927
workspace/unify/source_type_label_social: ff80c74b36f0511287404d286ec7976e
workspace/unify/source_type_label_support: 55aab5fd0f31a9cb055a2edeeedfaf63
workspace/unify/source_type_label_survey: b659d270a53dada994d926e0cc6e9a54
workspace/unify/source_type_label_usability_test: 33a7b1e9ee8b975008c48e0a524f0e57
workspace/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
workspace/unify/status_live_sync: 7e794257419414f57d34845ef38d0939
workspace/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
workspace/unify/status_ready: 437c0eea608e15ad5cdab94bde2f4b48
workspace/unify/submission_id: 02edf76883b47079dbe20f3f36b7c1a7
workspace/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d
+7 -7
View File
@@ -39,12 +39,12 @@ vi.mock("@formbricks/logger", () => ({
vi.mock("@/lib/env", () => ({
env: {
AI_PROVIDER: "gcp",
AI_PROVIDER: "google",
AI_MODEL: "gemini-2.5-flash",
AI_GCP_PROJECT: "vertex-project",
AI_GCP_LOCATION: "us-central1",
AI_GCP_CREDENTIALS_JSON: undefined,
AI_GCP_APPLICATION_CREDENTIALS: "/tmp/vertex.json",
AI_GOOGLE_CLOUD_PROJECT: "google-cloud-project",
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: undefined,
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: "/tmp/google-cloud.json",
AI_AWS_REGION: "us-east-1",
AI_AWS_ACCESS_KEY_ID: "aws-access-key-id",
AI_AWS_SECRET_ACCESS_KEY: "aws-secret-access-key",
@@ -144,9 +144,9 @@ describe("AI organization service", () => {
prompt: "Translate this survey",
},
expect.objectContaining({
AI_PROVIDER: "gcp",
AI_PROVIDER: "google",
AI_MODEL: "gemini-2.5-flash",
AI_GCP_PROJECT: "vertex-project",
AI_GOOGLE_CLOUD_PROJECT: "google-cloud-project",
})
);
});
+5 -40
View File
@@ -1,7 +1,6 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import {
@@ -24,7 +23,6 @@ import {
getOrganizationIdFromSurveyId,
getOrganizationIdFromWorkspaceId,
} from "@/lib/utils/helper";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { listFeedbackRecords } from "@/modules/hub/service";
import type { FeedbackRecordListParams, FeedbackRecordListResponse } from "@/modules/hub/types";
import { importCsvData } from "./csv-import";
@@ -125,23 +123,15 @@ const ZFormbricksSurveyMapping = z.object({
elementIds: z.array(z.string()).min(1),
});
// Temporary compatibility to support legacy client payloads using `formbricks`.
const ZConnectorCreateInputWithLegacyType = ZConnectorCreateInput.extend({
type: z.enum(["formbricks_survey", "csv", "formbricks"]),
});
const ZCreateConnectorWithMappingsAction = z
.object({
workspaceId: ZId,
connectorInput: ZConnectorCreateInputWithLegacyType,
connectorInput: ZConnectorCreateInput,
formbricksMappings: z.array(ZFormbricksSurveyMapping).optional(),
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
})
.superRefine((data, ctx) => {
const normalizedType =
data.connectorInput.type === "formbricks" ? "formbricks_survey" : data.connectorInput.type;
if (normalizedType === "formbricks_survey") {
if (data.connectorInput.type === "formbricks_survey") {
if (!data.formbricksMappings?.length) {
ctx.addIssue({
code: "custom",
@@ -149,7 +139,7 @@ const ZCreateConnectorWithMappingsAction = z
message: "At least one survey mapping is required for Formbricks connectors",
});
}
} else if (normalizedType === "csv") {
} else if (data.connectorInput.type === "csv") {
if (!data.fieldMappings?.length) {
ctx.addIssue({
code: "custom",
@@ -163,14 +153,6 @@ const ZCreateConnectorWithMappingsAction = z
export const createConnectorWithMappingsAction = authenticatedActionClient
.inputSchema(ZCreateConnectorWithMappingsAction)
.action(async ({ ctx, parsedInput }): Promise<TConnectorWithMappings> => {
const connectorInput = ZConnectorCreateInput.parse({
...parsedInput.connectorInput,
type:
parsedInput.connectorInput.type === "formbricks"
? "formbricks_survey"
: parsedInput.connectorInput.type,
});
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -188,15 +170,6 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
],
});
// Verify FRD belongs to same org
const frd = await prisma.feedbackRecordDirectory.findUnique({
where: { id: connectorInput.feedbackRecordDirectoryId },
select: { organizationId: true },
});
if (frd?.organizationId !== organizationId) {
throw new AuthorizationError("Invalid feedback record directory");
}
let mappingsInput: TMappingsInput | undefined;
const { formbricksMappings, fieldMappings } = parsedInput;
@@ -218,7 +191,7 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
return createConnectorWithMappings(
parsedInput.workspaceId,
{ ...connectorInput, createdBy: ctx.user.id },
{ ...parsedInput.connectorInput, createdBy: ctx.user.id },
mappingsInput
);
});
@@ -349,7 +322,6 @@ export const duplicateConnectorAction = authenticatedActionClient
{
name: `${source.name} (copy)`,
type: source.type,
feedbackRecordDirectoryId: source.feedbackRecordDirectoryId,
createdBy: ctx.user.id,
},
mappingsInput
@@ -492,7 +464,6 @@ 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(),
@@ -530,14 +501,8 @@ export const listFeedbackRecordsAction = authenticatedActionClient
],
});
// Verify FRD belongs to workspace's accessible FRDs
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(parsedInput.workspaceId);
if (!frds.some((f) => f.id === parsedInput.frdId)) {
throw new Error("Feedback record directory not accessible");
}
const params: FeedbackRecordListParams = {
tenant_id: parsedInput.frdId,
tenant_id: parsedInput.workspaceId,
limit: parsedInput.limit ?? 50,
};
if (parsedInput.cursor) params.cursor = parsedInput.cursor;
+1 -1
View File
@@ -22,7 +22,7 @@ export const importCsvData = async (
const { records, skipped } = transformCsvRowsToFeedbackRecords(
csvRows,
connector.fieldMappings,
connector.feedbackRecordDirectoryId
connector.workspaceId
);
let successes = 0;
+86 -15
View File
@@ -3,6 +3,7 @@ import { TConnectorFieldMapping } from "@formbricks/types/connector";
import { transformCsvRowToFeedbackRecord, transformCsvRowsToFeedbackRecords } from "./csv-transform";
const NOW = new Date("2026-02-25T10:00:00.000Z");
const TENANT = "tenant-test";
const makeMapping = (
sourceFieldId: string,
@@ -34,7 +35,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
timestamp: "2026-01-15T10:00:00Z",
};
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
expect(result).not.toBeNull();
expect(result!.source_type).toBe("survey");
@@ -42,13 +43,77 @@ describe("transformCsvRowToFeedbackRecord", () => {
expect(result!.field_type).toBe("text");
expect(result!.value_text).toBe("Great product!");
expect(result!.collected_at).toBe("2026-01-15T10:00:00.000Z");
expect(result!.tenant_id).toBe(TENANT);
});
test("returns null when required fields are missing", () => {
const row = { feedback_text: "Great product!" };
const mappings = [makeMapping("feedback_text", "value_text")];
const result = transformCsvRowToFeedbackRecord(row, mappings);
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
expect(result).toBeNull();
});
test("returns null when tenant_id is missing", () => {
const row = {
feedback_text: "Great product!",
question: "q1",
timestamp: "2026-01-15T10:00:00Z",
};
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
expect(result).toBeNull();
});
test("auto-generates submission_id as a UUID when unmapped", () => {
const row = {
feedback_text: "Great product!",
question: "q1",
timestamp: "2026-01-15T10:00:00Z",
};
const a = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
const b = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
expect(a!.submission_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
expect(b!.submission_id).not.toBe(a!.submission_id);
});
test("uses explicit submission_id mapping when provided", () => {
const mappings = [...baseMappings, makeMapping("order_id", "submission_id")];
const row = {
feedback_text: "x",
question: "q1",
timestamp: "2026-01-15",
order_id: "ORD-42",
};
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
expect(result!.submission_id).toBe("ORD-42");
});
test("returns null when submission_id mapped but cell is empty", () => {
const mappings = [...baseMappings, makeMapping("order_id", "submission_id")];
const row = {
feedback_text: "x",
question: "q1",
timestamp: "2026-01-15",
order_id: "",
};
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
expect(result).toBeNull();
});
test("returns null when submission_id mapped but column missing from row", () => {
const mappings = [...baseMappings, makeMapping("order_id", "submission_id")];
const row = {
feedback_text: "x",
question: "q1",
timestamp: "2026-01-15",
};
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
expect(result).toBeNull();
});
@@ -61,7 +126,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
rating: "4.5",
};
const result = transformCsvRowToFeedbackRecord(row, mappings);
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
expect(result!.value_number).toBe(4.5);
});
@@ -74,7 +139,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
rating: "not-a-number",
};
const result = transformCsvRowToFeedbackRecord(row, mappings);
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
expect(result!.value_number).toBeUndefined();
});
@@ -84,21 +149,24 @@ describe("transformCsvRowToFeedbackRecord", () => {
expect(
transformCsvRowToFeedbackRecord(
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "true" },
mappings
mappings,
TENANT
)!.value_boolean
).toBe(true);
expect(
transformCsvRowToFeedbackRecord(
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "0" },
mappings
mappings,
TENANT
)!.value_boolean
).toBe(false);
expect(
transformCsvRowToFeedbackRecord(
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "yes" },
mappings
mappings,
TENANT
)!.value_boolean
).toBe(true);
});
@@ -114,7 +182,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
makeMapping("", "collected_at", "$now"),
];
const result = transformCsvRowToFeedbackRecord({ question: "q1" }, mappings);
const result = transformCsvRowToFeedbackRecord({ question: "q1" }, mappings, TENANT);
expect(result!.collected_at).toBe(NOW.toISOString());
vi.useRealTimers();
@@ -129,7 +197,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
];
const row = { question: "q1", type_column: "review", timestamp: "2026-01-15" };
const result = transformCsvRowToFeedbackRecord(row, mappings);
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
expect(result!.source_type).toBe("always_survey");
});
@@ -140,7 +208,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
timestamp: "2026-01-15T10:00:00Z",
};
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
expect(result!.value_text).toBeUndefined();
});
@@ -153,7 +221,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
meta: '{"device":"mobile","version":"2.1"}',
};
const result = transformCsvRowToFeedbackRecord(row, mappings);
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
expect(result!.metadata).toEqual({ device: "mobile", version: "2.1" });
});
@@ -166,7 +234,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
meta: "just a string",
};
const result = transformCsvRowToFeedbackRecord(row, mappings);
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
expect(result!.metadata).toEqual({ raw: "just a string" });
});
@@ -177,7 +245,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
timestamp: "not-a-date",
};
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
expect(result!.collected_at).toBeUndefined();
});
});
@@ -198,16 +266,19 @@ describe("transformCsvRowsToFeedbackRecords", () => {
makeMapping("timestamp", "collected_at"),
];
const { records, skipped } = transformCsvRowsToFeedbackRecords(rows, mappings);
const { records, skipped } = transformCsvRowsToFeedbackRecords(rows, mappings, TENANT);
expect(records).toHaveLength(2);
expect(skipped).toBe(1);
expect(records[0].field_id).toBe("q1");
expect(records[1].field_id).toBe("q2");
expect(records[0].submission_id).toBeTruthy();
expect(records[1].submission_id).toBeTruthy();
expect(records[0].submission_id).not.toBe(records[1].submission_id);
});
test("returns empty records for empty input", () => {
const { records, skipped } = transformCsvRowsToFeedbackRecords([], baseMappings);
const { records, skipped } = transformCsvRowsToFeedbackRecords([], baseMappings, TENANT);
expect(records).toHaveLength(0);
expect(skipped).toBe(0);
});
+17 -2
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { TConnectorFieldMapping, THubTargetField } from "@formbricks/types/connector";
import { FeedbackRecordCreateParams } from "@/modules/hub";
@@ -50,8 +51,10 @@ const resolveValue = (
/**
* Transform a single CSV row into a FeedbackRecord using field mappings.
*
* Each mapping maps a CSV column (sourceFieldId) or a static value to a target field.
* Returns null if required fields (source_type, field_id, field_type) are missing after mapping.
* Returns null if any of source_type, field_id, field_type, tenant_id are missing,
* or if submission_id is mapped but resolves empty for this row (would break
* idempotency on re-import). Falls back to a random UUID for submission_id only
* when no mapping for it exists.
*/
export const transformCsvRowToFeedbackRecord = (
row: Record<string, string>,
@@ -83,6 +86,18 @@ export const transformCsvRowToFeedbackRecord = (
record.tenant_id = tenantId;
}
if (!record.tenant_id) {
return null;
}
if (!("submission_id" in record)) {
const submissionMapped = mappings.some((m) => m.targetFieldId === "submission_id");
if (submissionMapped) {
return null;
}
record.submission_id = randomUUID();
}
return record as unknown as FeedbackRecordCreateParams;
};
+1 -6
View File
@@ -50,12 +50,7 @@ export const importHistoricalResponses = async (
const responses = await getResponses(survey.id, IMPORT_BATCH_SIZE, offset);
if (responses.length === 0) break;
const batch = await processBatch(
responses,
survey,
connector.formbricksMappings,
connector.feedbackRecordDirectoryId
);
const batch = await processBatch(responses, survey, connector.formbricksMappings, connector.workspaceId);
successes += batch.successes;
failures += batch.failures;
skipped += batch.skipped;
@@ -56,7 +56,6 @@ function createConnector(
type: "formbricks_survey",
status: "active",
workspaceId: "env-1",
feedbackRecordDirectoryId: "frd-1",
lastSyncAt: null,
formbricksMappings: [
{
@@ -120,7 +119,7 @@ describe("handleConnectorPipeline", () => {
mockResponse,
mockSurvey,
connector.formbricksMappings,
"frd-1"
"env-1"
);
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
expect(updateConnector).not.toHaveBeenCalled();
+3 -3
View File
@@ -34,14 +34,14 @@ const logFailedRecords = (
const processConnector = async (
connector: TConnectorWithMappings,
response: TResponse,
survey: TSurvey,
survey: Pick<TSurvey, "id" | "name" | "blocks">,
workspaceId: string
): Promise<void> => {
const feedbackRecords = transformResponseToFeedbackRecords(
response,
survey,
connector.formbricksMappings,
connector.feedbackRecordDirectoryId
connector.workspaceId
);
if (feedbackRecords.length === 0) {
@@ -94,7 +94,7 @@ const processConnector = async (
*/
export const handleConnectorPipeline = async (
response: TResponse,
survey: TSurvey,
survey: Pick<TSurvey, "id" | "name" | "blocks">,
workspaceId: string
): Promise<void> => {
try {
+5 -9
View File
@@ -39,7 +39,6 @@ vi.mock("@/lib/utils/validate", () => ({
const ENV_ID = "clxxxxxxxxxxxxxxxx001";
const CONNECTOR_ID = "clxxxxxxxxxxxxxxxx002";
const SURVEY_ID = "clxxxxxxxxxxxxxxxx003";
const FRD_ID = "clxxxxxxxxxxxxxxxx004";
const NOW = new Date("2026-02-24T10:00:00.000Z");
const mockConnector = {
@@ -304,7 +303,6 @@ describe("createConnectorWithMappings", () => {
const result = await createConnectorWithMappings(ENV_ID, {
name: "New",
type: "formbricks_survey",
feedbackRecordDirectoryId: FRD_ID,
});
expect(tx.connector.create).toHaveBeenCalledWith(
@@ -313,7 +311,6 @@ describe("createConnectorWithMappings", () => {
name: "New",
type: "formbricks_survey",
workspaceId: ENV_ID,
feedbackRecordDirectoryId: FRD_ID,
},
})
);
@@ -330,7 +327,7 @@ describe("createConnectorWithMappings", () => {
await createConnectorWithMappings(
ENV_ID,
{ name: "FB", type: "formbricks_survey", feedbackRecordDirectoryId: FRD_ID },
{ name: "FB", type: "formbricks_survey" },
{
type: "formbricks_survey",
mappings: [
@@ -366,7 +363,7 @@ describe("createConnectorWithMappings", () => {
await createConnectorWithMappings(
ENV_ID,
{ name: "CSV", type: "csv", feedbackRecordDirectoryId: FRD_ID },
{ name: "CSV", type: "csv" },
{
type: "field",
mappings: [{ sourceFieldId: "col-1", targetFieldId: "value_text" }],
@@ -398,7 +395,6 @@ describe("createConnectorWithMappings", () => {
createConnectorWithMappings(ENV_ID, {
name: "Dup",
type: "formbricks_survey",
feedbackRecordDirectoryId: FRD_ID,
})
).rejects.toThrow(InvalidInputError);
});
@@ -411,9 +407,9 @@ describe("createConnectorWithMappings", () => {
})
);
await expect(
createConnectorWithMappings(ENV_ID, { name: "Fail", type: "csv", feedbackRecordDirectoryId: FRD_ID })
).rejects.toThrow(DatabaseError);
await expect(createConnectorWithMappings(ENV_ID, { name: "Fail", type: "csv" })).rejects.toThrow(
DatabaseError
);
});
});
-3
View File
@@ -26,7 +26,6 @@ const selectConnectorWithMappings = {
type: true,
status: true,
workspaceId: true,
feedbackRecordDirectoryId: true,
lastSyncAt: true,
createdBy: true,
creator: { select: { name: true } },
@@ -63,7 +62,6 @@ const selectConnector = {
type: true,
status: true,
workspaceId: true,
feedbackRecordDirectoryId: true,
lastSyncAt: true,
createdBy: true,
} satisfies Prisma.ConnectorSelect;
@@ -238,7 +236,6 @@ export const createConnectorWithMappings = async (
name: data.name,
type: data.type,
workspaceId,
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
createdBy: data.createdBy,
},
});
+1 -1
View File
@@ -96,7 +96,7 @@ const convertValueToHubFields = (
*/
export function transformResponseToFeedbackRecords(
response: TResponse,
survey: TSurvey,
survey: Pick<TSurvey, "id" | "name" | "blocks">,
mappings: TConnectorFormbricksMapping[],
tenantId: string
): FeedbackRecordCreateParams[] {
+47 -28
View File
@@ -6,10 +6,10 @@ const ZActiveAIProvider = z.enum(AI_PROVIDERS);
const ZAIConfigurationEnv = z.object({
AI_PROVIDER: ZActiveAIProvider.optional(),
AI_MODEL: z.string().optional(),
AI_GCP_PROJECT: z.string().optional(),
AI_GCP_LOCATION: z.string().optional(),
AI_GCP_CREDENTIALS_JSON: z.string().optional(),
AI_GCP_APPLICATION_CREDENTIALS: z.string().optional(),
AI_GOOGLE_CLOUD_PROJECT: z.string().optional(),
AI_GOOGLE_CLOUD_LOCATION: z.string().optional(),
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: z.string().optional(),
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: z.string().optional(),
AI_AWS_REGION: z.string().optional(),
AI_AWS_ACCESS_KEY_ID: z.string().optional(),
AI_AWS_SECRET_ACCESS_KEY: z.string().optional(),
@@ -20,6 +20,9 @@ const ZAIConfigurationEnv = z.object({
type TAIConfigurationEnv = z.infer<typeof ZAIConfigurationEnv>;
const isJsonObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const addEnvIssue = (ctx: z.RefinementCtx, path: keyof TAIConfigurationEnv, message: string): void => {
ctx.addIssue({
code: "custom",
@@ -48,28 +51,44 @@ const validateAwsAIConfiguration = (values: TAIConfigurationEnv, ctx: z.Refineme
}
};
const validateGcpAIConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
if (!values.AI_GCP_PROJECT) {
addEnvIssue(ctx, "AI_GCP_PROJECT", "AI_GCP_PROJECT is required when AI_PROVIDER=gcp");
}
if (!values.AI_GCP_LOCATION) {
addEnvIssue(ctx, "AI_GCP_LOCATION", "AI_GCP_LOCATION is required when AI_PROVIDER=gcp");
}
if (!values.AI_GCP_CREDENTIALS_JSON && !values.AI_GCP_APPLICATION_CREDENTIALS) {
const validateGoogleAIConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
if (!values.AI_GOOGLE_CLOUD_PROJECT) {
addEnvIssue(
ctx,
"AI_GCP_CREDENTIALS_JSON",
"AI_GCP_CREDENTIALS_JSON or AI_GCP_APPLICATION_CREDENTIALS is required when AI_PROVIDER=gcp"
"AI_GOOGLE_CLOUD_PROJECT",
"AI_GOOGLE_CLOUD_PROJECT is required when AI_PROVIDER=google"
);
}
if (values.AI_GCP_CREDENTIALS_JSON) {
if (!values.AI_GOOGLE_CLOUD_LOCATION) {
addEnvIssue(
ctx,
"AI_GOOGLE_CLOUD_LOCATION",
"AI_GOOGLE_CLOUD_LOCATION is required when AI_PROVIDER=google"
);
}
if (!values.AI_GOOGLE_CLOUD_CREDENTIALS_JSON && !values.AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS) {
addEnvIssue(
ctx,
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON",
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON or AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS is required when AI_PROVIDER=google"
);
}
if (values.AI_GOOGLE_CLOUD_CREDENTIALS_JSON) {
try {
JSON.parse(values.AI_GCP_CREDENTIALS_JSON);
const parsedCredentials = JSON.parse(values.AI_GOOGLE_CLOUD_CREDENTIALS_JSON) as unknown;
if (!isJsonObject(parsedCredentials)) {
throw new Error("AI_GOOGLE_CLOUD_CREDENTIALS_JSON must be a JSON object");
}
} catch {
addEnvIssue(ctx, "AI_GCP_CREDENTIALS_JSON", "AI_GCP_CREDENTIALS_JSON must be valid JSON");
addEnvIssue(
ctx,
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON",
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON must be a valid JSON object"
);
}
}
};
@@ -100,7 +119,7 @@ const validateActiveAIProviderConfiguration = (values: TAIConfigurationEnv, ctx:
(values: TAIConfigurationEnv, ctx: z.RefinementCtx) => void
> = {
aws: validateAwsAIConfiguration,
gcp: validateGcpAIConfiguration,
google: validateGoogleAIConfiguration,
azure: validateAzureAIConfiguration,
};
@@ -160,10 +179,10 @@ const parsedEnv = createEnv({
GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
AI_GCP_PROJECT: z.string().optional(),
AI_GCP_LOCATION: z.string().optional(),
AI_GCP_CREDENTIALS_JSON: z.string().optional(),
AI_GCP_APPLICATION_CREDENTIALS: z.string().optional(),
AI_GOOGLE_CLOUD_PROJECT: z.string().optional(),
AI_GOOGLE_CLOUD_LOCATION: z.string().optional(),
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: z.string().optional(),
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: z.string().optional(),
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
@@ -315,10 +334,10 @@ const parsedEnv = createEnv({
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
AI_GCP_PROJECT: process.env.AI_GCP_PROJECT,
AI_GCP_LOCATION: process.env.AI_GCP_LOCATION,
AI_GCP_CREDENTIALS_JSON: process.env.AI_GCP_CREDENTIALS_JSON,
AI_GCP_APPLICATION_CREDENTIALS: process.env.AI_GCP_APPLICATION_CREDENTIALS,
AI_GOOGLE_CLOUD_PROJECT: process.env.AI_GOOGLE_CLOUD_PROJECT,
AI_GOOGLE_CLOUD_LOCATION: process.env.AI_GOOGLE_CLOUD_LOCATION,
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: process.env.AI_GOOGLE_CLOUD_CREDENTIALS_JSON,
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: process.env.AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS,
GOOGLE_SHEETS_CLIENT_ID: process.env.GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET: process.env.GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
+3 -1
View File
@@ -6,6 +6,8 @@ import { parseRecallInfo } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
export type TElementResponseMappingSurvey = Pick<TSurvey, "blocks" | "languages">;
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
export const convertResponseValue = (
answer: TResponseDataValue,
@@ -34,7 +36,7 @@ export const convertResponseValue = (
};
export const getElementResponseMapping = (
survey: TSurvey,
survey: TElementResponseMappingSurvey,
response: TResponse
): { element: string; response: string | string[]; type: TSurveyElementTypeEnum }[] => {
const elementResponseMapping: {
+6 -47
View File
@@ -4,8 +4,8 @@ import { isLight, mixColor } from "@/lib/utils/colors";
export const COLOR_DEFAULTS = {
brandColor: "#64748b",
questionColor: "#2b2524",
inputColor: "#ffffff",
elementHeadlineColor: "#2b2524",
inputBgColor: "#ffffff",
inputBorderColor: "#cbd5e1",
cardBackgroundColor: "#ffffff",
cardBorderColor: "#f8fafc",
@@ -40,10 +40,8 @@ export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) =>
return {
// General
"brandColor.light": brandColor,
"questionColor.light": questionColor,
// Headlines & Descriptions — use questionColor to match the legacy behaviour
// where all text elements derived their color from questionColor.
// Headlines & Descriptions
"elementHeadlineColor.light": questionColor,
"elementDescriptionColor.light": questionColor,
"elementUpperLabelColor.light": questionColor,
@@ -53,7 +51,7 @@ export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) =>
"buttonTextColor.light": isLight(brandColor) ? "#0f172a" : "#ffffff",
// Inputs
"inputColor.light": inputBg,
"inputBgColor.light": inputBg,
"inputBorderColor.light": inputBorder,
"inputTextColor.light": questionColor,
@@ -94,8 +92,6 @@ const _colors = getSuggestedColors(DEFAULT_BRAND_COLOR);
export const STYLE_DEFAULTS: TWorkspaceStyling = {
allowStyleOverwrite: true,
brandColor: { light: _colors["brandColor.light"] },
questionColor: { light: _colors["questionColor.light"] },
inputColor: { light: _colors["inputColor.light"] },
inputBorderColor: { light: _colors["inputBorderColor.light"] },
cardBackgroundColor: { light: _colors["cardBackgroundColor.light"] },
cardBorderColor: { light: _colors["cardBorderColor.light"] },
@@ -117,6 +113,7 @@ export const STYLE_DEFAULTS: TWorkspaceStyling = {
elementUpperLabelFontWeight: 400,
// Inputs
inputBgColor: { light: _colors["inputBgColor.light"] },
inputTextColor: { light: _colors["inputTextColor.light"] },
inputBorderRadius: 8,
inputHeight: 20,
@@ -151,43 +148,6 @@ export const STYLE_DEFAULTS: TWorkspaceStyling = {
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
};
/**
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
*
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
*
* When loading v4.6 data the new fields are absent. Without this helper the
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
* colour), causing a visible mismatch. This function derives the new fields
* from the actually-saved legacy fields so the preview and form stay coherent.
*
* Only sets a field when the legacy source exists AND the new field is absent.
*/
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
const light = (key: string): string | undefined =>
(saved[key] as { light?: string } | null | undefined)?.light;
const q = light("questionColor");
const b = light("brandColor");
const i = light("inputColor");
const inputBorder = light("inputBorderColor");
return {
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
...(inputBorder && !saved.optionBorderColor && { optionBorderColor: { light: inputBorder } }),
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
...(b && !saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
};
};
/**
* Builds a complete TWorkspaceStyling object from a single brand color.
*
@@ -203,13 +163,12 @@ export const buildStylingFromBrandColor = (brandColor: string = DEFAULT_BRAND_CO
return {
...STYLE_DEFAULTS,
brandColor: { light: colors["brandColor.light"] },
questionColor: { light: colors["questionColor.light"] },
elementHeadlineColor: { light: colors["elementHeadlineColor.light"] },
elementDescriptionColor: { light: colors["elementDescriptionColor.light"] },
elementUpperLabelColor: { light: colors["elementUpperLabelColor.light"] },
buttonBgColor: { light: colors["buttonBgColor.light"] },
buttonTextColor: { light: colors["buttonTextColor.light"] },
inputColor: { light: colors["inputColor.light"] },
inputBgColor: { light: colors["inputBgColor.light"] },
inputBorderColor: { light: colors["inputBorderColor.light"] },
inputTextColor: { light: colors["inputTextColor.light"] },
optionBgColor: { light: colors["optionBgColor.light"] },
@@ -25,7 +25,6 @@ export type AuditLoggingCtx = {
chartId?: string;
dashboardId?: string;
dashboardWidgetId?: string;
feedbackRecordDirectoryId?: string;
};
export type ActionClientCtx = {
+8 -8
View File
@@ -36,13 +36,13 @@ describe("Template Utilities", () => {
} as unknown as TSurveyElement;
const workspace = {
name: "TestProject",
name: "TestWorkspace",
} as unknown as TWorkspace;
const result = replaceElementPresetPlaceholders(element, workspace);
// The function directly replaces without calling getLocalizedValue in the test scenario
expect(result.headline?.default).toBe("How do you like TestProject?");
expect(result.headline?.default).toBe("How do you like TestWorkspace?");
});
test("replaces workspaceName placeholder in subheader", () => {
@@ -53,13 +53,13 @@ describe("Template Utilities", () => {
} as unknown as TSurveyElement;
const workspace = {
name: "TestProject",
name: "TestWorkspace",
} as unknown as TWorkspace;
const result = replaceElementPresetPlaceholders(element, workspace);
expect(result.headline?.default).toBe("Question");
expect(result.subheader?.default).toBe("Subheader for TestProject");
expect(result.subheader?.default).toBe("Subheader for TestWorkspace");
});
test("handles missing headline and subheader", () => {
@@ -68,7 +68,7 @@ describe("Template Utilities", () => {
} as unknown as TSurveyElement;
const workspace = {
name: "TestProject",
name: "TestWorkspace",
} as unknown as TWorkspace;
const result = replaceElementPresetPlaceholders(element, workspace);
@@ -106,14 +106,14 @@ describe("Template Utilities", () => {
} as unknown as TTemplate;
const workspace = {
name: "TestProject",
name: "TestWorkspace",
} as TWorkspace;
const result = replacePresetPlaceholders(mockTemplate, workspace);
expect(structuredClone).toHaveBeenCalledWith(mockTemplate.preset);
expect(result.preset.name).toBe("TestProject Feedback");
expect(result.preset.blocks[0].elements[0].headline?.default).toBe("How would you rate TestProject?");
expect(result.preset.name).toBe("TestWorkspace Feedback");
expect(result.preset.blocks[0].elements[0].headline?.default).toBe("How would you rate TestWorkspace?");
});
});
});
+1
View File
@@ -12,6 +12,7 @@ const selectWorkspace = {
id: true,
createdAt: true,
updatedAt: true,
legacyEnvironmentId: true,
name: true,
organizationId: true,
languages: true,
+23 -9
View File
@@ -148,6 +148,7 @@
"apply_filters": "Filter anwenden",
"archived": "Archiviert",
"are_you_sure": "Bist du sicher?",
"ask": "Ask",
"attributes": "Attribute",
"back": "Zurück",
"billing": "Abrechnung",
@@ -163,7 +164,7 @@
"choice_n": "Auswahl {n}",
"choices": "Entscheidungen",
"choose_organization": "Organisation auswählen",
"choose_workspace": "Projekt auswählen",
"choose_workspace": "Workspace auswählen",
"clear_all": "Alles löschen",
"clear_filters": "Filter löschen",
"clear_selection": "Auswahl aufheben",
@@ -213,6 +214,7 @@
"delete_what": "{deleteWhat} löschen",
"description": "Beschreibung",
"disable": "Deaktivieren",
"disabled": "Deaktiviert",
"disallow": "Nicht erlauben",
"discard": "Verwerfen",
"dismissed": "Verworfen",
@@ -242,7 +244,7 @@
"expand_rows": "Zeilen erweitern",
"failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage",
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
"failed_to_load_workspaces": "Workspaces konnten nicht geladen werden",
"failed_to_parse_csv": "CSV-Analyse fehlgeschlagen",
"field_placeholder": "Platzhalter für {field}",
"filter": "Filter",
@@ -505,13 +507,13 @@
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workspace": "Arbeitsbereich",
"workspace_configuration": "Projektkonfiguration",
"workspace_created_successfully": "Projekt erfolgreich erstellt",
"workspace_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"workspace_id": "Projekt-ID",
"workspace_name": "Projektname",
"workspace_configuration": "Workspace-Konfiguration",
"workspace_created_successfully": "Workspace erfolgreich erstellt",
"workspace_creation_description": "Organisiere Umfragen in Workspaces für eine bessere Zugriffskontrolle.",
"workspace_id": "Workspace-ID",
"workspace_name": "Workspace-Name",
"workspace_name_placeholder": "z. B. Formbricks",
"workspaces": "Projekte",
"workspaces": "Workspaces",
"years": "Jahre",
"yes": "Ja",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
@@ -1724,6 +1726,7 @@
"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_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Diagramm konnte nicht gespeichert werden",
"field": "Feld",
"field_label_average_score": "Durchschnittliche Bewertung",
@@ -1868,6 +1871,9 @@
"app_connection_description": "Verbinde deine App oder Website mit Formbricks.",
"cache_update_delay_description": "Wenn du Aktualisierungen an Umfragen, Kontakten, Aktionen oder anderen Daten vornimmst, kann es bis zu 1 Minute dauern, bis diese Änderungen in deiner lokalen App mit dem Formbricks SDK sichtbar werden.",
"cache_update_delay_title": "Änderungen werden nach ~1 Minute durch Caching übernommen",
"environment_id_legacy": "Umgebungs-ID (veraltet)",
"environment_id_legacy_alert": "Deine bestehende SDK-Konfiguration verwendet möglicherweise noch eine veraltete Umgebungs-ID.",
"environment_id_legacy_alert_link": "Erfahre, warum und wie du migrieren kannst.",
"formbricks_sdk_connected": "Formbricks SDK ist verbunden",
"formbricks_sdk_not_connected": "Formbricks SDK ist noch nicht verbunden.",
"formbricks_sdk_not_connected_description": "Füge das Formbricks SDK zu deiner Website oder App hinzu, um es mit Formbricks zu verbinden",
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "Ein Feedback-Datensatz-Verzeichnis mit diesem Namen existiert bereits.",
"error_directory_name_required": "Verzeichnisname ist erforderlich.",
"error_directory_workspaces_invalid_org": "Einige der angegebenen Workspaces gehören nicht zu dieser Organisation.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "Feedback-Verzeichnisse",
"no_access": "Du hast keine Berechtigung, Feedback-Datensatz-Verzeichnisse zu verwalten.",
"no_connectors": "Noch keine Connectoren mit diesem Verzeichnis verknüpft.",
@@ -3749,9 +3756,16 @@
"source_name": "Quellenname",
"source_type": "Quellentyp",
"source_type_cannot_be_changed": "Quellentyp kann nicht geändert werden",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "Fehler",
"status_live_sync": "Live-Synchronisierung",
"status_paused": "Pausiert",
"status_ready": "Bereit",
"submission_id": "Einreichungs-ID",
"survey_has_no_questions": "Diese Umfrage hat keine Fragen",
+15 -1
View File
@@ -148,6 +148,7 @@
"apply_filters": "Apply filters",
"archived": "Archived",
"are_you_sure": "Are you sure?",
"ask": "Ask",
"attributes": "Attributes",
"back": "Back",
"billing": "Billing",
@@ -213,6 +214,7 @@
"delete_what": "Delete {deleteWhat}",
"description": "Description",
"disable": "Disable",
"disabled": "Disabled",
"disallow": "Do not allow",
"discard": "Discard",
"dismissed": "Dismissed",
@@ -1724,6 +1726,7 @@
"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_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Failed to save chart",
"field": "Field",
"field_label_average_score": "Average Score",
@@ -1868,6 +1871,9 @@
"app_connection_description": "Connect your app or website to Formbricks.",
"cache_update_delay_description": "When you make updates to surveys, contacts, actions, or other data, it can take up to 1 minute for those changes to appear in your local app running the Formbricks SDK.",
"cache_update_delay_title": "Changes will be reflected after ~1 minute due to caching",
"environment_id_legacy": "Environment ID (legacy)",
"environment_id_legacy_alert": "Your existing SDK setup may still use a legacy Environment ID.",
"environment_id_legacy_alert_link": "Learn why and how to migrate.",
"formbricks_sdk_connected": "Formbricks SDK is connected",
"formbricks_sdk_not_connected": "Formbricks SDK is not yet connected.",
"formbricks_sdk_not_connected_description": "Add the Formbricks SDK to your website or app to connect it with Formbricks",
@@ -2555,6 +2561,7 @@
"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.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"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.",
@@ -3749,9 +3756,16 @@
"source_name": "Source Name",
"source_type": "Source Type",
"source_type_cannot_be_changed": "Source type cannot be changed",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"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",
+51 -37
View File
@@ -131,9 +131,9 @@
"add_filter": "Añadir filtro",
"add_logo": "Añadir logotipo",
"add_member": "Añadir miembro",
"add_new_workspace": "Añadir proyecto nuevo",
"add_new_workspace": "Añadir nuevo espacio de trabajo",
"add_to_team": "Añadir al equipo",
"add_workspace": "Añadir proyecto",
"add_workspace": "Añadir espacio de trabajo",
"all": "Todos",
"all_questions": "Todas las preguntas",
"allow": "Permitir",
@@ -148,6 +148,7 @@
"apply_filters": "Aplicar filtros",
"archived": "Archivado",
"are_you_sure": "¿Estás seguro?",
"ask": "Ask",
"attributes": "Atributos",
"back": "Atrás",
"billing": "Facturación",
@@ -163,7 +164,7 @@
"choice_n": "Opción {n}",
"choices": "Opciones",
"choose_organization": "Elegir organización",
"choose_workspace": "Elegir proyecto",
"choose_workspace": "Elegir espacio de trabajo",
"clear_all": "Borrar todo",
"clear_filters": "Borrar filtros",
"clear_selection": "Borrar selección",
@@ -198,7 +199,7 @@
"create_new_organization": "Crear organización nueva",
"create_segment": "Crear segmento",
"create_survey": "Crear encuesta",
"create_workspace": "Crear proyecto",
"create_workspace": "Crear espacio de trabajo",
"created": "Creado",
"created_at": "Creado el",
"created_by": "Creado por",
@@ -213,6 +214,7 @@
"delete_what": "Eliminar {deleteWhat}",
"description": "Descripción",
"disable": "Desactivar",
"disabled": "Desactivado",
"disallow": "No permitir",
"discard": "Descartar",
"dismissed": "Descartado",
@@ -242,7 +244,7 @@
"expand_rows": "Expandir filas",
"failed_to_copy_to_clipboard": "Error al copiar al portapapeles",
"failed_to_load_organizations": "Error al cargar organizaciones",
"failed_to_load_workspaces": "Error al cargar los proyectos",
"failed_to_load_workspaces": "Error al cargar los espacios de trabajo",
"failed_to_parse_csv": "Error al analizar el CSV",
"field_placeholder": "Marcador de posición de {field}",
"filter": "Filtro",
@@ -478,7 +480,7 @@
"type": "Tipo",
"unify": "Unificar",
"unknown_survey": "Encuesta desconocida",
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más proyectos con un plan superior.",
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más espacios de trabajo con un plan superior.",
"update": "Actualizar",
"updated": "Actualizado",
"updated_at": "Actualizado el",
@@ -505,13 +507,13 @@
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"workspace": "Espacio de trabajo",
"workspace_configuration": "Configuración del proyecto",
"workspace_created_successfully": "Proyecto creado correctamente",
"workspace_creation_description": "Organiza las encuestas en proyectos para un mejor control de acceso.",
"workspace_id": "ID del proyecto",
"workspace_name": "Nombre del proyecto",
"workspace_configuration": "Configuración del espacio de trabajo",
"workspace_created_successfully": "Espacio de trabajo creado correctamente",
"workspace_creation_description": "Organiza las encuestas en espacios de trabajo para un mejor control de acceso.",
"workspace_id": "ID del espacio de trabajo",
"workspace_name": "Nombre del espacio de trabajo",
"workspace_name_placeholder": "p. ej. Formbricks",
"workspaces": "Proyectos",
"workspaces": "Espacios de trabajo",
"years": "años",
"yes": "Sí",
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
@@ -1724,6 +1726,7 @@
"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_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Error al guardar el gráfico",
"field": "Campo",
"field_label_average_score": "Puntuación media",
@@ -1868,6 +1871,9 @@
"app_connection_description": "Conecta tu aplicación o sitio web a Formbricks.",
"cache_update_delay_description": "Cuando realizas actualizaciones en encuestas, contactos, acciones u otros datos, puede tardar hasta 1 minuto en que esos cambios aparezcan en tu aplicación local que ejecuta el SDK de Formbricks.",
"cache_update_delay_title": "Los cambios se reflejarán después de ~1 minuto debido al almacenamiento en caché",
"environment_id_legacy": "ID de entorno (heredado)",
"environment_id_legacy_alert": "Tu configuración actual del SDK puede seguir utilizando un ID de entorno heredado.",
"environment_id_legacy_alert_link": "Descubre por qué y cómo migrar.",
"formbricks_sdk_connected": "El SDK de Formbricks está conectado",
"formbricks_sdk_not_connected": "El SDK de Formbricks aún no está conectado.",
"formbricks_sdk_not_connected_description": "Añade el SDK de Formbricks a tu sitio web o aplicación para conectarlo con Formbricks",
@@ -1991,7 +1997,7 @@
},
"formbricks_logo": "Logo de Formbricks",
"general": {
"cannot_delete_only_workspace": "Este es tu único proyecto, no se puede eliminar. Crea primero un proyecto nuevo.",
"cannot_delete_only_workspace": "Este es tu único espacio de trabajo, no se puede eliminar. Crea primero un nuevo espacio de trabajo.",
"custom_scripts": "Scripts personalizados",
"custom_scripts_card_description": "Añade scripts de seguimiento y píxeles a todas las encuestas con enlace en este espacio de trabajo.",
"custom_scripts_description": "Los scripts se inyectarán en el <head> de todas las páginas de encuestas con enlace.",
@@ -1999,20 +2005,20 @@
"custom_scripts_placeholder": "<!-- Pega tus scripts de seguimiento aquí -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripts personalizados actualizados correctamente",
"custom_scripts_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
"delete_workspace": "Eliminar proyecto",
"delete_workspace": "Eliminar espacio de trabajo",
"delete_workspace_confirmation": "¿Estás seguro de que quieres eliminar {workspaceName}? Esta acción no se puede deshacer.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {workspaceName} incluyendo todas las encuestas, respuestas, personas, acciones y atributos.",
"delete_workspace_settings_description": "Eliminar proyecto con todas las encuestas, respuestas, personas, acciones y atributos. Esto no se puede deshacer.",
"error_saving_workspace_information": "Error al guardar la información del proyecto",
"only_owners_or_managers_can_delete_workspaces": "Solo los propietarios o administradores pueden eliminar proyectos",
"delete_workspace_settings_description": "Elimina el espacio de trabajo con todas las encuestas, respuestas, personas, acciones y atributos. Esta acción no se puede deshacer.",
"error_saving_workspace_information": "Error al guardar la información del espacio de trabajo",
"only_owners_or_managers_can_delete_workspaces": "Solo los propietarios o administradores pueden eliminar espacios de trabajo",
"recontact_waiting_time": "Periodo de espera (entre encuestas)",
"recontact_waiting_time_settings_description": "Controla con qué frecuencia se puede encuestar a los usuarios en todas las encuestas de sitio web y aplicación de este espacio de trabajo.",
"this_action_cannot_be_undone": "Esta acción no se puede deshacer.",
"wait_x_days_before_showing_next_survey": "Esperar X días antes de mostrar la siguiente encuesta:",
"waiting_period_updated_successfully": "Periodo de espera actualizado correctamente",
"whats_your_workspace_called": "¿Cómo se llama tu proyecto?",
"workspace_deleted_successfully": "Proyecto eliminado correctamente",
"workspace_name_settings_description": "Cambia el nombre de tu proyecto.",
"whats_your_workspace_called": "¿Cómo se llama tu espacio de trabajo?",
"workspace_deleted_successfully": "Espacio de trabajo eliminado correctamente",
"workspace_name_settings_description": "Cambia el nombre de tu espacio de trabajo.",
"workspace_name_updated_successfully": "Nombre del espacio de trabajo actualizado correctamente"
},
"integrations": {
@@ -2452,7 +2458,7 @@
"trial_payment_method_added_description": "¡Todo listo! Tu plan Pro continuará automáticamente cuando termine el periodo de prueba.",
"trial_title": "¡Consigue Formbricks Pro gratis!",
"unlimited_responses": "Respuestas ilimitadas",
"unlimited_workspaces": "Proyectos ilimitados",
"unlimited_workspaces": "Espacios de trabajo ilimitados",
"upgrade": "Actualizar",
"upgrade_now": "Actualizar ahora",
"usage_cycle": "Usage cycle",
@@ -2475,7 +2481,7 @@
"pretty_url": "URL bonita",
"survey_name": "Nombre de la encuesta",
"title": "URL bonitas",
"workspace": "Proyecto"
"workspace": "Espacio de trabajo"
},
"enterprise": {
"audit_logs": "Registros de auditoría",
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "Ya existe un directorio de registros de comentarios con este nombre.",
"error_directory_name_required": "El nombre del directorio es obligatorio.",
"error_directory_workspaces_invalid_org": "Algunos de los espacios de trabajo especificados no pertenecen a esta organización.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "Directorios de Feedback",
"no_access": "No tienes permiso para gestionar los directorios de registros de feedback.",
"no_connectors": "Aún no hay conectores vinculados a este directorio.",
@@ -2581,13 +2588,13 @@
"cannot_leave_only_organization": "No puedes abandonar esta organización ya que es tu única organización. Crea una nueva organización primero.",
"copy_invite_link_to_clipboard": "Copiar enlace de invitación al portapapeles",
"create_new_organization": "Crear nueva organización",
"create_new_organization_description": "Crea una nueva organización para gestionar un conjunto diferente de proyectos.",
"create_new_organization_description": "Crea una nueva organización para gestionar un conjunto diferente de espacios de trabajo.",
"customize_email_with_a_higher_plan": "Personaliza el correo electrónico con un plan superior",
"delete_member_confirmation": "Los miembros eliminados perderán acceso a todos los proyectos y encuestas de tu organización.",
"delete_member_confirmation": "Los miembros eliminados perderán el acceso a todos los espacios de trabajo y encuestas de tu organización.",
"delete_organization": "Eliminar organización",
"delete_organization_description": "Eliminar organización con todos sus proyectos incluyendo todas las encuestas, respuestas, personas, acciones y atributos",
"delete_organization_description": "Elimina la organización con todos sus espacios de trabajo, incluyendo todas las encuestas, respuestas, personas, acciones y atributos",
"delete_organization_warning": "Antes de proceder con la eliminación de esta organización, ten en cuenta las siguientes consecuencias:",
"delete_organization_warning_1": "Eliminación permanente de todos los proyectos vinculados a esta organización.",
"delete_organization_warning_1": "Eliminación permanente de todos los espacios de trabajo vinculados a esta organización.",
"delete_organization_warning_2": "Esta acción no se puede deshacer. Si desaparece, desaparece para siempre.",
"delete_organization_warning_3": "Por favor, introduce {organizationName} en el siguiente campo para confirmar la eliminación definitiva de esta organización:",
"eliminate_branding_with_whitelabel": "Elimina la marca Formbricks y habilita opciones adicionales de personalización de marca blanca.",
@@ -2682,10 +2689,10 @@
},
"teams": {
"add_members_description": "Añade miembros al equipo y determina su rol.",
"add_workspaces_description": "Controla a qué proyectos pueden acceder los miembros del equipo.",
"add_workspaces_description": "Controla a qué espacios de trabajo pueden acceder los miembros del equipo.",
"all_members_added": "Todos los miembros añadidos a este equipo.",
"all_workspaces_added": "Todos los proyectos añadidos a este equipo.",
"are_you_sure_you_want_to_delete_this_team": "¿Estás seguro de que quieres eliminar este equipo? Esto también elimina el acceso a todos los proyectos y encuestas asociados con este equipo.",
"all_workspaces_added": "Todos los espacios de trabajo añadidos a este equipo.",
"are_you_sure_you_want_to_delete_this_team": "¿Estás seguro de que quieres eliminar este equipo? Esto también elimina el acceso a todos los espacios de trabajo y encuestas asociadas con este equipo.",
"billing_role_description": "Solo tienen acceso a la información de facturación.",
"bulk_invite": "Invitación masiva",
"contributor": "Colaborador",
@@ -2701,14 +2708,14 @@
"manage": "Gestionar",
"manage_team": "Gestionar equipo",
"manage_team_disabled": "Solo los propietarios de la organización, gestores y administradores de equipo pueden gestionar equipos.",
"manager_role_description": "Los gestores pueden acceder a todos los proyectos y añadir y eliminar miembros.",
"manager_role_description": "Los gestores pueden acceder a todos los espacios de trabajo y añadir o eliminar miembros.",
"member": "Miembro",
"member_role_description": "Los miembros pueden trabajar en proyectos seleccionados.",
"member_role_info_message": "Para dar a los nuevos miembros acceso a un proyecto, por favor añádelos a un equipo a continuación. Con los equipos puedes gestionar quién tiene acceso a qué proyecto.",
"member_role_description": "Los miembros pueden trabajar en los espacios de trabajo seleccionados.",
"member_role_info_message": "Para dar acceso a nuevos miembros a un espacio de trabajo, añádelos a un equipo a continuación. Con los equipos puedes gestionar quién tiene acceso a qué espacio de trabajo.",
"organization_role": "Rol en la organización",
"owner_role_description": "Los propietarios tienen control total sobre la organización.",
"please_fill_all_member_fields": "Por favor, rellena todos los campos para añadir un nuevo miembro.",
"please_fill_all_workspace_fields": "Por favor, rellena todos los campos para añadir un proyecto nuevo.",
"please_fill_all_workspace_fields": "Por favor, rellena todos los campos para añadir un nuevo espacio de trabajo.",
"read": "Lectura",
"read_write": "Lectura y escritura",
"team_admin": "Administrador de equipo",
@@ -2721,8 +2728,8 @@
"team_settings_description": "Gestiona miembros del equipo, derechos de acceso y más.",
"team_updated_successfully": "Equipo actualizado correctamente",
"teams": "Equipos",
"teams_description": "Asigna miembros a equipos y da acceso a los equipos a proyectos.",
"unlock_teams_description": "Gestiona qué miembros de la organización tienen acceso a proyectos y encuestas específicos.",
"teams_description": "Asigna miembros a equipos y otorga a los equipos acceso a los espacios de trabajo.",
"unlock_teams_description": "Gestiona qué miembros de la organización tienen acceso a espacios de trabajo y encuestas específicos.",
"unlock_teams_title": "Desbloquea Equipos con un plan superior.",
"upgrade_plan_notice_message": "Desbloquea Roles de Organización con un plan superior.",
"you_are_a_member": "Eres miembro"
@@ -3068,7 +3075,7 @@
"options_used_in_logic_bulk_error": "Las siguientes opciones se utilizan en la lógica: {questionIndexes}. Por favor, elimínalas de la lógica primero.",
"override_theme_with_individual_styles_for_this_survey": "Anular el tema con estilos individuales para esta encuesta.",
"overwrite_global_waiting_time": "Establecer periodo de espera personalizado",
"overwrite_global_waiting_time_description": "Anular la configuración del proyecto solo para esta encuesta.",
"overwrite_global_waiting_time_description": "Anula la configuración del espacio de trabajo solo para esta encuesta.",
"overwrite_placement": "Sobrescribir ubicación",
"overwrite_survey_logo": "Establecer logotipo personalizado para la encuesta",
"overwrite_the_global_placement_of_the_survey": "Sobrescribir la ubicación global de la encuesta",
@@ -3749,9 +3756,16 @@
"source_name": "Nombre de origen",
"source_type": "Tipo de fuente",
"source_type_cannot_be_changed": "El tipo de origen no se puede cambiar",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "Error",
"status_live_sync": "Sincronización en vivo",
"status_paused": "Pausado",
"status_ready": "Listo",
"submission_id": "ID de envío",
"survey_has_no_questions": "Esta encuesta no tiene preguntas",
+44 -30
View File
@@ -131,9 +131,9 @@
"add_filter": "Ajouter un filtre",
"add_logo": "Ajouter un logo",
"add_member": "Ajouter un membre",
"add_new_workspace": "Ajouter un nouveau projet",
"add_new_workspace": "Ajouter un nouvel espace de travail",
"add_to_team": "Ajouter à l'équipe",
"add_workspace": "Ajouter un projet",
"add_workspace": "Ajouter un espace de travail",
"all": "Tout",
"all_questions": " toutes les questions",
"allow": "Autoriser",
@@ -148,6 +148,7 @@
"apply_filters": "Appliquer des filtres",
"archived": "Archivé",
"are_you_sure": "Es-tu sûr ?",
"ask": "Ask",
"attributes": "Attributs",
"back": "Retour",
"billing": "Facturation",
@@ -163,7 +164,7 @@
"choice_n": "Choix {n}",
"choices": "Choix",
"choose_organization": "Choisir l'organisation",
"choose_workspace": "Choisir un projet",
"choose_workspace": "Choisir un espace de travail",
"clear_all": "Tout effacer",
"clear_filters": "Effacer les filtres",
"clear_selection": "Effacer la sélection",
@@ -198,7 +199,7 @@
"create_new_organization": "Créer une nouvelle organisation",
"create_segment": "Créer un segment",
"create_survey": "Créer un sondage",
"create_workspace": "Créer un projet",
"create_workspace": "Créer un espace de travail",
"created": "Créé",
"created_at": "Créé le",
"created_by": "Créé par",
@@ -213,6 +214,7 @@
"delete_what": "Supprimer {deleteWhat}",
"description": "Description",
"disable": "Désactiver",
"disabled": "Désactivé",
"disallow": "Ne pas autoriser",
"discard": "Annuler",
"dismissed": "Rejeté",
@@ -242,7 +244,7 @@
"expand_rows": "Développer les lignes",
"failed_to_copy_to_clipboard": "Échec de la copie dans le presse-papiers",
"failed_to_load_organizations": "Échec du chargement des organisations",
"failed_to_load_workspaces": "Échec du chargement des projets",
"failed_to_load_workspaces": "Échec du chargement des espaces de travail",
"failed_to_parse_csv": "Échec de l'analyse du CSV",
"field_placeholder": "Espace réservé pour {field}",
"filter": "Filtre",
@@ -478,7 +480,7 @@
"type": "Type",
"unify": "Unifier",
"unknown_survey": "Enquête inconnue",
"unlock_more_workspaces_with_a_higher_plan": "Débloquez plus de projets avec un forfait supérieur.",
"unlock_more_workspaces_with_a_higher_plan": "Débloque plus d'espaces de travail avec un forfait supérieur.",
"update": "Mise à jour",
"updated": "Mise à jour",
"updated_at": "Mis à jour à",
@@ -505,13 +507,13 @@
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"workspace": "Espace de travail",
"workspace_configuration": "Configuration du projet",
"workspace_created_successfully": "Projet créé avec succès",
"workspace_creation_description": "Organisez les enquêtes dans des projets pour un meilleur contrôle d'accès.",
"workspace_id": "ID du projet",
"workspace_name": "Nom du projet",
"workspace_configuration": "Configuration de l'espace de travail",
"workspace_created_successfully": "Espace de travail créé avec succès",
"workspace_creation_description": "Organise tes enquêtes dans des espaces de travail pour un meilleur contrôle d'accès.",
"workspace_id": "ID de l'espace de travail",
"workspace_name": "Nom de l'espace de travail",
"workspace_name_placeholder": "par ex. Formbricks",
"workspaces": "Projets",
"workspaces": "Espaces de travail",
"years": "années",
"yes": "Oui",
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
@@ -1724,6 +1726,7 @@
"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_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Échec de l'enregistrement du graphique",
"field": "Champ",
"field_label_average_score": "Score moyen",
@@ -1868,6 +1871,9 @@
"app_connection_description": "Connectez votre application ou votre site web à Formbricks.",
"cache_update_delay_description": "Lorsque vous effectuez des mises à jour de sondages, de contacts, d'actions ou d'autres données, il peut falloir jusqu'à 1 minute pour que ces modifications apparaissent dans votre application locale exécutant le SDK Formbricks.",
"cache_update_delay_title": "Les modifications seront reflétées après environ 1 minute en raison de la mise en cache",
"environment_id_legacy": "ID d'environnement (ancien)",
"environment_id_legacy_alert": "Votre configuration SDK existante peut encore utiliser un ID d'environnement ancien.",
"environment_id_legacy_alert_link": "Découvrez pourquoi et comment migrer.",
"formbricks_sdk_connected": "Le SDK Formbricks est connecté",
"formbricks_sdk_not_connected": "Le SDK Formbricks n'est pas encore connecté.",
"formbricks_sdk_not_connected_description": "Ajoutez le SDK Formbricks à votre site web ou à votre application pour le connecter à Formbricks",
@@ -1991,7 +1997,7 @@
},
"formbricks_logo": "Logo Formbricks",
"general": {
"cannot_delete_only_workspace": "Il s'agit de votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.",
"cannot_delete_only_workspace": "C'est ton seul espace de travail, il ne peut pas être supprimé. Crée d'abord un nouvel espace de travail.",
"custom_scripts": "Scripts personnalisés",
"custom_scripts_card_description": "Ajouter des scripts de suivi et des pixels à toutes les enquêtes par lien dans cet espace de travail.",
"custom_scripts_description": "Les scripts seront injectés dans le <head> de toutes les pages d'enquête par lien.",
@@ -1999,21 +2005,21 @@
"custom_scripts_placeholder": "<!-- Collez vos scripts de suivi ici -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripts personnalisés mis à jour avec succès",
"custom_scripts_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
"delete_workspace": "Supprimer le projet",
"delete_workspace": "Supprimer l'espace de travail",
"delete_workspace_confirmation": "Es-tu sûr de vouloir supprimer {workspaceName} ? Cette action est irréversible.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Supprimer {workspaceName} y compris tous les sondages, réponses, personnes, actions et attributs.",
"delete_workspace_settings_description": "Supprimer le projet avec toutes les enquêtes, réponses, personnes, actions et attributs. Cette opération est irréversible.",
"error_saving_workspace_information": "Erreur lors de l'enregistrement des informations du projet",
"only_owners_or_managers_can_delete_workspaces": "Seuls les propriétaires ou les gestionnaires peuvent supprimer des projets",
"delete_workspace_settings_description": "Supprimer l'espace de travail avec toutes les enquêtes, réponses, personnes, actions et attributs. Cette action est irréversible.",
"error_saving_workspace_information": "Erreur lors de l'enregistrement des informations de l'espace de travail",
"only_owners_or_managers_can_delete_workspaces": "Seuls les propriétaires ou les gestionnaires peuvent supprimer des espaces de travail",
"recontact_waiting_time": "Période de refroidissement (entre les sondages)",
"recontact_waiting_time_settings_description": "Contrôlez la fréquence à laquelle les utilisateurs peuvent être interrogés dans tous les sondages de site web et d'application de cet espace de travail.",
"this_action_cannot_be_undone": "Cette action ne peut pas être annulée.",
"wait_x_days_before_showing_next_survey": "Attendre X jours avant d'afficher la prochaine enquête:",
"waiting_period_updated_successfully": "Période d'attente mise à jour avec succès",
"whats_your_workspace_called": "Comment s'appelle votre projet?",
"workspace_deleted_successfully": "Projet supprimé avec succès",
"workspace_name_settings_description": "Modifiez le nom de votre projet.",
"workspace_name_updated_successfully": "Nom du projet mis à jour avec succès"
"whats_your_workspace_called": "Comment s'appelle ton espace de travail ?",
"workspace_deleted_successfully": "Espace de travail supprimé avec succès",
"workspace_name_settings_description": "Modifie le nom de ton espace de travail.",
"workspace_name_updated_successfully": "Nom de l'espace de travail mis à jour avec succès"
},
"integrations": {
"activepieces_integration_description": "Connectez instantanément Formbricks à des applications populaires pour automatiser des tâches sans effectuer de codage.",
@@ -2171,7 +2177,7 @@
"alias_tooltip": "L'alias est un nom alternatif pour identifier la langue dans les enquêtes par lien et le SDK (facultatif)",
"cannot_remove_language_warning": "Vous ne pouvez pas supprimer cette langue car elle est encore utilisée dans ces enquêtes:",
"conflict_between_identifier_and_alias": "Il y a un conflit entre l'identifiant d'une langue ajoutée et l'un de vos alias. Les alias et les identifiants ne peuvent pas être identiques.",
"conflict_between_selected_alias_and_another_language": "Il y a un conflit entre l'alias sélectionné et une autre langue qui possède cet identifiant. Veuillez plutôt ajouter la langue avec cet identifiant à votre projet pour éviter les incohérences.",
"conflict_between_selected_alias_and_another_language": "Il y a un conflit entre l'alias sélectionné et une autre langue qui possède cet identifiant. Ajoute plutôt la langue avec cet identifiant à ton espace de travail pour éviter les incohérences.",
"delete_language_confirmation": "Êtes-vous sûr de vouloir supprimer cette langue? Cette action ne peut pas être annulée.",
"duplicate_language_or_language_id": "Langue ou identifiant de langue en double",
"edit_languages": "Modifier les langues",
@@ -2452,7 +2458,7 @@
"trial_payment_method_added_description": "Tout est prêt ! Votre abonnement Pro se poursuivra automatiquement après la fin de la période d'essai.",
"trial_title": "Obtenez Formbricks Pro gratuitement !",
"unlimited_responses": "Réponses illimitées",
"unlimited_workspaces": "Projets illimités",
"unlimited_workspaces": "Espaces de travail illimités",
"upgrade": "Mise à niveau",
"upgrade_now": "Passer à la formule supérieure maintenant",
"usage_cycle": "Usage cycle",
@@ -2475,7 +2481,7 @@
"pretty_url": "URL personnalisée",
"survey_name": "Nom de l'enquête",
"title": "URL personnalisées",
"workspace": "Projet"
"workspace": "Espace de travail"
},
"enterprise": {
"audit_logs": "Journaux d'audit",
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "Un répertoire d'enregistrement de feedback avec ce nom existe déjà.",
"error_directory_name_required": "Le nom du répertoire est requis.",
"error_directory_workspaces_invalid_org": "Certains espaces de travail spécifiés n'appartiennent pas à cette organisation.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "Répertoires de feedback",
"no_access": "Tu n'as pas la permission de gérer les répertoires de feedback.",
"no_connectors": "Aucun connecteur lié à ce répertoire pour le moment.",
@@ -2581,13 +2588,13 @@
"cannot_leave_only_organization": "Vous ne pouvez pas quitter cette organisation car c'est votre seule organisation. Créez d'abord une nouvelle organisation.",
"copy_invite_link_to_clipboard": "Copier le lien d'invitation dans le presse-papiers",
"create_new_organization": "Créer une nouvelle organisation",
"create_new_organization_description": "Créez une nouvelle organisation pour gérer un ensemble différent de projets.",
"create_new_organization_description": "Crée une nouvelle organisation pour gérer un ensemble différent d'espaces de travail.",
"customize_email_with_a_higher_plan": "Personnalisez vos e-mails en passant à un forfait supérieur",
"delete_member_confirmation": "Les membres supprimés perdront l'accès à tous les projets et enquêtes de votre organisation.",
"delete_member_confirmation": "Les membres supprimés perdront l'accès à tous les espaces de travail et enquêtes de votre organisation.",
"delete_organization": "Supprimer l'organisation",
"delete_organization_description": "Supprimer l'organisation avec tous ses projets, y compris toutes les enquêtes, réponses, personnes, actions et attributs",
"delete_organization_description": "Supprimer l'organisation avec tous ses espaces de travail, incluant toutes les enquêtes, réponses, personnes, actions et attributs",
"delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :",
"delete_organization_warning_1": "Suppression définitive de tous les projets liés à cette organisation.",
"delete_organization_warning_1": "Suppression définitive de tous les espaces de travail liés à cette organisation.",
"delete_organization_warning_2": "Cette action ne peut pas être annulée. Si c'est parti, c'est parti.",
"delete_organization_warning_3": "Veuillez entrer {organizationName} dans le champ suivant pour confirmer la suppression définitive de cette organisation :",
"eliminate_branding_with_whitelabel": "Le logo Formbricks n'apparaîtra plus et d'autres options de personnalisation s'offriront à vous.",
@@ -2682,7 +2689,7 @@
},
"teams": {
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
"add_workspaces_description": "Contrôlez les projets auxquels les membres de l'équipe peuvent accéder.",
"add_workspaces_description": "Contrôlez les espaces de travail auxquels les membres de l'équipe peuvent accéder.",
"all_members_added": "Tous les membres ajoutés à cette équipe.",
"all_workspaces_added": "Tous les espaces de travail ont été ajoutés à cette équipe.",
"are_you_sure_you_want_to_delete_this_team": "Êtes-vous sûr de vouloir supprimer cette équipe? Cela supprime également l'accès à tous les espaces de travail et enquêtes associés à cette équipe.",
@@ -3749,9 +3756,16 @@
"source_name": "Nom de la source",
"source_type": "Type de source",
"source_type_cannot_be_changed": "Le type de source ne peut pas être modifié",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "Erreur",
"status_live_sync": "Synchronisation en direct",
"status_paused": "En pause",
"status_ready": "Prêt",
"submission_id": "ID de soumission",
"survey_has_no_questions": "Ce sondage n'a pas de questions",
+15 -1
View File
@@ -148,6 +148,7 @@
"apply_filters": "Szűrők alkalmazása",
"archived": "Archivált",
"are_you_sure": "Biztos benne?",
"ask": "Ask",
"attributes": "Attribútumok",
"back": "Vissza",
"billing": "Számlázás",
@@ -213,6 +214,7 @@
"delete_what": "{deleteWhat} törlése",
"description": "Leírás",
"disable": "Letiltás",
"disabled": "Letiltva",
"disallow": "Ne engedélyezze",
"discard": "Elvetés",
"dismissed": "Eltüntetve",
@@ -1724,6 +1726,7 @@
"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_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "A diagram mentése sikertelen",
"field": "Mező",
"field_label_average_score": "Átlagos pontszám",
@@ -1868,6 +1871,9 @@
"app_connection_description": "Alkalmazás vagy webhely csatlakoztatása a Formbrickshez.",
"cache_update_delay_description": "Ha frissítéseket hajt végre a kérdőíveken, partnereken, műveleteken vagy egyéb adatokon, akkor akár 1 percet is igénybe vehet, mire azok a változtatások megjelennek a Formbricks SDK-t futtató helyi alkalmazásban.",
"cache_update_delay_title": "A változtatások körülbelül 1 perc múlva jelennek meg a gyorsítótárazás miatt",
"environment_id_legacy": "Környezet azonosító (elavult)",
"environment_id_legacy_alert": "Az Ön meglévő SDK beállítása esetleg még egy elavult környezet azonosítót használ.",
"environment_id_legacy_alert_link": "Ismerje meg, miért és hogyan kell migrálnia.",
"formbricks_sdk_connected": "A Formbricks SDK csatlakoztatva van",
"formbricks_sdk_not_connected": "A Formbricks SDK még nincs csatlakoztatva.",
"formbricks_sdk_not_connected_description": "Adja hozzá a Formbricks SDK-t a webhelyéhez vagy az alkalmazásához, hogy összekapcsolja azt a Formbricks platformmal",
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "Ezzel a névvel már létezik visszajelzési rekord könyvtár.",
"error_directory_name_required": "A könyvtár neve kötelező megadni.",
"error_directory_workspaces_invalid_org": "Egyes megadott munkaterületek nem ehhez a szervezethez tartoznak.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "Visszajelzési könyvtárak",
"no_access": "Nem rendelkezik jogosultsággal a visszajelzési nyilvántartási könyvtárak kezeléséhez.",
"no_connectors": "Még nincsenek csatlakozók társítva ehhez a könyvtárhoz.",
@@ -3749,9 +3756,16 @@
"source_name": "Forrásnév",
"source_type": "Forrás típus",
"source_type_cannot_be_changed": "A forrástípus nem módosítható",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "Hiba",
"status_live_sync": "Élő szinkronizálás",
"status_paused": "Szüneteltetve",
"status_ready": "Kész",
"submission_id": "Beküldés azonosítója",
"survey_has_no_questions": "Ez a felmérés nem tartalmaz kérdéseket",
+15 -1
View File
@@ -148,6 +148,7 @@
"apply_filters": "フィルターを適用",
"archived": "アーカイブ済み",
"are_you_sure": "よろしいですか?",
"ask": "Ask",
"attributes": "属性",
"back": "戻る",
"billing": "請求",
@@ -213,6 +214,7 @@
"delete_what": "{deleteWhat}を削除",
"description": "説明",
"disable": "無効にする",
"disabled": "無効",
"disallow": "許可しない",
"discard": "破棄",
"dismissed": "非表示",
@@ -1724,6 +1726,7 @@
"failed_to_execute_query": "クエリの実行に失敗しました",
"failed_to_load_chart": "チャートの読み込みに失敗しました",
"failed_to_load_chart_data": "チャートデータの読み込みに失敗しました",
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "チャートの保存に失敗しました",
"field": "フィールド",
"field_label_average_score": "平均スコア",
@@ -1868,6 +1871,9 @@
"app_connection_description": "アプリやウェブサイトをFormbricksに接続します。",
"cache_update_delay_description": "アンケート、連絡先、アクション、その他のデータを更新した場合、Formbricks SDKを実行しているローカルアプリに変更が反映されるまで最大1分かかることがあります。",
"cache_update_delay_title": "キャッシュにより変更は約1分後に反映されます",
"environment_id_legacy": "環境ID(レガシー)",
"environment_id_legacy_alert": "既存のSDKセットアップでは、レガシーの環境IDが使用されている可能性があります。",
"environment_id_legacy_alert_link": "理由と移行方法について詳しく見る。",
"formbricks_sdk_connected": "Formbricks SDKは接続されています",
"formbricks_sdk_not_connected": "Formbricks SDKはまだ接続されていません。",
"formbricks_sdk_not_connected_description": "ウェブサイトやアプリにFormbricks SDKを追加してFormbricksと接続してください",
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "この名前のフィードバック記録ディレクトリは既に存在します。",
"error_directory_name_required": "ディレクトリ名は必須です。",
"error_directory_workspaces_invalid_org": "指定されたワークスペースの一部がこの組織に属していません。",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "フィードバックディレクトリ",
"no_access": "フィードバック記録ディレクトリを管理する権限がありません。",
"no_connectors": "このディレクトリにリンクされているコネクタはまだありません。",
@@ -3749,9 +3756,16 @@
"source_name": "ソース名",
"source_type": "ソースタイプ",
"source_type_cannot_be_changed": "ソースタイプは変更できません",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "エラー",
"status_live_sync": "リアルタイム同期",
"status_paused": "一時停止",
"status_ready": "準備完了",
"submission_id": "提出ID",
"survey_has_no_questions": "このアンケートには質問がありません",
+35 -21
View File
@@ -148,6 +148,7 @@
"apply_filters": "Pas filters toe",
"archived": "Gearchiveerd",
"are_you_sure": "Weet je het zeker?",
"ask": "Ask",
"attributes": "Kenmerken",
"back": "Rug",
"billing": "Facturering",
@@ -213,6 +214,7 @@
"delete_what": "Verwijder {deleteWhat}",
"description": "Beschrijving",
"disable": "Uitzetten",
"disabled": "Uitgeschakeld",
"disallow": "Niet toestaan",
"discard": "Weggooien",
"dismissed": "Afgewezen",
@@ -506,7 +508,7 @@
"welcome_card": "Welkomstkaart",
"workspace": "Werkruimte",
"workspace_configuration": "Werkruimte-configuratie",
"workspace_created_successfully": "Project succesvol aangemaakt",
"workspace_created_successfully": "Werkruimte succesvol aangemaakt",
"workspace_creation_description": "Organiseer enquêtes in werkruimtes voor beter toegangsbeheer.",
"workspace_id": "Werkruimte-ID",
"workspace_name": "Werkruimtenaam",
@@ -1724,6 +1726,7 @@
"failed_to_execute_query": "Query uitvoeren mislukt",
"failed_to_load_chart": "Diagram laden mislukt",
"failed_to_load_chart_data": "Diagramdata laden mislukt",
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Opslaan van diagram mislukt",
"field": "Veld",
"field_label_average_score": "Gemiddelde score",
@@ -1868,6 +1871,9 @@
"app_connection_description": "Verbind uw app of website met Formbricks.",
"cache_update_delay_description": "Wanneer u wijzigingen aanbrengt in enquêtes, contacten, acties of andere gegevens, kan het tot 1 minuut duren voordat deze wijzigingen verschijnen in uw lokale app die de Formbricks SDK gebruikt.",
"cache_update_delay_title": "Wijzigingen worden na ~1 minuut weergegeven vanwege caching",
"environment_id_legacy": "Omgeving-ID (verouderd)",
"environment_id_legacy_alert": "Je bestaande SDK-configuratie gebruikt mogelijk nog een verouderde Omgeving-ID.",
"environment_id_legacy_alert_link": "Lees waarom en hoe je kunt migreren.",
"formbricks_sdk_connected": "Formbricks SDK is verbonden",
"formbricks_sdk_not_connected": "Formbricks SDK is nog niet verbonden.",
"formbricks_sdk_not_connected_description": "Voeg de Formbricks SDK toe aan uw website of app om deze te verbinden met Formbricks",
@@ -1991,7 +1997,7 @@
},
"formbricks_logo": "Formbricks-logo",
"general": {
"cannot_delete_only_workspace": "Dit is uw enige project, het kan niet worden verwijderd. Maak eerst een nieuw project aan.",
"cannot_delete_only_workspace": "Dit is je enige werkruimte en kan niet worden verwijderd. Maak eerst een nieuwe werkruimte aan.",
"custom_scripts": "Aangepaste scripts",
"custom_scripts_card_description": "Voeg trackingscripts en pixels toe aan alle linkenquêtes in deze werkruimte.",
"custom_scripts_description": "Scripts worden geïnjecteerd in de <head> van alle linkenquêtepagina's.",
@@ -1999,20 +2005,20 @@
"custom_scripts_placeholder": "<!-- Plak hier je trackingscripts -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Aangepaste scripts succesvol bijgewerkt",
"custom_scripts_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
"delete_workspace": "Project verwijderen",
"delete_workspace": "Werkruimte verwijderen",
"delete_workspace_confirmation": "Weet je zeker dat je {workspaceName} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Verwijder {workspaceName} inclusief alle enquêtes, antwoorden, mensen, acties en attributen.",
"delete_workspace_settings_description": "Verwijder project met alle enquêtes, reacties, mensen, acties en attributen. Dit kan niet ongedaan worden gemaakt.",
"error_saving_workspace_information": "Fout bij opslaan van projectinformatie",
"only_owners_or_managers_can_delete_workspaces": "Alleen eigenaren of beheerders kunnen projecten verwijderen",
"delete_workspace_settings_description": "Verwijder werkruimte met alle enquêtes, reacties, personen, acties en attributen. Dit kan niet ongedaan worden gemaakt.",
"error_saving_workspace_information": "Fout bij het opslaan van werkruimte-informatie",
"only_owners_or_managers_can_delete_workspaces": "Alleen eigenaren of managers kunnen werkruimtes verwijderen",
"recontact_waiting_time": "Afkoelperiode (voor alle enquêtes)",
"recontact_waiting_time_settings_description": "Bepaal hoe vaak gebruikers kunnen worden bevraagd voor alle website- en app-enquêtes in deze workspace.",
"this_action_cannot_be_undone": "Deze actie kan niet ongedaan worden gemaakt.",
"wait_x_days_before_showing_next_survey": "Wacht X dagen voordat de volgende enquête wordt getoond:",
"waiting_period_updated_successfully": "Wachtperiode succesvol bijgewerkt",
"whats_your_workspace_called": "Hoe heet uw project?",
"workspace_deleted_successfully": "Project succesvol verwijderd",
"workspace_name_settings_description": "Wijzig de naam van uw project.",
"whats_your_workspace_called": "Hoe heet je werkruimte?",
"workspace_deleted_successfully": "Werkruimte succesvol verwijderd",
"workspace_name_settings_description": "Wijzig de naam van je werkruimte.",
"workspace_name_updated_successfully": "Werkruimtenaam succesvol bijgewerkt"
},
"integrations": {
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "Er bestaat al een feedback-recordmap met deze naam.",
"error_directory_name_required": "Mapnaam is verplicht.",
"error_directory_workspaces_invalid_org": "Sommige opgegeven werkruimtes behoren niet tot deze organisatie.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "Feedbackmappen",
"no_access": "Je hebt geen toestemming om feedbackregistratiemappen te beheren.",
"no_connectors": "Nog geen connectoren gekoppeld aan deze map.",
@@ -2581,13 +2588,13 @@
"cannot_leave_only_organization": "U kunt deze organisatie niet verlaten, aangezien dit uw enige organisatie is. Maak eerst een nieuwe organisatie aan.",
"copy_invite_link_to_clipboard": "Kopieer de uitnodigingslink naar het klembord",
"create_new_organization": "Creëer een nieuwe organisatie",
"create_new_organization_description": "Creëer een nieuwe organisatie om een andere reeks projecten af te handelen.",
"create_new_organization_description": "Maak een nieuwe organisatie aan om een andere set werkruimtes te beheren.",
"customize_email_with_a_higher_plan": "Pas e-mail aan met een hoger abonnement",
"delete_member_confirmation": "Verwijderde leden verliezen de toegang tot alle projecten en enquêtes van uw organisatie.",
"delete_member_confirmation": "Verwijderde leden verliezen toegang tot alle werkruimtes en enquêtes van je organisatie.",
"delete_organization": "Organisatie verwijderen",
"delete_organization_description": "Verwijder de organisatie met al haar projecten, inclusief alle enquêtes, reacties, mensen, acties en attributen",
"delete_organization_description": "Verwijder organisatie met al haar werkruimtes inclusief alle enquêtes, reacties, personen, acties en attributen",
"delete_organization_warning": "Voordat u doorgaat met het verwijderen van deze organisatie, moet u rekening houden met de volgende gevolgen:",
"delete_organization_warning_1": "Permanente verwijdering van alle projecten die aan deze organisatie zijn gekoppeld.",
"delete_organization_warning_1": "Permanente verwijdering van alle werkruimtes die aan deze organisatie zijn gekoppeld.",
"delete_organization_warning_2": "Deze actie kan niet ongedaan worden gemaakt. Als het weg is, is het weg.",
"delete_organization_warning_3": "Voer {organizationName} in het volgende veld in om de definitieve verwijdering van deze organisatie te bevestigen:",
"eliminate_branding_with_whitelabel": "Elimineer de Formbricks-branding en maak extra white-label aanpassingsopties mogelijk.",
@@ -2685,7 +2692,7 @@
"add_workspaces_description": "Bepaal tot welke werkruimtes de teamleden toegang hebben.",
"all_members_added": "Alle leden zijn aan dit team toegevoegd.",
"all_workspaces_added": "Alle werkruimtes toegevoegd aan dit team.",
"are_you_sure_you_want_to_delete_this_team": "Weet u zeker dat u dit team wilt verwijderen? Hiermee wordt ook de toegang verwijderd tot alle projecten en enquêtes die aan dit team zijn gekoppeld.",
"are_you_sure_you_want_to_delete_this_team": "Weet je zeker dat je dit team wilt verwijderen? Dit verwijdert ook de toegang tot alle werkruimtes en enquêtes die bij dit team horen.",
"billing_role_description": "U heeft alleen toegang tot factuurgegevens.",
"bulk_invite": "Bulk-uitnodiging",
"contributor": "Bijdrager",
@@ -2701,10 +2708,10 @@
"manage": "Beheren",
"manage_team": "Beheer team",
"manage_team_disabled": "Alleen organisatie-eigenaren, managers en teambeheerders kunnen teams beheren.",
"manager_role_description": "Managers hebben toegang tot alle projecten en kunnen leden toevoegen en verwijderen.",
"manager_role_description": "Managers hebben toegang tot alle werkruimtes en kunnen leden toevoegen en verwijderen.",
"member": "Lid",
"member_role_description": "Leden kunnen in geselecteerde projecten werken.",
"member_role_info_message": "Om nieuwe leden toegang te geven tot een project, voegt u ze hieronder toe aan een team. Met Teams kun je beheren wie toegang heeft tot welk project.",
"member_role_description": "Leden kunnen werken in geselecteerde werkruimtes.",
"member_role_info_message": "Om nieuwe leden toegang te geven tot een werkruimte, voeg ze toe aan een Team hieronder. Met Teams kun je beheren wie toegang heeft tot welke werkruimte.",
"organization_role": "Organisatierol",
"owner_role_description": "Eigenaars hebben volledige controle over de organisatie.",
"please_fill_all_member_fields": "Vul alle velden in om een nieuw lid toe te voegen.",
@@ -2721,8 +2728,8 @@
"team_settings_description": "Beheer teamleden, toegangsrechten en meer.",
"team_updated_successfully": "Team succesvol bijgewerkt",
"teams": "Teams",
"teams_description": "Wijs leden toe aan teams en geef teams toegang tot projecten.",
"unlock_teams_description": "Beheer welke organisatieleden toegang hebben tot specifieke projecten en enquêtes.",
"teams_description": "Wijs leden toe aan teams en geef teams toegang tot werkruimtes.",
"unlock_teams_description": "Beheer welke organisatieleden toegang hebben tot specifieke werkruimtes en enquêtes.",
"unlock_teams_title": "Ontgrendel teams met een hoger plan.",
"upgrade_plan_notice_message": "Ontgrendel organisatierollen met een hoger plan.",
"you_are_a_member": "Je bent lid"
@@ -3068,7 +3075,7 @@
"options_used_in_logic_bulk_error": "De volgende opties worden gebruikt in logica: {questionIndexes}. Verwijder ze eerst uit de logica.",
"override_theme_with_individual_styles_for_this_survey": "Overschrijf het thema met individuele stijlen voor deze enquête.",
"overwrite_global_waiting_time": "Aangepaste afkoelperiode instellen",
"overwrite_global_waiting_time_description": "Overschrijf de projectconfiguratie alleen voor deze enquête.",
"overwrite_global_waiting_time_description": "Overschrijf de werkruimte-instellingen alleen voor deze enquête.",
"overwrite_placement": "Plaatsing overschrijven",
"overwrite_survey_logo": "Stel aangepast enquêtelogo in",
"overwrite_the_global_placement_of_the_survey": "Overschrijf de globale plaatsing van de enquête",
@@ -3749,9 +3756,16 @@
"source_name": "Bronnaam",
"source_type": "Brontype",
"source_type_cannot_be_changed": "Brontype kan niet worden gewijzigd",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "Fout",
"status_live_sync": "Live synchronisatie",
"status_paused": "Gepauzeerd",
"status_ready": "Klaar",
"submission_id": "Inzendings-ID",
"survey_has_no_questions": "Deze enquête heeft geen vragen",
+44 -30
View File
@@ -131,9 +131,9 @@
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logo",
"add_member": "Adicionar membro",
"add_new_workspace": "Adicionar novo projeto",
"add_new_workspace": "Adicionar novo workspace",
"add_to_team": "Adicionar à equipe",
"add_workspace": "Adicionar projeto",
"add_workspace": "Adicionar workspace",
"all": "Todos",
"all_questions": "Todas as perguntas",
"allow": "permitir",
@@ -148,6 +148,7 @@
"apply_filters": "Aplicar filtros",
"archived": "Arquivado",
"are_you_sure": "Certeza?",
"ask": "Ask",
"attributes": "atributos",
"back": "Voltar",
"billing": "Faturamento",
@@ -163,7 +164,7 @@
"choice_n": "Escolha {n}",
"choices": "Escolhas",
"choose_organization": "Escolher organização",
"choose_workspace": "Escolher projeto",
"choose_workspace": "Escolher workspace",
"clear_all": "Limpar tudo",
"clear_filters": "Limpar filtros",
"clear_selection": "Limpar seleção",
@@ -198,7 +199,7 @@
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar pesquisa",
"create_workspace": "Criar projeto",
"create_workspace": "Criar workspace",
"created": "Criado",
"created_at": "Data de criação",
"created_by": "Criado por",
@@ -213,6 +214,7 @@
"delete_what": "Excluir {deleteWhat}",
"description": "Descrição",
"disable": "desativar",
"disabled": "Desativado",
"disallow": "Não permita",
"discard": "Descartar",
"dismissed": "Dispensado",
@@ -242,7 +244,7 @@
"expand_rows": "Expandir linhas",
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"failed_to_load_workspaces": "Falha ao carregar workspaces",
"failed_to_parse_csv": "Falha ao analisar CSV",
"field_placeholder": "Espaço reservado de {field}",
"filter": "Filtro",
@@ -478,7 +480,7 @@
"type": "Tipo",
"unify": "Unificar",
"unknown_survey": "Pesquisa desconhecida",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais workspaces com um plano superior.",
"update": "atualizar",
"updated": "atualizado",
"updated_at": "Atualizado em",
@@ -505,13 +507,13 @@
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workspace": "Espaço de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
"workspace_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
"workspace_id": "ID do projeto",
"workspace_name": "Nome do projeto",
"workspace_configuration": "Configuração do Workspace",
"workspace_created_successfully": "Workspace criado com sucesso",
"workspace_creation_description": "Organize pesquisas em workspaces para melhor controle de acesso.",
"workspace_id": "ID do Workspace",
"workspace_name": "Nome do Workspace",
"workspace_name_placeholder": "ex: Formbricks",
"workspaces": "Projetos",
"workspaces": "Workspaces",
"years": "anos",
"yes": "Sim",
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
@@ -1724,6 +1726,7 @@
"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_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Falha ao salvar gráfico",
"field": "Campo",
"field_label_average_score": "Pontuação média",
@@ -1868,6 +1871,9 @@
"app_connection_description": "Conecte seu app ou site ao Formbricks.",
"cache_update_delay_description": "Quando você faz atualizações em pesquisas, contatos, ações ou outros dados, pode levar até 1 minuto para que essas alterações apareçam no seu app local executando o SDK do Formbricks.",
"cache_update_delay_title": "As alterações serão refletidas após ~1 minuto devido ao cache",
"environment_id_legacy": "ID do Ambiente (legado)",
"environment_id_legacy_alert": "Sua configuração de SDK existente pode ainda estar usando um ID de Ambiente legado.",
"environment_id_legacy_alert_link": "Saiba por que e como migrar.",
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
"formbricks_sdk_not_connected": "O SDK do Formbricks ainda não está conectado.",
"formbricks_sdk_not_connected_description": "Adicione o SDK do Formbricks ao seu site ou app para conectá-lo ao Formbricks",
@@ -1991,7 +1997,7 @@
},
"formbricks_logo": "Logo da Formbricks",
"general": {
"cannot_delete_only_workspace": "Este é seu único projeto, ele não pode ser excluído. Crie um novo projeto primeiro.",
"cannot_delete_only_workspace": "Este é o seu único workspace, não pode ser excluído. Crie um novo workspace primeiro.",
"custom_scripts": "Scripts personalizados",
"custom_scripts_card_description": "Adicione scripts de rastreamento e pixels a todas as pesquisas de link neste workspace.",
"custom_scripts_description": "Os scripts serão injetados no <head> de todas as páginas de pesquisa de link.",
@@ -1999,21 +2005,21 @@
"custom_scripts_placeholder": "<!-- Cole seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripts personalizados atualizados com sucesso",
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
"delete_workspace": "Excluir projeto",
"delete_workspace": "Excluir Workspace",
"delete_workspace_confirmation": "Tem certeza que deseja excluir {workspaceName}? Esta ação não pode ser desfeita.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Excluir {workspaceName} incluindo todas as pesquisas, respostas, pessoas, ações e atributos.",
"delete_workspace_settings_description": "Excluir projeto com todas as pesquisas, respostas, pessoas, ações e atributos. Isso não pode ser desfeito.",
"error_saving_workspace_information": "Erro ao salvar informações do projeto",
"only_owners_or_managers_can_delete_workspaces": "Apenas proprietários ou gerentes podem excluir projetos",
"delete_workspace_settings_description": "Excluir workspace com todas as pesquisas, respostas, pessoas, ações e atributos. Isso não pode ser desfeito.",
"error_saving_workspace_information": "Erro ao salvar informações do workspace",
"only_owners_or_managers_can_delete_workspaces": "Apenas proprietários ou gerentes podem excluir workspaces",
"recontact_waiting_time": "Período de espera (entre pesquisas)",
"recontact_waiting_time_settings_description": "Controle com que frequência os usuários podem ser pesquisados em todas as pesquisas de website e app neste workspace.",
"this_action_cannot_be_undone": "Essa ação não pode ser desfeita.",
"wait_x_days_before_showing_next_survey": "Aguardar X dias antes de mostrar a próxima pesquisa:",
"waiting_period_updated_successfully": "Período de espera atualizado com sucesso",
"whats_your_workspace_called": "Como se chama seu projeto?",
"workspace_deleted_successfully": "Projeto excluído com sucesso",
"workspace_name_settings_description": "Altere o nome do seu projeto.",
"workspace_name_updated_successfully": "Nome do projeto atualizado com sucesso"
"whats_your_workspace_called": "Como seu workspace se chama?",
"workspace_deleted_successfully": "Workspace excluído com sucesso",
"workspace_name_settings_description": "Altere o nome do seu workspace.",
"workspace_name_updated_successfully": "Nome do workspace atualizado com sucesso"
},
"integrations": {
"activepieces_integration_description": "Conecte o Formbricks instantaneamente com aplicativos populares para automatizar tarefas sem codificação.",
@@ -2171,7 +2177,7 @@
"alias_tooltip": "O alias é um nome alternativo para identificar o idioma em pesquisas de link e no SDK (opcional)",
"cannot_remove_language_warning": "Você não pode remover este idioma, pois ele ainda está sendo usado nestas pesquisas:",
"conflict_between_identifier_and_alias": "Há um conflito entre o identificador de um idioma adicionado e um dos seus aliases. Aliases e identificadores não podem ser idênticos.",
"conflict_between_selected_alias_and_another_language": "Há um conflito entre o alias selecionado e outro idioma que possui este identificador. Adicione o idioma com este identificador ao seu projeto para evitar inconsistências.",
"conflict_between_selected_alias_and_another_language": "Há um conflito entre o alias selecionado e outro idioma que possui este identificador. Por favor, adicione o idioma com este identificador ao seu workspace para evitar inconsistências.",
"delete_language_confirmation": "Tem certeza de que deseja excluir este idioma? Essa ação não pode ser desfeita.",
"duplicate_language_or_language_id": "Idioma ou ID de idioma duplicado",
"edit_languages": "Editar idiomas",
@@ -2452,7 +2458,7 @@
"trial_payment_method_added_description": "Tudo pronto! Seu plano Pro continuará automaticamente após o término do período de teste.",
"trial_title": "Ganhe o Formbricks Pro gratuitamente!",
"unlimited_responses": "Respostas Ilimitadas",
"unlimited_workspaces": "Projetos ilimitados",
"unlimited_workspaces": "Workspaces Ilimitados",
"upgrade": "Atualizar",
"upgrade_now": "Fazer upgrade agora",
"usage_cycle": "Usage cycle",
@@ -2475,7 +2481,7 @@
"pretty_url": "URL amigável",
"survey_name": "Nome da Pesquisa",
"title": "URLs amigáveis",
"workspace": "Projeto"
"workspace": "Workspace"
},
"enterprise": {
"audit_logs": "Registros de Auditoria",
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "Já existe um diretório de registros de feedback com este nome.",
"error_directory_name_required": "O nome do diretório é obrigatório.",
"error_directory_workspaces_invalid_org": "Alguns espaços de trabalho especificados não pertencem a esta organização.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "Diretórios de Feedback",
"no_access": "Você não tem permissão para gerenciar diretórios de registros de feedback.",
"no_connectors": "Nenhum conector vinculado a este diretório ainda.",
@@ -2581,13 +2588,13 @@
"cannot_leave_only_organization": "Você não pode sair dessa organização porque é a sua única. Crie uma nova organização primeiro.",
"copy_invite_link_to_clipboard": "Copiar link do convite para a área de transferência",
"create_new_organization": "Criar nova organização",
"create_new_organization_description": "Crie uma nova organização para gerenciar um conjunto diferente de projetos.",
"create_new_organization_description": "Crie uma nova organização para gerenciar um conjunto diferente de workspaces.",
"customize_email_with_a_higher_plan": "Personalize o email com um plano superior",
"delete_member_confirmation": "Membros excluídos perderão acesso a todos os projetos e pesquisas da sua organização.",
"delete_member_confirmation": "Membros excluídos perderão acesso a todos os workspaces e pesquisas da sua organização.",
"delete_organization": "Excluir Organização",
"delete_organization_description": "Excluir organização com todos os seus projetos, incluindo todas as pesquisas, respostas, pessoas, ações e atributos",
"delete_organization_description": "Excluir organização com todos os seus workspaces, incluindo todas as pesquisas, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de continuar com a exclusão desta organização, esteja ciente das seguintes consequências:",
"delete_organization_warning_1": "Remoção permanente de todos os projetos vinculados a esta organização.",
"delete_organization_warning_1": "Remoção permanente de todos os workspaces vinculados a esta organização.",
"delete_organization_warning_2": "Essa ação não pode ser desfeita. Se foi, foi.",
"delete_organization_warning_3": "Por favor, insira {organizationName} no campo abaixo para confirmar a exclusão definitiva desta organização:",
"eliminate_branding_with_whitelabel": "Elimine a marca Formbricks e ative opções adicionais de personalização de marca branca.",
@@ -2682,7 +2689,7 @@
},
"teams": {
"add_members_description": "Adicione membros à equipe e determine sua função.",
"add_workspaces_description": "Controle quais projetos os membros da equipe podem acessar.",
"add_workspaces_description": "Controle quais workspaces os membros da equipe podem acessar.",
"all_members_added": "Todos os membros adicionados a esta equipe.",
"all_workspaces_added": "Todos os espaços de trabalho adicionados a esta equipe.",
"are_you_sure_you_want_to_delete_this_team": "Tem certeza de que deseja excluir esta equipe? Isso também remove o acesso a todos os espaços de trabalho e pesquisas associados a esta equipe.",
@@ -3749,9 +3756,16 @@
"source_name": "Nome da origem",
"source_type": "Tipo de fonte",
"source_type_cannot_be_changed": "O tipo de origem não pode ser alterado",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "Erro",
"status_live_sync": "Sincronização ao vivo",
"status_paused": "Pausado",
"status_ready": "Pronto",
"submission_id": "ID de envio",
"survey_has_no_questions": "Esta pesquisa não possui perguntas",
+44 -30
View File
@@ -131,9 +131,9 @@
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logótipo",
"add_member": "Adicionar membro",
"add_new_workspace": "Adicionar novo projeto",
"add_new_workspace": "Adicionar novo espaço de trabalho",
"add_to_team": "Adicionar à equipa",
"add_workspace": "Adicionar projeto",
"add_workspace": "Adicionar espaço de trabalho",
"all": "Todos",
"all_questions": "Todas as perguntas",
"allow": "Permitir",
@@ -148,6 +148,7 @@
"apply_filters": "Aplicar filtros",
"archived": "Arquivado",
"are_you_sure": "Tem a certeza?",
"ask": "Ask",
"attributes": "Atributos",
"back": "Voltar",
"billing": "Faturação",
@@ -163,7 +164,7 @@
"choice_n": "Escolha {n}",
"choices": "Escolhas",
"choose_organization": "Escolher organização",
"choose_workspace": "Escolher projeto",
"choose_workspace": "Escolher espaço de trabalho",
"clear_all": "Limpar tudo",
"clear_filters": "Limpar filtros",
"clear_selection": "Limpar seleção",
@@ -198,7 +199,7 @@
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar inquérito",
"create_workspace": "Criar projeto",
"create_workspace": "Criar espaço de trabalho",
"created": "Criado",
"created_at": "Criado em",
"created_by": "Criado por",
@@ -213,6 +214,7 @@
"delete_what": "Eliminar {deleteWhat}",
"description": "Descrição",
"disable": "Desativar",
"disabled": "Desativado",
"disallow": "Não permitir",
"discard": "Descartar",
"dismissed": "Dispensado",
@@ -242,7 +244,7 @@
"expand_rows": "Expandir linhas",
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"failed_to_load_workspaces": "Falha ao carregar espaços de trabalho",
"failed_to_parse_csv": "Falha ao analisar o CSV",
"field_placeholder": "Espaço reservado de {field}",
"filter": "Filtro",
@@ -478,7 +480,7 @@
"type": "Tipo",
"unify": "Unificar",
"unknown_survey": "Inquérito desconhecido",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueia mais espaços de trabalho com um plano superior.",
"update": "Atualizar",
"updated": "Atualizado",
"updated_at": "Atualizado em",
@@ -505,13 +507,13 @@
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workspace": "Espaço de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
"workspace_creation_description": "Organize inquéritos em projetos para melhor controlo de acesso.",
"workspace_id": "ID do projeto",
"workspace_name": "Nome do projeto",
"workspace_configuration": "Configuração do Espaço de Trabalho",
"workspace_created_successfully": "Espaço de trabalho criado com sucesso",
"workspace_creation_description": "Organiza inquéritos em espaços de trabalho para um melhor controlo de acesso.",
"workspace_id": "ID do Espaço de Trabalho",
"workspace_name": "Nome do Espaço de Trabalho",
"workspace_name_placeholder": "ex. Formbricks",
"workspaces": "Projetos",
"workspaces": "Espaços de Trabalho",
"years": "anos",
"yes": "Sim",
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
@@ -1724,6 +1726,7 @@
"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_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Falha ao guardar gráfico",
"field": "Campo",
"field_label_average_score": "Pontuação média",
@@ -1868,6 +1871,9 @@
"app_connection_description": "Ligue a sua aplicação ou website ao Formbricks.",
"cache_update_delay_description": "Quando faz atualizações a inquéritos, contactos, ações ou outros dados, pode demorar até 1 minuto para que essas alterações apareçam na sua aplicação local que executa o SDK do Formbricks.",
"cache_update_delay_title": "As alterações serão refletidas após ~1 minuto devido ao caching",
"environment_id_legacy": "ID de Ambiente (legado)",
"environment_id_legacy_alert": "A tua configuração SDK existente pode ainda utilizar um ID de Ambiente legado.",
"environment_id_legacy_alert_link": "Descobre porquê e como migrar.",
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
"formbricks_sdk_not_connected": "O SDK do Formbricks ainda não está conectado.",
"formbricks_sdk_not_connected_description": "Adicione o SDK do Formbricks ao seu website ou aplicação para o conectar ao Formbricks",
@@ -1991,7 +1997,7 @@
},
"formbricks_logo": "Logotipo do Formbricks",
"general": {
"cannot_delete_only_workspace": "Este é o seu único projeto, não pode ser eliminado. Crie primeiro um novo projeto.",
"cannot_delete_only_workspace": "Este é o teu único espaço de trabalho e não pode ser eliminado. Cria primeiro um novo espaço de trabalho.",
"custom_scripts": "Scripts personalizados",
"custom_scripts_card_description": "Adicionar scripts de rastreamento e pixels a todos os inquéritos de link nesta área de trabalho.",
"custom_scripts_description": "Os scripts serão injetados no <head> de todas as páginas de inquéritos de link.",
@@ -1999,21 +2005,21 @@
"custom_scripts_placeholder": "<!-- Cole os seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripts personalizados atualizados com sucesso",
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
"delete_workspace": "Eliminar projeto",
"delete_workspace": "Eliminar Espaço de Trabalho",
"delete_workspace_confirmation": "Tens a certeza de que queres eliminar {workspaceName}? Esta ação não pode ser revertida.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {workspaceName} incluindo todos os inquéritos, respostas, pessoas, ações e atributos.",
"delete_workspace_settings_description": "Eliminar projeto com todos os inquéritos, respostas, pessoas, ações e atributos. Isto não pode ser desfeito.",
"error_saving_workspace_information": "Erro ao guardar informações do projeto",
"only_owners_or_managers_can_delete_workspaces": "Apenas proprietários ou gestores podem eliminar projetos",
"delete_workspace_settings_description": "Elimina o espaço de trabalho com todos os inquéritos, respostas, pessoas, ações e atributos. Esta ação não pode ser revertida.",
"error_saving_workspace_information": "Erro ao guardar informações do espaço de trabalho",
"only_owners_or_managers_can_delete_workspaces": "Apenas proprietários ou gestores podem eliminar espaços de trabalho",
"recontact_waiting_time": "Período de espera (entre inquéritos)",
"recontact_waiting_time_settings_description": "Controle com que frequência os utilizadores podem ser inquiridos em todos os inquéritos de website e aplicação neste espaço de trabalho.",
"this_action_cannot_be_undone": "Esta ação não pode ser desfeita.",
"wait_x_days_before_showing_next_survey": "Aguardar X dias antes de mostrar o próximo inquérito:",
"waiting_period_updated_successfully": "Período de espera atualizado com sucesso",
"whats_your_workspace_called": "Como se chama o seu projeto?",
"workspace_deleted_successfully": "Projeto eliminado com sucesso",
"workspace_name_settings_description": "Altere o nome do seu projeto.",
"workspace_name_updated_successfully": "Nome do projeto atualizado com sucesso"
"whats_your_workspace_called": "Como se chama o teu espaço de trabalho?",
"workspace_deleted_successfully": "Espaço de trabalho eliminado com sucesso",
"workspace_name_settings_description": "Altera o nome do teu espaço de trabalho.",
"workspace_name_updated_successfully": "Nome do espaço de trabalho atualizado com sucesso"
},
"integrations": {
"activepieces_integration_description": "Conecte instantaneamente o Formbricks com apps populares para automatizar tarefas sem codificação.",
@@ -2171,7 +2177,7 @@
"alias_tooltip": "O alias é um nome alternativo para identificar o idioma em inquéritos de link e no SDK (opcional)",
"cannot_remove_language_warning": "Não pode remover este idioma porque ainda está a ser utilizado nestes inquéritos:",
"conflict_between_identifier_and_alias": "Existe um conflito entre o identificador de um idioma adicionado e um dos seus aliases. Aliases e identificadores não podem ser idênticos.",
"conflict_between_selected_alias_and_another_language": "Existe um conflito entre o alias selecionado e outro idioma que tem este identificador. Por favor, adicione o idioma com este identificador ao seu projeto para evitar inconsistências.",
"conflict_between_selected_alias_and_another_language": "Existe um conflito entre o alias selecionado e outro idioma que possui este identificador. Por favor, adiciona o idioma com este identificador ao teu espaço de trabalho para evitar inconsistências.",
"delete_language_confirmation": "Tem a certeza de que pretende eliminar este idioma? Esta ação não pode ser desfeita.",
"duplicate_language_or_language_id": "Idioma ou ID de idioma duplicado",
"edit_languages": "Editar idiomas",
@@ -2452,7 +2458,7 @@
"trial_payment_method_added_description": "Está tudo pronto! O teu plano Pro continuará automaticamente após o fim do período experimental.",
"trial_title": "Obtém o Formbricks Pro gratuitamente!",
"unlimited_responses": "Respostas Ilimitadas",
"unlimited_workspaces": "Projetos ilimitados",
"unlimited_workspaces": "Espaços de Trabalho Ilimitados",
"upgrade": "Atualizar",
"upgrade_now": "Fazer upgrade agora",
"usage_cycle": "Usage cycle",
@@ -2475,7 +2481,7 @@
"pretty_url": "URL amigável",
"survey_name": "Nome do inquérito",
"title": "URLs amigáveis",
"workspace": "Projeto"
"workspace": "Espaço de Trabalho"
},
"enterprise": {
"audit_logs": "Registos de Auditoria",
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "Já existe um diretório de registos de feedback com este nome.",
"error_directory_name_required": "O nome do diretório é obrigatório.",
"error_directory_workspaces_invalid_org": "Algumas áreas de trabalho especificadas não pertencem a esta organização.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "Diretórios de Feedback",
"no_access": "Não tens permissão para gerir diretórios de registos de feedback.",
"no_connectors": "Ainda não há conectores associados a este diretório.",
@@ -2581,13 +2588,13 @@
"cannot_leave_only_organization": "Não pode sair desta organização, pois é a sua única organização. Crie uma nova organização primeiro.",
"copy_invite_link_to_clipboard": "Copiar link de convite para a área de transferência",
"create_new_organization": "Criar nova organização",
"create_new_organization_description": "Crie uma nova organização para gerir um conjunto diferente de projetos.",
"create_new_organization_description": "Cria uma nova organização para gerir um conjunto diferente de espaços de trabalho.",
"customize_email_with_a_higher_plan": "Personalize o e-mail com um plano superior",
"delete_member_confirmation": "Os membros eliminados perderão o acesso a todos os projetos e inquéritos da sua organização.",
"delete_member_confirmation": "Os membros eliminados perderão o acesso a todos os espaços de trabalho e inquéritos da tua organização.",
"delete_organization": "Eliminar Organização",
"delete_organization_description": "Eliminar organização com todos os seus projetos, incluindo todos os inquéritos, respostas, pessoas, ações e atributos",
"delete_organization_description": "Eliminar organização com todos os seus espaços de trabalho, incluindo todos os inquéritos, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de prosseguir com a eliminação desta organização, esteja ciente das seguintes consequências:",
"delete_organization_warning_1": "Remoção permanente de todos os projetos associados a esta organização.",
"delete_organization_warning_1": "Remoção permanente de todos os espaços de trabalho associados a esta organização.",
"delete_organization_warning_2": "Esta ação não pode ser desfeita. Se for eliminada, está eliminada.",
"delete_organization_warning_3": "Por favor, insira {organizationName} no campo seguinte para confirmar a eliminação definitiva desta organização:",
"eliminate_branding_with_whitelabel": "Elimine a marca Formbricks e ative opções adicionais de personalização de marca branca.",
@@ -2682,7 +2689,7 @@
},
"teams": {
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
"add_workspaces_description": "Controle a quais projetos os membros da equipa podem aceder.",
"add_workspaces_description": "Controla a quais espaços de trabalho os membros da equipa podem aceder.",
"all_members_added": "Todos os membros adicionados a esta equipa.",
"all_workspaces_added": "Todos os espaços de trabalho adicionados a esta equipa.",
"are_you_sure_you_want_to_delete_this_team": "Tem a certeza de que pretende eliminar esta equipa? Isto também remove o acesso a todos os espaços de trabalho e inquéritos associados a esta equipa.",
@@ -3749,9 +3756,16 @@
"source_name": "Nome da fonte",
"source_type": "Tipo de fonte",
"source_type_cannot_be_changed": "O tipo de fonte não pode ser alterado",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "Erro",
"status_live_sync": "Sincronização em direto",
"status_paused": "Em pausa",
"status_ready": "Pronto",
"submission_id": "ID de envio",
"survey_has_no_questions": "Este inquérito não tem perguntas",
+25 -11
View File
@@ -148,6 +148,7 @@
"apply_filters": "Aplică filtre",
"archived": "Arhivat",
"are_you_sure": "Ești sigur?",
"ask": "Ask",
"attributes": "Atribute",
"back": "Înapoi",
"billing": "Facturare",
@@ -213,6 +214,7 @@
"delete_what": "Șterge {deleteWhat}",
"description": "Descriere",
"disable": "Dezactivează",
"disabled": "Dezactivat",
"disallow": "Nu permite",
"discard": "Renunță",
"dismissed": "Respins",
@@ -1724,6 +1726,7 @@
"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_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Nu s-a putut salva graficul",
"field": "Câmp",
"field_label_average_score": "Scor mediu",
@@ -1868,6 +1871,9 @@
"app_connection_description": "Conectează-ți aplicația sau site-ul la Formbricks.",
"cache_update_delay_description": "Când faci actualizări la sondaje, contacte, acțiuni sau alte date, poate dura până la 1 minut până când aceste modificări apar în aplicația ta locală care rulează SDK-ul Formbricks.",
"cache_update_delay_title": "Modificările vor fi vizibile după aproximativ 1 minut din cauza cache-ului",
"environment_id_legacy": "ID mediu (legacy)",
"environment_id_legacy_alert": "Configurația SDK existentă poate folosi încă un ID de mediu legacy.",
"environment_id_legacy_alert_link": "Află de ce și cum să migrezi.",
"formbricks_sdk_connected": "SDK Formbricks este conectat",
"formbricks_sdk_not_connected": "SDK Formbricks nu este încă conectat.",
"formbricks_sdk_not_connected_description": "Adaugă SDK-ul Formbricks pe site-ul sau în aplicația ta pentru a-l conecta cu Formbricks",
@@ -1991,7 +1997,7 @@
},
"formbricks_logo": "Logo Formbricks",
"general": {
"cannot_delete_only_workspace": "Acesta este singurul tău proiect, nu poate fi șters. Creează mai întâi un proiect nou.",
"cannot_delete_only_workspace": "Acesta este singurul tău spațiu de lucru și nu poate fi șters. Creează mai întâi un spațiu de lucru nou.",
"custom_scripts": "Scripturi personalizate",
"custom_scripts_card_description": "Adaugă scripturi de tracking și pixeli tuturor sondajelor cu link din acest spațiu de lucru.",
"custom_scripts_description": "Scripturile vor fi injectate în <head> pe toate paginile sondajelor cu link.",
@@ -1999,21 +2005,21 @@
"custom_scripts_placeholder": "<!-- Lipește aici scripturile tale de tracking -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripturile personalizate au fost actualizate cu succes",
"custom_scripts_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
"delete_workspace": "Șterge proiectul",
"delete_workspace": "Șterge Spațiul de Lucru",
"delete_workspace_confirmation": "Ești sigur că vrei să ștergi {workspaceName}? Această acțiune nu poate fi anulată.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Șterge {workspaceName} incluzând toate sondajele, răspunsurile, persoanele, acțiunile și atributele.",
"delete_workspace_settings_description": "Șterge proiectul cu toate sondajele, răspunsurile, persoanele, acțiunile și atributele. Aceasta nu poate fi anulată.",
"error_saving_workspace_information": "Eroare la salvarea informațiilor despre proiect",
"only_owners_or_managers_can_delete_workspaces": "Doar proprietarii sau managerii pot șterge proiecte",
"delete_workspace_settings_description": "Șterge spațiul de lucru împreună cu toate sondajele, răspunsurile, persoanele, acțiunile și atributele. Această acțiune nu poate fi anulată.",
"error_saving_workspace_information": "Eroare la salvarea informațiilor spațiului de lucru",
"only_owners_or_managers_can_delete_workspaces": "Doar proprietarii sau managerii pot șterge spațiile de lucru",
"recontact_waiting_time": "Perioadă de răcire (între sondaje)",
"recontact_waiting_time_settings_description": "Controlează cât de des pot fi chestionați utilizatorii în toate sondajele Website & App din acest workspace.",
"this_action_cannot_be_undone": "Această acțiune nu poate fi anulată.",
"wait_x_days_before_showing_next_survey": "Așteaptă X zile înainte de a afișa următorul sondaj:",
"waiting_period_updated_successfully": "Perioada de așteptare a fost actualizată cu succes",
"whats_your_workspace_called": "Cum se numește proiectul tău?",
"workspace_deleted_successfully": "Proiectul a fost șters cu succes",
"workspace_name_settings_description": "Schimbă numele proiectului tău.",
"workspace_name_updated_successfully": "Numele proiectului a fost actualizat cu succes"
"whats_your_workspace_called": "Cum se numește spațiul tău de lucru?",
"workspace_deleted_successfully": "Spațiul de lucru a fost șters cu succes",
"workspace_name_settings_description": "Schimbă numele spațiului tău de lucru.",
"workspace_name_updated_successfully": "Numele spațiului de lucru a fost actualizat cu succes"
},
"integrations": {
"activepieces_integration_description": "Conectați instantaneu Formbricks cu aplicații populare pentru a automatiza sarcini fără codare.",
@@ -2171,7 +2177,7 @@
"alias_tooltip": "Aliasul este un nume alternativ pentru a identifica limba în sondajele pe link și în SDK (opțional)",
"cannot_remove_language_warning": "Nu poți elimina această limbă deoarece este încă folosită în următoarele sondaje:",
"conflict_between_identifier_and_alias": "Există un conflict între identificatorul unei limbi adăugate și unul dintre aliasurile tale. Aliasurile și identificatorii nu pot fi identici.",
"conflict_between_selected_alias_and_another_language": "Există un conflict între aliasul selectat și o altă limbă care are acest identificator. Adaugă limba cu acest identificator în proiectul tău pentru a evita inconsistențele.",
"conflict_between_selected_alias_and_another_language": "Există un conflict între aliasul selectat și o altă limbă care are acest identificator. Te rugăm să adaugi în schimb limba cu acest identificator în spațiul tău de lucru pentru a evita inconsistențele.",
"delete_language_confirmation": "Sigur vrei să ștergi această limbă? Această acțiune nu poate fi anulată.",
"duplicate_language_or_language_id": "Limbă sau ID de limbă duplicat",
"edit_languages": "Editați limbile",
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "Există deja un director de înregistrări feedback cu acest nume.",
"error_directory_name_required": "Numele directorului este obligatoriu.",
"error_directory_workspaces_invalid_org": "Unele spații de lucru specificate nu aparțin acestei organizații.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "Directoare de feedback",
"no_access": "Nu ai permisiunea de a gestiona directoarele de înregistrări de feedback.",
"no_connectors": "Niciun conector asociat acestui director încă.",
@@ -3749,9 +3756,16 @@
"source_name": "Nume sursă",
"source_type": "Tip sursă",
"source_type_cannot_be_changed": "Tipul sursei nu poate fi schimbat",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "Eroare",
"status_live_sync": "Sincronizare în timp real",
"status_paused": "Pauzat",
"status_ready": "Gata",
"submission_id": "ID-ul trimiterii",
"survey_has_no_questions": "Acest sondaj nu are întrebări",
+35 -21
View File
@@ -148,6 +148,7 @@
"apply_filters": "Применить фильтры",
"archived": "Архивный",
"are_you_sure": "Вы уверены?",
"ask": "Ask",
"attributes": "Атрибуты",
"back": "Назад",
"billing": "Оплата",
@@ -213,6 +214,7 @@
"delete_what": "Удалить {deleteWhat}",
"description": "Описание",
"disable": "Отключить",
"disabled": "Отключено",
"disallow": "Не разрешать",
"discard": "Отменить",
"dismissed": "Отклонено",
@@ -506,7 +508,7 @@
"welcome_card": "Приветственная карточка",
"workspace": "Рабочее пространство",
"workspace_configuration": "Настройка рабочего пространства",
"workspace_created_successfully": "Рабочий проект успешно создан",
"workspace_created_successfully": "Рабочее пространство успешно создано",
"workspace_creation_description": "Организуйте опросы в рабочих пространствах для лучшего контроля доступа.",
"workspace_id": "ID рабочего пространства",
"workspace_name": "Название рабочего пространства",
@@ -1724,6 +1726,7 @@
"failed_to_execute_query": "Не удалось выполнить запрос",
"failed_to_load_chart": "Не удалось загрузить график",
"failed_to_load_chart_data": "Не удалось загрузить данные графика",
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Не удалось сохранить график",
"field": "Поле",
"field_label_average_score": "Средний балл",
@@ -1868,6 +1871,9 @@
"app_connection_description": "Подключите ваше приложение или сайт к Formbricks.",
"cache_update_delay_description": "Когда вы обновляете опросы, контакты, действия или другие данные, изменения могут появиться в вашем локальном приложении с Formbricks SDK с задержкой до 1 минуты.",
"cache_update_delay_title": "Изменения отразятся примерно через 1 минуту из-за кэширования",
"environment_id_legacy": "ID среды (устаревший)",
"environment_id_legacy_alert": "Твоя текущая настройка SDK может всё ещё использовать устаревший ID среды.",
"environment_id_legacy_alert_link": "Узнай, почему и как выполнить миграцию.",
"formbricks_sdk_connected": "Formbricks SDK подключён",
"formbricks_sdk_not_connected": "Formbricks SDK ещё не подключён.",
"formbricks_sdk_not_connected_description": "Добавьте SDK Formbricks на ваш сайт или в приложение, чтобы подключить его к Formbricks",
@@ -1991,7 +1997,7 @@
},
"formbricks_logo": "Логотип Formbricks",
"general": {
"cannot_delete_only_workspace": "Это ваш единственный рабочий проект, его нельзя удалить. Сначала создайте новый проект.",
"cannot_delete_only_workspace": "Это ваше единственное рабочее пространство, его нельзя удалить. Сначала создайте новое рабочее пространство.",
"custom_scripts": "Пользовательские скрипты",
"custom_scripts_card_description": "Добавьте трекинговые скрипты и пиксели ко всем опросам по ссылке в этом рабочем пространстве.",
"custom_scripts_description": "Скрипты будут внедряться в <head> всех страниц опросов по ссылке.",
@@ -1999,20 +2005,20 @@
"custom_scripts_placeholder": "<!-- Вставьте сюда ваши трекинговые скрипты -->\n<script>\n // Google Tag Manager, Analytics и др.\n</script>",
"custom_scripts_updated_successfully": "Пользовательские скрипты успешно обновлены",
"custom_scripts_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
"delete_workspace": "Удалить рабочий проект",
"delete_workspace": "Удалить рабочее пространство",
"delete_workspace_confirmation": "Вы уверены, что хотите удалить {workspaceName}? Это действие нельзя отменить.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Удалить {workspaceName}, включая все опросы, ответы, людей, действия и атрибуты.",
"delete_workspace_settings_description": "Удалить рабочий проект со всеми опросами, ответами, пользователями, действиями и атрибутами. Это действие необратимо.",
"error_saving_workspace_information": "Ошибка при сохранении информации о рабочем проекте",
"only_owners_or_managers_can_delete_workspaces": "Только владельцы или менеджеры могут удалять рабочие проекты",
"delete_workspace_settings_description": "Удалить рабочее пространство со всеми опросами, ответами, людьми, действиями и атрибутами. Это действие нельзя отменить.",
"error_saving_workspace_information": "Ошибка при сохранении информации о рабочем пространстве",
"only_owners_or_managers_can_delete_workspaces": "Только владельцы или менеджеры могут удалять рабочие пространства",
"recontact_waiting_time": "Период ожидания (между опросами)",
"recontact_waiting_time_settings_description": "Управляйте частотой, с которой пользователи могут проходить опросы на сайте и в приложении в этом рабочем пространстве.",
"this_action_cannot_be_undone": "Это действие нельзя отменить.",
"wait_x_days_before_showing_next_survey": "Ждать X дней перед показом следующего опроса:",
"waiting_period_updated_successfully": "Период ожидания успешно обновлён",
"whats_your_workspace_called": "Как называется ваш рабочий проект?",
"workspace_deleted_successfully": "Рабочий проект успешно удалён",
"workspace_name_settings_description": "Измените название вашего рабочего проекта.",
"whats_your_workspace_called": "Как называется твоё рабочее пространство?",
"workspace_deleted_successfully": "Рабочее пространство успешно удалено",
"workspace_name_settings_description": "Измените название вашего рабочего пространства.",
"workspace_name_updated_successfully": "Название рабочей области успешно обновлено"
},
"integrations": {
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "Директория с записями обратной связи с таким именем уже существует.",
"error_directory_name_required": "Необходимо указать имя директории.",
"error_directory_workspaces_invalid_org": "Некоторые указанные рабочие пространства не принадлежат этой организации.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "Каталоги отзывов",
"no_access": "У тебя нет прав для управления каталогами записей отзывов.",
"no_connectors": "К этому каталогу пока не привязано ни одного коннектора.",
@@ -2581,13 +2588,13 @@
"cannot_leave_only_organization": "Вы не можете покинуть эту организацию, так как она у вас единственная. Сначала создайте новую организацию.",
"copy_invite_link_to_clipboard": "Скопировать ссылку-приглашение в буфер обмена",
"create_new_organization": "Создать новую организацию",
"create_new_organization_description": "Создайте новую организацию для управления отдельным набором проектов.",
"create_new_organization_description": "Создайте новую организацию для управления другим набором рабочих пространств.",
"customize_email_with_a_higher_plan": "Настройте электронную почту с помощью более высокого тарифа",
"delete_member_confirmation": "Удалённые участники потеряют доступ ко всем проектам и опросам вашей организации.",
"delete_member_confirmation": "Удалённые участники потеряют доступ ко всем рабочим пространствам и опросам вашей организации.",
"delete_organization": "Удалить организацию",
"delete_organization_description": "Удалите организацию со всеми её проектами, включая все опросы, ответы, людей, действия и атрибуты",
"delete_organization_description": "Удалить организацию со всеми её рабочими пространствами, включая все опросы, ответы, людей, действия и атрибуты",
"delete_organization_warning": "Прежде чем продолжить удаление этой организации, обратите внимание на следующие последствия:",
"delete_organization_warning_1": "Безвозвратное удаление всех проектов, связанных с этой организацией.",
"delete_organization_warning_1": "Безвозвратное удаление всех рабочих пространств, связанных с этой организацией.",
"delete_organization_warning_2": "Это действие нельзя отменить. Если удалено — то удалено навсегда.",
"delete_organization_warning_3": "Пожалуйста, введите {organizationName} в поле ниже для подтверждения окончательного удаления этой организации:",
"eliminate_branding_with_whitelabel": "Уберите брендинг Formbricks и получите дополнительные возможности для white-label кастомизации.",
@@ -2685,7 +2692,7 @@
"add_workspaces_description": "Управляйте доступом участников команды к рабочим пространствам.",
"all_members_added": "Все участники добавлены в эту команду.",
"all_workspaces_added": "Все рабочие пространства добавлены в эту команду.",
"are_you_sure_you_want_to_delete_this_team": "Вы уверены, что хотите удалить эту команду? Это также удалит доступ ко всем проектам и опросам, связанным с этой командой.",
"are_you_sure_you_want_to_delete_this_team": "Вы уверены, что хотите удалить эту команду? Это также удалит доступ ко всем рабочим пространствам и опросам, связанным с этой командой.",
"billing_role_description": "Доступ только к платёжной информации.",
"bulk_invite": "Массовое приглашение",
"contributor": "Участник",
@@ -2701,10 +2708,10 @@
"manage": "Управлять",
"manage_team": "Управлять командой",
"manage_team_disabled": "Только владельцы организации, менеджеры и администраторы команд могут управлять командами.",
"manager_role_description": "Менеджеры имеют доступ ко всем проектам, а также могут добавлять и удалять участников.",
"manager_role_description": "Менеджеры могут получать доступ ко всем рабочим пространствам, а также добавлять и удалять участников.",
"member": "Участник",
"member_role_description": "Участники могут работать в выбранных проектах.",
"member_role_info_message": "Чтобы предоставить новым участникам доступ к проекту, добавьте их в команду ниже. С помощью команд вы можете управлять доступом к проектам.",
"member_role_description": "Участники могут работать в выбранных рабочих пространствах.",
"member_role_info_message": "Чтобы предоставить новым участникам доступ к рабочему пространству, добавьте их в команду ниже. С помощью команд ты можешь управлять тем, кто имеет доступ к какому рабочему пространству.",
"organization_role": "Роль в организации",
"owner_role_description": "Владельцы имеют полный контроль над организацией.",
"please_fill_all_member_fields": "Пожалуйста, заполните все поля для добавления нового участника.",
@@ -2721,8 +2728,8 @@
"team_settings_description": "Управляйте участниками команды, правами доступа и другими параметрами.",
"team_updated_successfully": "Команда успешно обновлена",
"teams": "Команды",
"teams_description": "Распределяйте участников по командам и предоставляйте командам доступ к проектам.",
"unlock_teams_description": "Управляйте доступом участников организации к отдельным проектам и опросам.",
"teams_description": "Распределите участников по командам и предоставьте командам доступ к рабочим пространствам.",
"unlock_teams_description": "Управляйте тем, какие участники организации имеют доступ к конкретным рабочим пространствам и опросам.",
"unlock_teams_title": "Откройте доступ к командам с более высоким тарифом.",
"upgrade_plan_notice_message": "Откройте роли организации с более высоким тарифом.",
"you_are_a_member": "Вы участник"
@@ -3068,7 +3075,7 @@
"options_used_in_logic_bulk_error": "Следующие варианты используются в логике: {questionIndexes}. Пожалуйста, сначала удалите их из логики.",
"override_theme_with_individual_styles_for_this_survey": "Переопределить тему индивидуальными стилями для этого опроса.",
"overwrite_global_waiting_time": "Установить свой период ожидания",
"overwrite_global_waiting_time_description": "Переопределить настройки проекта только для этого опроса.",
"overwrite_global_waiting_time_description": "Переопределить настройки рабочего пространства только для этого опроса.",
"overwrite_placement": "Переопределить размещение",
"overwrite_survey_logo": "Установить индивидуальный логотип опроса",
"overwrite_the_global_placement_of_the_survey": "Переопределить глобальное размещение опроса",
@@ -3749,9 +3756,16 @@
"source_name": "Имя источника",
"source_type": "Тип источника",
"source_type_cannot_be_changed": "Тип источника нельзя изменить",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "Ошибка",
"status_live_sync": "Синхронизация в реальном времени",
"status_paused": "Приостановлен",
"status_ready": "Готово",
"submission_id": "Идентификатор отправки",
"survey_has_no_questions": "В этом опросе нет вопросов",
+26 -12
View File
@@ -148,6 +148,7 @@
"apply_filters": "Tillämpa filter",
"archived": "Arkiverad",
"are_you_sure": "Är du säker?",
"ask": "Ask",
"attributes": "Attribut",
"back": "Tillbaka",
"billing": "Fakturering",
@@ -213,6 +214,7 @@
"delete_what": "Ta bort {deleteWhat}",
"description": "Beskrivning",
"disable": "Inaktivera",
"disabled": "Inaktiverad",
"disallow": "Tillåt inte",
"discard": "Förkasta",
"dismissed": "Avvisad",
@@ -1724,6 +1726,7 @@
"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_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Det gick inte att spara diagrammet",
"field": "Fält",
"field_label_average_score": "Genomsnittligt betyg",
@@ -1868,6 +1871,9 @@
"app_connection_description": "Anslut din app eller webbplats till Formbricks.",
"cache_update_delay_description": "När du gör uppdateringar av undersökningar, kontakter, åtgärder eller annan data kan det ta upp till 1 minut innan ändringarna syns i din lokala app som kör Formbricks SDK.",
"cache_update_delay_title": "Ändringar syns efter cirka 1 minut på grund av cachelagring",
"environment_id_legacy": "Miljö-ID (äldre version)",
"environment_id_legacy_alert": "Din befintliga SDK-konfiguration kan fortfarande använda ett äldre miljö-ID.",
"environment_id_legacy_alert_link": "Läs mer om varför och hur du migrerar.",
"formbricks_sdk_connected": "Formbricks SDK är anslutet",
"formbricks_sdk_not_connected": "Formbricks SDK är ännu inte anslutet.",
"formbricks_sdk_not_connected_description": "Lägg till Formbricks SDK på din webbplats eller i din app för att ansluta den till Formbricks",
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "En katalog för återkopplingsregister med detta namn finns redan.",
"error_directory_name_required": "Katalognamn krävs.",
"error_directory_workspaces_invalid_org": "Vissa angivna arbetsytor tillhör inte denna organisation.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "Feedbackkataloger",
"no_access": "Du har inte behörighet att hantera kataloger för feedbackposter.",
"no_connectors": "Inga kopplingar länkade till den här katalogen ännu.",
@@ -2581,13 +2588,13 @@
"cannot_leave_only_organization": "Du kan inte lämna denna organisation eftersom det är din enda organisation. Skapa en ny organisation först.",
"copy_invite_link_to_clipboard": "Kopiera inbjudningslänk till urklipp",
"create_new_organization": "Skapa ny organisation",
"create_new_organization_description": "Skapa en ny organisation för att hantera en annan uppsättning projekt.",
"create_new_organization_description": "Skapa en ny organisation för att hantera en annan uppsättning arbetsytor.",
"customize_email_with_a_higher_plan": "Anpassa e-post med en högre plan",
"delete_member_confirmation": "Borttagna medlemmar förlorar åtkomst till alla projekt och enkäter i din organisation.",
"delete_member_confirmation": "Borttagna medlemmar förlorar åtkomst till alla arbetsytor och undersökningar i din organisation.",
"delete_organization": "Ta bort organisation",
"delete_organization_description": "Ta bort organisation med alla dess projekt inklusive alla enkäter, svar, personer, åtgärder och attribut",
"delete_organization_description": "Ta bort organisation med alla dess arbetsytor inklusive alla undersökningar, svar, personer, åtgärder och attribut",
"delete_organization_warning": "Innan du fortsätter med att ta bort denna organisation, var medveten om följande konsekvenser:",
"delete_organization_warning_1": "Permanent borttagning av alla projekt kopplade till denna organisation.",
"delete_organization_warning_1": "Permanent borttagning av alla arbetsytor kopplade till denna organisation.",
"delete_organization_warning_2": "Denna åtgärd kan inte ångras. När det är borta, är det borta.",
"delete_organization_warning_3": "Vänligen ange {organizationName} i följande fält för att bekräfta den definitiva borttagningen av denna organisation:",
"eliminate_branding_with_whitelabel": "Eliminera Formbricks-varumärke och aktivera ytterligare white-label-anpassningsalternativ.",
@@ -2685,7 +2692,7 @@
"add_workspaces_description": "Styr vilka arbetsytor teammedlemmarna kan komma åt.",
"all_members_added": "Alla medlemmar tillagda i detta team.",
"all_workspaces_added": "Alla arbetsytor har lagts till i detta team.",
"are_you_sure_you_want_to_delete_this_team": "Är du säker på att du vill ta bort detta team? Detta tar även bort åtkomsten till alla projekt och enkäter kopplade till detta team.",
"are_you_sure_you_want_to_delete_this_team": "Är du säker på att du vill ta bort det här teamet? Detta tar även bort åtkomsten till alla arbetsytor och undersökningar som är kopplade till teamet.",
"billing_role_description": "Har endast åtkomst till faktureringsinformation.",
"bulk_invite": "Massinbjudning",
"contributor": "Bidragsgivare",
@@ -2701,10 +2708,10 @@
"manage": "Hantera",
"manage_team": "Hantera team",
"manage_team_disabled": "Endast organisationsägare, administratörer och teamadministratörer kan hantera team.",
"manager_role_description": "Administratörer kan komma åt alla projekt och lägga till och ta bort medlemmar.",
"manager_role_description": "Administratörer kan komma åt alla arbetsytor och lägga till samt ta bort medlemmar.",
"member": "Medlem",
"member_role_description": "Medlemmar kan arbeta i valda projekt.",
"member_role_info_message": "För att ge nya medlemmar åtkomst till ett projekt, vänligen lägg till dem i ett team nedan. Med team kan du hantera vem som har åtkomst till vilket projekt.",
"member_role_description": "Medlemmar kan arbeta i utvalda arbetsytor.",
"member_role_info_message": "För att ge nya medlemmar åtkomst till en arbetsyta, lägg till dem i ett team nedan. Med team kan du hantera vem som har åtkomst till vilken arbetsyta.",
"organization_role": "Organisationsroll",
"owner_role_description": "Ägare har full kontroll över organisationen.",
"please_fill_all_member_fields": "Vänligen fyll i alla fält för att lägga till en ny medlem.",
@@ -2721,8 +2728,8 @@
"team_settings_description": "Hantera teammedlemmar, åtkomsträttigheter och mer.",
"team_updated_successfully": "Team uppdaterat",
"teams": "Team",
"teams_description": "Tilldela medlemmar till team och ge team åtkomst till projekt.",
"unlock_teams_description": "Hantera vilka organisationsmedlemmar som har åtkomst till specifika projekt och enkäter.",
"teams_description": "Tilldela medlemmar till team och ge team åtkomst till arbetsytor.",
"unlock_teams_description": "Hantera vilka organisationsmedlemmar som har åtkomst till specifika arbetsytor och undersökningar.",
"unlock_teams_title": "Lås upp team med en högre plan.",
"upgrade_plan_notice_message": "Lås upp organisationsroller med en högre plan.",
"you_are_a_member": "Du är medlem"
@@ -3068,7 +3075,7 @@
"options_used_in_logic_bulk_error": "Följande alternativ används i logiken: {questionIndexes}. Vänligen ta bort dem från logiken först.",
"override_theme_with_individual_styles_for_this_survey": "Åsidosätt temat med individuella stilar för denna enkät.",
"overwrite_global_waiting_time": "Ange anpassad väntetid",
"overwrite_global_waiting_time_description": "Åsidosätt projektkonfigurationen endast för denna enkät.",
"overwrite_global_waiting_time_description": "Åsidosätt arbetsytans konfiguration för enbart denna undersökning.",
"overwrite_placement": "Åsidosätt placering",
"overwrite_survey_logo": "Ange anpassad logotyp för undersökningen",
"overwrite_the_global_placement_of_the_survey": "Åsidosätt den globala placeringen av enkäten",
@@ -3749,9 +3756,16 @@
"source_name": "Källnamn",
"source_type": "Källtyp",
"source_type_cannot_be_changed": "Källtyp kan inte ändras",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "Fel",
"status_live_sync": "Live sync",
"status_paused": "Pausad",
"status_ready": "Ready",
"submission_id": "Inlämnings-ID",
"survey_has_no_questions": "Den här enkäten har inga frågor",
+73 -59
View File
@@ -148,6 +148,7 @@
"apply_filters": "Filtreleri uygula",
"archived": "Arşivlenmiş",
"are_you_sure": "Emin misiniz?",
"ask": "Ask",
"attributes": "Öznitelikler",
"back": "Geri",
"billing": "Faturalandırma",
@@ -213,6 +214,7 @@
"delete_what": "{deleteWhat} sil",
"description": "Açıklama",
"disable": "Devre dışı bırak",
"disabled": "Devre Dışı",
"disallow": "İzin verme",
"discard": "İptal et",
"dismissed": "Reddedildi",
@@ -516,7 +518,7 @@
"yes": "Evet",
"you_are_downgraded_to_the_community_edition": "Topluluk Sürümüne düşürüldünüz.",
"you_are_not_authorized_to_perform_this_action": "Bu işlemi gerçekleştirme yetkiniz yok.",
"you_have_reached_your_limit_of_workspace_limit": "{projectLimit} çalışma alanı sınırınıza ulaştınız.",
"you_have_reached_your_limit_of_workspace_limit": "{workspaceLimit} çalışma alanı limitine ulaştınız.",
"you_have_reached_your_monthly_response_limit_of": "Aylık yanıt sınırınıza ulaştınız:",
"you_will_be_downgraded_to_the_community_edition_on_date": "{date} tarihinde Topluluk Sürümüne düşürüleceksiniz.",
"your_license_has_expired_please_renew": "Kurumsal lisansınızın süresi doldu. Kurumsal özellikleri kullanmaya devam etmek için lütfen yenileyin."
@@ -714,10 +716,10 @@
"book_interview": "Mülakat planla",
"build_product_roadmap_description": "Kullanıcılarınızın en çok istediği TEK şeyi belirleyin ve oluşturun.",
"build_product_roadmap_name": "Ürün Yol Haritası Oluştur",
"build_product_roadmap_question_1_headline": "$[projectName] özelliklerinden ve işlevselliğinden ne kadar memnunsunuz?",
"build_product_roadmap_question_1_headline": "$[workspaceName] ürününün özellikleri ve işlevselliğinden ne kadar memnunsunuz?",
"build_product_roadmap_question_1_lower_label": "Hiç memnun değil",
"build_product_roadmap_question_1_upper_label": "Son derece memnun",
"build_product_roadmap_question_2_headline": "$[projectName] deneyiminizi en çok iyileştirecek TEK değişiklik ne olurdu?",
"build_product_roadmap_question_2_headline": "$[workspaceName] deneyiminizi en çok geliştirebilmemiz için yapabileceğimiz TEK değişiklik nedir?",
"build_product_roadmap_question_2_placeholder": "Cevabınızı buraya yazın…",
"card_abandonment_survey": "Sepet Terk Survey'i",
"card_abandonment_survey_description": "Web mağazanızdaki sepet terk nedenlerini anlayın.",
@@ -750,10 +752,10 @@
"card_abandonment_survey_question_8_headline": "Ek yorum veya önerileriniz var mı?",
"career_development_survey_description": "Çalışanların kariyer gelişimi ve fırsatlarından memnuniyetini değerlendirin.",
"career_development_survey_name": "Kariyer Gelişimi Survey'i",
"career_development_survey_question_1_headline": "$[projectName] bünyesindeki kişisel ve mesleki gelişim fırsatlarından memnunum.",
"career_development_survey_question_1_headline": "$[workspaceName] bünyesinde kişisel ve profesyonel gelişim fırsatlarından memnunum.",
"career_development_survey_question_1_lower_label": "Kesinlikle katılmıyorum",
"career_development_survey_question_1_upper_label": "Kesinlikle katılıyorum",
"career_development_survey_question_2_headline": "$[projectName] bünyesinde bana sunulan kariyer ilerleme fırsatlarından memnunum.",
"career_development_survey_question_2_headline": "$[workspaceName] bünyesinde bana sunulan kariyer ilerleme fırsatlarından memnunum.",
"career_development_survey_question_2_lower_label": "Kesinlikle katılmıyorum",
"career_development_survey_question_2_upper_label": "Kesinlikle katılıyorum",
"career_development_survey_question_3_headline": "Organizasyonumun sunduğu işle ilgili eğitimlerden memnunum.",
@@ -783,7 +785,7 @@
"ces_lower_label": "Çok zor",
"ces_upper_label": "Çok kolay",
"cess_survey_name": "CES Survey",
"cess_survey_question_1_headline": "$[projectName] benim için [HEDEF EKLE] işlemini kolaylaştırıyor",
"cess_survey_question_1_headline": "$[workspaceName], [HEDEF EKLE] konusunda işimi kolaylaştırıyor",
"cess_survey_question_1_lower_label": "Kesinlikle katılmıyorum",
"cess_survey_question_1_upper_label": "Kesinlikle katılıyorum",
"cess_survey_question_2_headline": "Teşekkürler! [HEDEF EKLE] işlemini sizin için nasıl kolaylaştırabiliriz?",
@@ -810,7 +812,7 @@
"churn_survey_question_1_headline": "Aboneliğinizi neden iptal ettiniz?",
"churn_survey_question_1_subheader": "Ayrılmanıza üzüldük. Daha iyisini yapmamıza yardımcı olun:",
"churn_survey_question_2_button_label": "Gönder",
"churn_survey_question_2_headline": "$[projectName] kullanımını sizin için ne kolaylaştırırdı?",
"churn_survey_question_2_headline": "$[workspaceName] ürününü kullanımını kolaylaştıracak ne olabilirdi?",
"churn_survey_question_3_button_label": "Yüzde 30 indirim alın",
"churn_survey_question_3_headline": "Gelecek yıl için %30 indirim kazanın!",
"churn_survey_question_3_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Sizi müşterimiz olarak tutmak istiyoruz. Gelecek yıl için %30 indirim sunmaktan mutluluk duyarız.</span></p>",
@@ -851,7 +853,7 @@
"csat_name": "Müşteri Memnuniyeti Puanı (CSAT)",
"csat_question_10_headline": "Başka yorumunuz, sorunuz veya endişeniz var mı?",
"csat_question_10_placeholder": "Cevabınızı buraya yazın…",
"csat_question_1_headline": "Bu $[projectName] ürününü bir arkadaşınıza veya iş arkadaşınıza tavsiye etme olasılığınız nedir?",
"csat_question_1_headline": "Bu $[workspaceName] ürününü bir arkadaşına veya meslektaşına tavsiye etme olasılığın ne kadar?",
"csat_question_1_lower_label": "Olası değil",
"csat_question_1_upper_label": "Çok olası",
"csat_question_2_choice_1": "Oldukça memnun",
@@ -859,7 +861,7 @@
"csat_question_2_choice_3": "Ne memnun ne de memnuniyetsiz",
"csat_question_2_choice_4": "Biraz memnuniyetsiz",
"csat_question_2_choice_5": "Çok memnuniyetsiz",
"csat_question_2_headline": "Genel olarak $[projectName] ürünümüzden ne kadar memnun veya memnuniyetsizsiniz?",
"csat_question_2_headline": "Genel olarak $[workspaceName] ürünümüzden ne kadar memnunsunuz veya memnun değilsiniz?",
"csat_question_2_subheader": "Lütfen birini seçin:",
"csat_question_3_choice_1": "Etkisiz",
"csat_question_3_choice_10": "Benzersiz",
@@ -871,28 +873,28 @@
"csat_question_3_choice_7": "Paraya iyi değer",
"csat_question_3_choice_8": "Düşük kalite",
"csat_question_3_choice_9": "Güvenilmez",
"csat_question_3_headline": "$[projectName] ürünümüzü tanımlamak için aşağıdaki kelimelerden hangisini kullanırsınız?",
"csat_question_3_headline": "$[workspaceName] ürünümüzü tanımlamak için aşağıdaki kelimelerden hangilerini kullanırsınız?",
"csat_question_3_subheader": "Lütfen geçerli olanların tümünü seçin:",
"csat_question_4_choice_1": "Son derece iyi",
"csat_question_4_choice_2": "Çok iyi",
"csat_question_4_choice_3": "Oldukça iyi",
"csat_question_4_choice_4": "Pek iyi değil",
"csat_question_4_choice_5": "Hiç iyi değil",
"csat_question_4_headline": "$[projectName] ürünlerimiz ihtiyaçlarınızı ne kadar karşılıyor?",
"csat_question_4_headline": "$[workspaceName] ürünümüz ihtiyaçlarınızı ne kadar karşılıyor?",
"csat_question_4_subheader": "Bir seçenek seçin:",
"csat_question_5_choice_1": "Çok yüksek kalite",
"csat_question_5_choice_2": "Yüksek kalite",
"csat_question_5_choice_3": "Düşük kalite",
"csat_question_5_choice_4": "Çok düşük kalite",
"csat_question_5_choice_5": "Ne yüksek ne düşük",
"csat_question_5_headline": "$[projectName] ürününün kalitesini nasıl değerlendirirsiniz?",
"csat_question_5_headline": "$[workspaceName] ürününün kalitesini nasıl değerlendirirsiniz?",
"csat_question_5_subheader": "Bir seçenek seçin:",
"csat_question_6_choice_1": "Mükemmel",
"csat_question_6_choice_2": "Ortalamanın üstünde",
"csat_question_6_choice_3": "Ortalama",
"csat_question_6_choice_4": "Ortalamanın altında",
"csat_question_6_choice_5": "Zayıf",
"csat_question_6_headline": "$[projectName] ürününün fiyat/performans oranını nasıl değerlendirirsiniz?",
"csat_question_6_headline": "$[workspaceName] ürününün fiyat-performans değerini nasıl değerlendirirsiniz?",
"csat_question_6_subheader": "Lütfen birini seçin:",
"csat_question_7_choice_1": "Son derece duyarlı",
"csat_question_7_choice_2": "Çok duyarlı",
@@ -906,17 +908,17 @@
"csat_question_8_choice_3": "Altı aydan bir yıla kadar",
"csat_question_8_choice_4": "1-2 yıl",
"csat_question_8_choice_5": "3 veya daha fazla yıl",
"csat_question_8_headline": "Ne kadar süredir $[projectName] müşterisisiniz?",
"csat_question_8_headline": "Ne kadar süredir $[workspaceName] müşterisisiniz?",
"csat_question_8_subheader": "Lütfen birini seçin:",
"csat_question_9_choice_1": "Son derece olası",
"csat_question_9_choice_2": "Çok olası",
"csat_question_9_choice_3": "Biraz olası",
"csat_question_9_choice_4": "Pek olası değil",
"csat_question_9_choice_5": "Hiç olası değil",
"csat_question_9_headline": "$[projectName] ürünlerimizden herhangi birini tekrar satın alma olasılığınız nedir?",
"csat_question_9_headline": "$[workspaceName] ürünlerimizden herhangi birini tekrar satın alma olasılığınız nedir?",
"csat_question_9_subheader": "Bir seçenek seçin:",
"csat_survey_name": "$[projectName] CSAT",
"csat_survey_question_1_headline": "$[projectName] deneyiminizden ne kadar memnunsunuz?",
"csat_survey_name": "$[workspaceName] CSAT",
"csat_survey_question_1_headline": "$[workspaceName] deneyiminizden ne kadar memnunsunuz?",
"csat_survey_question_1_lower_label": "Son derece memnuniyetsiz",
"csat_survey_question_1_upper_label": "Son derece memnun",
"csat_survey_question_2_headline": "Harika! Deneyiminizi iyileştirmek için yapabileceğimiz bir şey var mı?",
@@ -931,7 +933,7 @@
"custom_survey_question_1_placeholder": "Cevabınızı buraya yazın…",
"customer_effort_score_description": "Bir özelliğin kullanım kolaylığını belirleyin.",
"customer_effort_score_name": "Müşteri Çaba Puanı (CES)",
"customer_effort_score_question_1_headline": "$[projectName] benim için [HEDEF EKLE] işlemini kolaylaştırıyor",
"customer_effort_score_question_1_headline": "$[workspaceName], [HEDEF EKLE] konusunda işimi kolaylaştırıyor",
"customer_effort_score_question_1_lower_label": "Kesinlikle katılmıyorum",
"customer_effort_score_question_1_upper_label": "Kesinlikle katılıyorum",
"customer_effort_score_question_2_headline": "Teşekkürler! [HEDEF EKLE] işlemini sizin için nasıl kolaylaştırabiliriz?",
@@ -955,14 +957,14 @@
"earned_advocacy_score_name": "Kazanılmış Savunuculuk Puanı (EAS)",
"earned_advocacy_score_question_1_choice_1": "Evet",
"earned_advocacy_score_question_1_choice_2": "Hayır",
"earned_advocacy_score_question_1_headline": "$[projectName] ürününü başkalarına aktif olarak tavsiye ettiniz mi?",
"earned_advocacy_score_question_1_headline": "$[workspaceName] ürününü başkalarına aktif olarak tavsiye ettiniz mi?",
"earned_advocacy_score_question_2_headline": "Bizi neden tavsiye ettiniz?",
"earned_advocacy_score_question_2_placeholder": "Cevabınızı buraya yazın…",
"earned_advocacy_score_question_3_headline": "Çok üzücü. Neden tavsiye etmediniz?",
"earned_advocacy_score_question_3_placeholder": "Cevabınızı buraya yazın…",
"earned_advocacy_score_question_4_choice_1": "Evet",
"earned_advocacy_score_question_4_choice_2": "Hayır",
"earned_advocacy_score_question_4_headline": "Başkalarını $[projectName] tercih etmekten aktif olarak caydırdınız mı?",
"earned_advocacy_score_question_4_headline": "Başkalarını $[workspaceName] ürününü seçmekten aktif olarak caydırdınız mı?",
"earned_advocacy_score_question_5_headline": "Onları caydırmanıza ne sebep oldu?",
"earned_advocacy_score_question_5_placeholder": "Cevabınızı buraya yazın…",
"employee_satisfaction_description": "Çalışan memnuniyetini ölçün ve iyileştirme alanlarını belirleyin.",
@@ -1011,7 +1013,7 @@
"evaluate_a_product_idea_description": "Kullanıcılara ürün veya özellik fikirleri hakkında survey yapın. Hızlıca geri bildirim alın.",
"evaluate_a_product_idea_name": "Ürün Fikrini Değerlendirin",
"evaluate_a_product_idea_question_1_button_label": "Haydi başlayalım!",
"evaluate_a_product_idea_question_1_headline": "$[projectName] ürününü nasıl kullandığınızı çok beğeniyoruz! Bir özellik fikri hakkında düşüncelerinizi almak istiyoruz. Bir dakikanız var mı?",
"evaluate_a_product_idea_question_1_headline": "$[workspaceName] ürününü nasıl kullandığını çok beğeniyoruz! Bir özellik fikri hakkında fikrini almak isteriz. Bir dakikan var mı?",
"evaluate_a_product_idea_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Zamanınıza saygı duyuyoruz ve kısa tuttuk 🤸</span></p>",
"evaluate_a_product_idea_question_2_headline": "Teşekkürler! Bugün [PROBLEM ALANI] konusunda ne kadar zor veya kolay?",
"evaluate_a_product_idea_question_2_lower_label": "Çok zor",
@@ -1093,13 +1095,13 @@
"identify_customer_goals_question_1_choice_2": "Üst satış fırsatlarını belirlemek",
"identify_customer_goals_question_1_choice_3": "Mümkün olan en iyi ürünü geliştirmek",
"identify_customer_goals_question_1_choice_4": "Dünyaya hükmedip herkese kahvaltıda brüksel lahanası yedirmek",
"identify_customer_goals_question_1_headline": "$[projectName] kullanmanın birincil hedefin ne?",
"identify_customer_goals_question_1_headline": "$[workspaceName] ürününü kullanmaktaki birincil hedefin nedir?",
"identify_sign_up_barriers_description": "Kayıt engelleri hakkında bilgi toplamak için indirim sunun.",
"identify_sign_up_barriers_name": "Kayıt Engellerini Belirleyin",
"identify_sign_up_barriers_question_1_button_label": "Yüzde 10 indirim alın",
"identify_sign_up_barriers_question_1_headline": "Bu kısa anketi yanıtlayın, %10 indirim kazanın!",
"identify_sign_up_barriers_question_1_html": "Kayıt olmayı düşünüyor gibisiniz. Dört soruyu yanıtlayın ve herhangi bir planda %10 indirim kazanın.",
"identify_sign_up_barriers_question_2_headline": "$[projectName] için kayıt olma olasılığınız ne kadar?",
"identify_sign_up_barriers_question_2_headline": "$[workspaceName] ürününe kaydolma olasılığın ne kadar?",
"identify_sign_up_barriers_question_2_lower_label": "Hiç olası değil",
"identify_sign_up_barriers_question_2_upper_label": "Çok olası",
"identify_sign_up_barriers_question_3_choice_1_label": "Aradığım şeye sahip olmayabilir",
@@ -1107,8 +1109,8 @@
"identify_sign_up_barriers_question_3_choice_3_label": "Karmaşık görünüyor",
"identify_sign_up_barriers_question_3_choice_4_label": "Fiyatlandırma endişe verici",
"identify_sign_up_barriers_question_3_choice_5_label": "Başka bir şey",
"identify_sign_up_barriers_question_3_headline": "$[projectName] ürününü denemenizi engelleyen nedir?",
"identify_sign_up_barriers_question_4_headline": "Neye ihtiyacınız var ama $[projectName] sunmuyor?",
"identify_sign_up_barriers_question_3_headline": "$[workspaceName] ürününü denemekten seni alıkoyan nedir?",
"identify_sign_up_barriers_question_4_headline": "İhtiyacın olup da $[workspaceName] ürününün sunmadığı nedir?",
"identify_sign_up_barriers_question_4_placeholder": "Cevabınızı buraya yazın…",
"identify_sign_up_barriers_question_5_headline": "Hangi seçenekleri değerlendiriyorsunuz?",
"identify_sign_up_barriers_question_5_placeholder": "Cevabınızı buraya yazın…",
@@ -1127,7 +1129,7 @@
"identify_upsell_opportunities_question_1_choice_2": "1-2 saat",
"identify_upsell_opportunities_question_1_choice_3": "3-5 saat",
"identify_upsell_opportunities_question_1_choice_4": "5+ saat",
"identify_upsell_opportunities_question_1_headline": "Ekibiniz $[projectName] kullanarak haftada kaç saat tasarruf ediyor?",
"identify_upsell_opportunities_question_1_headline": "Ekibiniz $[workspaceName] kullanarak haftada kaç saat tasarruf ediyor?",
"improve_activation_rate_description": "Kullanıcı aktivasyonunu artırmak için başlangıç akışınızdaki zayıf noktaları belirleyin.",
"improve_activation_rate_name": "Aktivasyon Oranını İyileştirin",
"improve_activation_rate_question_1_choice_1": "Bana faydalı görünmedi",
@@ -1135,10 +1137,10 @@
"improve_activation_rate_question_1_choice_3": "Özellik/işlevsellik eksikti",
"improve_activation_rate_question_1_choice_4": "Henüz vakit bulamadım",
"improve_activation_rate_question_1_choice_5": "Başka bir şey",
"improve_activation_rate_question_1_headline": "$[projectName] kurulumunu tamamlamamanızın ana nedeni nedir?",
"improve_activation_rate_question_2_headline": "$[projectName] ürününün faydalı olmayacağını düşünmenize ne sebep oldu?",
"improve_activation_rate_question_1_headline": "$[workspaceName] kurulumunu tamamlamamanın ana nedeni nedir?",
"improve_activation_rate_question_2_headline": "$[workspaceName]'in faydalı olmayacağını düşünmenize ne sebep oldu?",
"improve_activation_rate_question_2_placeholder": "Cevabınızı buraya yazın…",
"improve_activation_rate_question_3_headline": "$[projectName] kurulumunda veya kullanımında zor olan neydi?",
"improve_activation_rate_question_3_headline": "$[workspaceName]'i kurarken veya kullanırken neyi zor buldunuz?",
"improve_activation_rate_question_3_placeholder": "Cevabınızı buraya yazın…",
"improve_activation_rate_question_4_headline": "Hangi özellikler veya işlevler eksikti?",
"improve_activation_rate_question_4_placeholder": "Cevabınızı buraya yazın…",
@@ -1167,9 +1169,9 @@
"improve_trial_conversion_question_1_headline": "Deneme sürümünü neden bıraktınız?",
"improve_trial_conversion_question_1_subheader": "Sizi daha iyi anlamamıza yardımcı olun:",
"improve_trial_conversion_question_2_button_label": "Sonraki",
"improve_trial_conversion_question_2_headline": "Üzgünüz. $[projectName] kullanırken en büyük sorun neydi?",
"improve_trial_conversion_question_2_headline": "Bunu duyduğumuza üzüldük. $[workspaceName] kullanırken karşılaştığınız en büyük sorun neydi?",
"improve_trial_conversion_question_3_button_label": "İleri",
"improve_trial_conversion_question_3_headline": "$[projectName]'in ne yapmasını bekliyordun?",
"improve_trial_conversion_question_3_headline": "$[workspaceName]'in ne yapmasını bekliyordunuz?",
"improve_trial_conversion_question_4_button_label": "Yüzde 20 indirim alın",
"improve_trial_conversion_question_4_headline": "Üzgünüz! İlk yıl %20 indirim kazanın.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Yıllık planda size %20 indirim sunmaktan mutluluk duyarız.</span></p>",
@@ -1185,7 +1187,7 @@
"integration_setup_survey_question_1_upper_label": "Çok kolay",
"integration_setup_survey_question_2_headline": "Neden zordu?",
"integration_setup_survey_question_2_placeholder": "Cevabınızı buraya yazın…",
"integration_setup_survey_question_3_headline": "$[projectName] ile birlikte hangi araçları kullanmak istersiniz?",
"integration_setup_survey_question_3_headline": "$[workspaceName] ile başka hangi araçları kullanmak istersiniz?",
"integration_setup_survey_question_3_subheader": "Entegrasyonlar geliştirmeye devam ediyoruz, sıradaki sizinki olabilir:",
"interview_prompt_description": "Belirli bir kullanıcı grubunu ürün ekibinizle görüşme yapmaya davet edin.",
"interview_prompt_name": "Görüşme Daveti",
@@ -1196,23 +1198,23 @@
"long_term_retention_check_in_name": "Uzun Vadeli Elde Tutma Kontrolü",
"long_term_retention_check_in_question_10_headline": "Ek geri bildiriminiz veya yorumunuz var mı?",
"long_term_retention_check_in_question_10_placeholder": "İyileştirmemize yardımcı olabilecek düşüncelerinizi paylaşın...",
"long_term_retention_check_in_question_1_headline": "Genel olarak $[projectName] ürününden ne kadar memnunsunuz?",
"long_term_retention_check_in_question_1_headline": "$[workspaceName]'den genel olarak ne kadar memnunsunuz?",
"long_term_retention_check_in_question_1_lower_label": "Memnun değil",
"long_term_retention_check_in_question_1_upper_label": "Çok memnun",
"long_term_retention_check_in_question_2_headline": "$[projectName] ürününde en değerli bulduğunuz şey nedir?",
"long_term_retention_check_in_question_2_headline": "$[workspaceName]'in en değerli bulduğunuz yanı nedir?",
"long_term_retention_check_in_question_2_placeholder": "En çok değer verdiğiniz özellik veya faydayı açıklayın...",
"long_term_retention_check_in_question_3_choice_1": "Özellikler",
"long_term_retention_check_in_question_3_choice_2": "Müşteri desteği",
"long_term_retention_check_in_question_3_choice_3": "Kullanıcı deneyimi",
"long_term_retention_check_in_question_3_choice_4": "Fiyatlandırma",
"long_term_retention_check_in_question_3_choice_5": "Güvenilirlik ve çalışma süresi",
"long_term_retention_check_in_question_3_headline": "$[projectName] ürününün deneyiminiz için en temel yönü hangisi?",
"long_term_retention_check_in_question_4_headline": "$[projectName] beklentilerinizi ne kadar karşılıyor?",
"long_term_retention_check_in_question_3_headline": "$[workspaceName]'in deneyiminiz için en önemli bulduğunuz yönü hangisi?",
"long_term_retention_check_in_question_4_headline": "$[workspaceName] beklentilerinizi ne kadar karşılıyor?",
"long_term_retention_check_in_question_4_lower_label": "Beklentilerin altında",
"long_term_retention_check_in_question_4_upper_label": "Beklentilerin üstünde",
"long_term_retention_check_in_question_5_headline": "$[projectName] kullanırken hangi zorluklarla veya hayal kırıklıklarıyla karşılaştınız?",
"long_term_retention_check_in_question_5_headline": "$[workspaceName]'i kullanırken hangi zorluklar veya hayal kırıklıklarıyla karşılaştınız?",
"long_term_retention_check_in_question_5_placeholder": "Karşılaştığınız zorlukları veya görmek istediğiniz iyileştirmeleri açıklayın...",
"long_term_retention_check_in_question_6_headline": "$[projectName] ürününü bir arkadaşınıza veya meslektaşınıza tavsiye etme olasılığınız nedir?",
"long_term_retention_check_in_question_6_headline": "$[workspaceName]'i bir arkadaşınıza veya iş arkadaşınıza tavsiye etme olasılığınız nedir?",
"long_term_retention_check_in_question_6_lower_label": "Olası değil",
"long_term_retention_check_in_question_6_upper_label": "Çok olası",
"long_term_retention_check_in_question_7_choice_1": "Yeni özellikler ve iyileştirmeler",
@@ -1221,7 +1223,7 @@
"long_term_retention_check_in_question_7_choice_4": "Daha fazla entegrasyon",
"long_term_retention_check_in_question_7_choice_5": "Kullanıcı deneyimi iyileştirmeleri",
"long_term_retention_check_in_question_7_headline": "Uzun vadeli kullanıcı olarak kalma olasılığınızı ne artırır?",
"long_term_retention_check_in_question_8_headline": "$[projectName] hakkında bir şeyi değiştirebilseydiniz, ne olurdu?",
"long_term_retention_check_in_question_8_headline": "$[workspaceName] hakkında bir şeyi değiştirebilseydiniz, bu ne olurdu?",
"long_term_retention_check_in_question_8_placeholder": "Dikkate almamızı istediğiniz değişiklik veya özellikleri paylaşın...",
"long_term_retention_check_in_question_9_headline": "Ürün güncellemelerimizden ve sıklığından ne kadar memnunsunuz?",
"long_term_retention_check_in_question_9_lower_label": "Memnun değilim",
@@ -1240,8 +1242,8 @@
"market_site_clarity_question_1_choice_1": "Evet, kesinlikle",
"market_site_clarity_question_1_choice_2": "Biraz…",
"market_site_clarity_question_1_choice_3": "Hayır, hiç değil",
"market_site_clarity_question_1_headline": "$[projectName] ürününü denemek için ihtiyacınız olan tüm bilgilere sahip misiniz?",
"market_site_clarity_question_2_headline": "$[projectName] hakkında eksik veya belirsiz olan nedir?",
"market_site_clarity_question_1_headline": "$[workspaceName]'i denemek için ihtiyacınız olan tüm bilgilere sahip misiniz?",
"market_site_clarity_question_2_headline": "$[workspaceName] hakkında size eksik veya belirsiz gelen nedir?",
"market_site_clarity_question_3_button_label": "İndirim alın",
"market_site_clarity_question_3_headline": "Yanıtınız için teşekkürler! İlk 6 ayda %25 indirim kazanın:",
"matrix": "Matris",
@@ -1286,12 +1288,12 @@
"nps_description": "Net Tavsiye Skorunu (0-10) ölçün",
"nps_lower_label": "Hiç olası değil",
"nps_name": "Net Tavsiye Skoru (NPS)",
"nps_question_1_headline": "$[projectName] ürününü bir arkadaşınıza veya meslektaşınıza tavsiye etme olasılığınız nedir?",
"nps_question_1_headline": "$[workspaceName]'i bir arkadaşınıza veya iş arkadaşınıza tavsiye etme olasılığınız nedir?",
"nps_question_1_lower_label": "Olası değil",
"nps_question_1_upper_label": "Çok olası",
"nps_question_2_headline": "Bu puanı vermenize ne sebep oldu?",
"nps_survey_name": "NPS Anketi",
"nps_survey_question_1_headline": "$[projectName] ürününü bir arkadaşınıza veya meslektaşınıza tavsiye etme olasılığınız nedir?",
"nps_survey_question_1_headline": "$[workspaceName]'i bir arkadaşınıza veya iş arkadaşınıza tavsiye etme olasılığınız nedir?",
"nps_survey_question_1_lower_label": "Hiç olası değil",
"nps_survey_question_1_upper_label": "Son derece olası",
"nps_survey_question_2_headline": "Gelişmemize yardımcı olmak için puanlama nedeninizi açıklar mısınız?",
@@ -1325,7 +1327,7 @@
"preview_survey_ending_card_description": "Lütfen başlangıç sürecinize devam edin.",
"preview_survey_ending_card_headline": "Başardınız!",
"preview_survey_name": "Yeni Anket",
"preview_survey_question_1_headline": "{projectName} ürününü nasıl değerlendirirsiniz?",
"preview_survey_question_1_headline": "{workspaceName}'i nasıl değerlendirirsiniz?",
"preview_survey_question_1_lower_label": "İyi değil",
"preview_survey_question_1_subheader": "Bu bir anket önizlemesidir.",
"preview_survey_question_1_upper_label": "Çok iyi",
@@ -1349,16 +1351,16 @@
"prioritize_features_question_2_choice_2": "Özellik 2",
"prioritize_features_question_2_choice_3": "Özellik 3",
"prioritize_features_question_2_headline": "Bu özelliklerden hangisi sizin için EN AZ DEĞERLİ olurdu?",
"prioritize_features_question_3_headline": "$[projectName] deneyiminizi başka nasıl iyileştirebiliriz?",
"prioritize_features_question_3_headline": "$[workspaceName] deneyiminizi geliştirmek için başka ne yapabiliriz?",
"prioritize_features_question_3_placeholder": "Cevabınızı buraya yazın…",
"product_market_fit_short_description": "Ürününüz ortadan kalksa kullanıcıların ne kadar hayal kırıklığına uğrayacağını değerlendirerek ÜPU ölçün.",
"product_market_fit_short_name": "Ürün Pazar Uyumu Anketi (Kısa)",
"product_market_fit_short_question_1_choice_1": "Hiç hayal kırıklığına uğramamış",
"product_market_fit_short_question_1_choice_2": "Biraz hayal kırıklığına uğramış",
"product_market_fit_short_question_1_choice_3": "Çok hayal kırıklığına uğramış",
"product_market_fit_short_question_1_headline": "$[projectName] ürününü artık kullanamasanız ne kadar hayal kırıklığına uğrarsınız?",
"product_market_fit_short_question_1_headline": "$[workspaceName]'i artık kullanamıyor olsaydınız ne kadar hayal kırıklığına uğrardınız?",
"product_market_fit_short_question_1_subheader": "Lütfen aşağıdaki seçeneklerden birini seçin:",
"product_market_fit_short_question_2_headline": "$[projectName] ürününü sizin için nasıl geliştirebiliriz?",
"product_market_fit_short_question_2_headline": "$[workspaceName]'i sizin için nasıl geliştirebiliriz?",
"product_market_fit_short_question_2_subheader": "Lütfen mümkün olduğunca detaylı olun.",
"product_market_fit_superhuman": "Ürün Pazar Uyumu (Superhuman)",
"product_market_fit_superhuman_description": "Ürününüz ortadan kalksa kullanıcıların ne kadar hayal kırıklığına uğrayacağını değerlendirerek ÜPU ölçün.",
@@ -1368,7 +1370,7 @@
"product_market_fit_superhuman_question_2_choice_1": "Hiç hayal kırıklığına uğramamış",
"product_market_fit_superhuman_question_2_choice_2": "Biraz hayal kırıklığına uğramış",
"product_market_fit_superhuman_question_2_choice_3": "Çok hayal kırıklığına uğramış",
"product_market_fit_superhuman_question_2_headline": "$[projectName] ürününü artık kullanamasanız ne kadar hayal kırıklığına uğrarsınız?",
"product_market_fit_superhuman_question_2_headline": "$[workspaceName]'i artık kullanamıyor olsaydınız ne kadar hayal kırıklığına uğrardınız?",
"product_market_fit_superhuman_question_2_subheader": "Lütfen aşağıdaki seçeneklerden birini seçin:",
"product_market_fit_superhuman_question_3_choice_1": "Kurucu",
"product_market_fit_superhuman_question_3_choice_2": "Yönetici",
@@ -1377,9 +1379,9 @@
"product_market_fit_superhuman_question_3_choice_5": "Yazılım Mühendisi",
"product_market_fit_superhuman_question_3_headline": "Rolünüz nedir?",
"product_market_fit_superhuman_question_3_subheader": "Lütfen aşağıdaki seçeneklerden birini seçin:",
"product_market_fit_superhuman_question_4_headline": "Sizce $[projectName] ürününden en çok hangi tür insanlar fayda sağlar?",
"product_market_fit_superhuman_question_5_headline": "$[projectName] ürününden aldığınız temel fayda nedir?",
"product_market_fit_superhuman_question_6_headline": "$[projectName] ürününü sizin için nasıl geliştirebiliriz?",
"product_market_fit_superhuman_question_4_headline": "$[workspaceName]'den en çok hangi tür insanların faydalanacağını düşünüyorsunuz?",
"product_market_fit_superhuman_question_5_headline": "$[workspaceName]'den elde ettiğiniz ana fayda nedir?",
"product_market_fit_superhuman_question_6_headline": "$[workspaceName] uygulamasını sizin için nasıl geliştirebiliriz?",
"product_market_fit_superhuman_question_6_subheader": "Lütfen mümkün olduğunca detaylı olun.",
"professional_development_growth_survey_description": "Çalışanların profesyonel gelişim ve büyüme fırsatlarından memnuniyetini değerlendirin.",
"professional_development_growth_survey_name": "Profesyonel Gelişim ve Büyüme Anketi",
@@ -1450,7 +1452,7 @@
"recognition_and_reward_survey_question_4_placeholder": "Cevabınızı buraya yazın…",
"review_prompt_description": "Ürününüzü seven kullanıcıları herkese açık değerlendirme yazmaya davet edin.",
"review_prompt_name": "Değerlendirme Daveti",
"review_prompt_question_1_headline": "$[projectName] ürününü nasıl buluyorsunuz?",
"review_prompt_question_1_headline": "$[workspaceName] hakkında ne düşünüyorsunuz?",
"review_prompt_question_1_lower_label": "İyi değil",
"review_prompt_question_1_upper_label": "Çok memnun",
"review_prompt_question_2_button_label": "Yorum yaz",
@@ -1493,7 +1495,7 @@
"site_abandonment_survey_question_8_headline": "Lütfen email adresinizi paylaşın:",
"site_abandonment_survey_question_9_headline": "Ek yorumlarınız veya önerileriniz var mı?",
"smileys_survey_name": "Gülen Yüz Anketi",
"smileys_survey_question_1_headline": "$[projectName] ürününü nasıl buluyorsunuz?",
"smileys_survey_question_1_headline": "$[workspaceName] hakkında ne düşünüyorsunuz?",
"smileys_survey_question_1_lower_label": "İyi değil",
"smileys_survey_question_1_upper_label": "Çok memnun",
"smileys_survey_question_2_button_label": "Yorum yaz",
@@ -1503,8 +1505,8 @@
"smileys_survey_question_3_headline": "Üzgünüz! Daha iyi yapabileceğimiz BİR şey nedir?",
"smileys_survey_question_3_placeholder": "Cevabınızı buraya yazın…",
"smileys_survey_question_3_subheader": "Deneyiminizi iyileştirmemize yardımcı olun.",
"star_rating_survey_name": "$[projectName] Puanlama Anketi",
"star_rating_survey_question_1_headline": "$[projectName] ürününü nasıl buluyorsunuz?",
"star_rating_survey_name": "$[workspaceName] Değerlendirme Anketi",
"star_rating_survey_question_1_headline": "$[workspaceName] hakkında ne düşünüyorsunuz?",
"star_rating_survey_question_1_lower_label": "Son derece memnuniyetsiz",
"star_rating_survey_question_1_upper_label": "Son derece memnun",
"star_rating_survey_question_2_button_label": "Yorum yaz",
@@ -1537,7 +1539,7 @@
"uncover_strengths_and_weaknesses_question_1_choice_3": "Açık kaynak olması",
"uncover_strengths_and_weaknesses_question_1_choice_4": "Kurucular çok sempatik",
"uncover_strengths_and_weaknesses_question_1_choice_5": "Diğer",
"uncover_strengths_and_weaknesses_question_1_headline": "$[projectName] ürününde en çok neye değer veriyorsunuz?",
"uncover_strengths_and_weaknesses_question_1_headline": "$[workspaceName] uygulamasında en çok neye değer veriyorsunuz?",
"uncover_strengths_and_weaknesses_question_2_choice_1": "Dokümantasyon",
"uncover_strengths_and_weaknesses_question_2_choice_2": "Özelleştirilebilirlik",
"uncover_strengths_and_weaknesses_question_2_choice_3": "Fiyatlandırma",
@@ -1553,8 +1555,8 @@
"understand_low_engagement_question_1_choice_3": "Henüz vakit bulamadım",
"understand_low_engagement_question_1_choice_4": "İhtiyacım olan özellikler eksikti",
"understand_low_engagement_question_1_choice_5": "Diğer",
"understand_low_engagement_question_1_headline": "Son zamanlarda $[projectName] ürününe geri dönmemenizin ana nedeni nedir?",
"understand_low_engagement_question_2_headline": "$[projectName] kullanımında zor olan nedir?",
"understand_low_engagement_question_1_headline": "Son zamanlarda $[workspaceName] uygulamasına geri dönmemenizin ana nedeni nedir?",
"understand_low_engagement_question_2_headline": "$[workspaceName] uygulamasını kullanmakta zorluk çektiğiniz şey nedir?",
"understand_low_engagement_question_2_placeholder": "Cevabınızı buraya yazın…",
"understand_low_engagement_question_3_headline": "Anladım. Onun yerine hangi alternatifi kullanıyorsunuz?",
"understand_low_engagement_question_3_placeholder": "Cevabınızı buraya yazın…",
@@ -1724,6 +1726,7 @@
"failed_to_execute_query": "Sorgu çalıştırılamadı",
"failed_to_load_chart": "Grafik yüklenemedi",
"failed_to_load_chart_data": "Grafik verileri yüklenemedi",
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Grafik kaydedilemedi",
"field": "Alan",
"field_label_average_score": "Ortalama Puan",
@@ -1868,6 +1871,9 @@
"app_connection_description": "Uygulamanı veya web siteni Formbricks'e bağla.",
"cache_update_delay_description": "Anketler, kişiler, eylemler veya diğer verilerde güncelleme yaptığında, bu değişikliklerin Formbricks SDK'sını çalıştıran yerel uygulamanda görünmesi 1 dakikaya kadar sürebilir.",
"cache_update_delay_title": "Önbelleğe alma nedeniyle değişiklikler yaklaşık 1 dakika sonra yansıtılacak",
"environment_id_legacy": "Ortam Kimliği (eski)",
"environment_id_legacy_alert": "Mevcut SDK kurulumunuz hala eski bir Ortam Kimliği kullanıyor olabilir.",
"environment_id_legacy_alert_link": "Neden ve nasıl geçiş yapacağınızı öğrenin.",
"formbricks_sdk_connected": "Formbricks SDK bağlandı",
"formbricks_sdk_not_connected": "Formbricks SDK henüz bağlanmadı.",
"formbricks_sdk_not_connected_description": "Web sitenize veya uygulamanıza Formbricks SDK'sını ekleyerek Formbricks ile bağlantı kurun",
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "Bu ada sahip bir geri bildirim kayıt dizini zaten mevcut.",
"error_directory_name_required": "Dizin adı gereklidir.",
"error_directory_workspaces_invalid_org": "Belirtilen çalışma alanlarından bazıları bu organizasyona ait değil.",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "Geri Bildirim Dizinleri",
"no_access": "Geri bildirim kayıt dizinlerini yönetme izniniz yok.",
"no_connectors": "Bu dizine henüz bağlı bağlayıcı yok.",
@@ -3749,9 +3756,16 @@
"source_name": "Kaynak Adı",
"source_type": "Kaynak Türü",
"source_type_cannot_be_changed": "Kaynak türü değiştirilemez",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "Hata",
"status_live_sync": "Live sync",
"status_paused": "Duraklatıldı",
"status_ready": "Ready",
"submission_id": "Gönderim Kimliği",
"survey_has_no_questions": "Bu ankette soru yok",
+26 -12
View File
@@ -148,6 +148,7 @@
"apply_filters": "应用 筛选",
"archived": "已归档",
"are_you_sure": "你 确定 吗?",
"ask": "Ask",
"attributes": "属性",
"back": "返回",
"billing": "账单",
@@ -213,6 +214,7 @@
"delete_what": "删除{deleteWhat}",
"description": "描述",
"disable": "禁用",
"disabled": "已禁用",
"disallow": "不允许",
"discard": "丢弃",
"dismissed": "忽略",
@@ -1724,6 +1726,7 @@
"failed_to_execute_query": "查询执行失败",
"failed_to_load_chart": "加载图表失败",
"failed_to_load_chart_data": "加载图表数据失败",
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "图表保存失败",
"field": "字段",
"field_label_average_score": "平均分",
@@ -1868,6 +1871,9 @@
"app_connection_description": "将您的应用或网站连接到 Formbricks。",
"cache_update_delay_description": "当您更新问卷、联系人、操作或其他数据时,这些更改最多可能需要 1 分钟才能在本地运行 Formbricks SDK 的应用中显示。",
"cache_update_delay_title": "由于缓存,变更将在约 1 分钟后生效",
"environment_id_legacy": "环境 ID(旧版)",
"environment_id_legacy_alert": "您现有的 SDK 设置可能仍在使用旧版环境 ID。",
"environment_id_legacy_alert_link": "了解原因以及如何迁移。",
"formbricks_sdk_connected": "Formbricks SDK 已连接",
"formbricks_sdk_not_connected": "Formbricks SDK 尚未连接。",
"formbricks_sdk_not_connected_description": "将 Formbricks SDK 添加到您的网站或应用,以实现与 Formbricks 的连接。",
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "已存在同名的反馈记录目录。",
"error_directory_name_required": "目录名称为必填项。",
"error_directory_workspaces_invalid_org": "某些指定的工作区不属于此组织。",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "反馈目录",
"no_access": "你没有管理反馈记录目录的权限。",
"no_connectors": "此目录尚未链接任何连接器。",
@@ -2581,13 +2588,13 @@
"cannot_leave_only_organization": "您 不能 离开 此 组织,因为 这是 您 唯一的 组织。请 先 创建一个新的 组织。",
"copy_invite_link_to_clipboard": "复制 invite 链接 到 剪贴板",
"create_new_organization": "创建 新的 组织",
"create_new_organization_description": "创建一个新 组织 来处理不同的 项目 集。",
"create_new_organization_description": "创建一个新组织来管理不同的工作空间集合。",
"customize_email_with_a_higher_plan": "通过更高的计划 自定义电子邮件",
"delete_member_confirmation": "删除的 成员 将无法访问 你的组织 的 所有项目 和 调查。",
"delete_member_confirmation": "删除的成员将失去对你组织中所有工作空间和调查的访问权限。",
"delete_organization": "删除 组织",
"delete_organization_description": "删除 组织 的 所有 项目 包括所有 调查、回、人员、动作 和 属性",
"delete_organization_description": "删除组织及其所有工作空间,包括所有调查、回、人员、操作和属性",
"delete_organization_warning": "在 您 继续 删除 这个 组织 前, 请 注意 以下 后果:",
"delete_organization_warning_1": "永久删除与此组织关联的所有项目。",
"delete_organization_warning_1": "永久删除与此组织关联的所有工作空间。",
"delete_organization_warning_2": "此操作无法撤消。 一旦 消失 就 消失 。",
"delete_organization_warning_3": "请在下列字段中输入 {organizationName} 以确认此组织的最终删除:",
"eliminate_branding_with_whitelabel": "消除 Formbricks 品牌 并启用 额外的 白标 自定义 选项",
@@ -2685,7 +2692,7 @@
"add_workspaces_description": "控制团队成员可以访问哪些工作区。",
"all_members_added": "所有成员已添加到此团队。",
"all_workspaces_added": "该团队已添加所有工作区。",
"are_you_sure_you_want_to_delete_this_team": "您 确定 要 删除 这个 团队 吗?这也会 移除 对 与 这个 团队 相关 的 所有 项目 和 调查 的 访问。",
"are_you_sure_you_want_to_delete_this_team": "你确定要删除这个团队吗?这也会移除对与此团队关联的所有工作空间和调查的访问权限。",
"billing_role_description": "仅 能 访问 账单 信息。",
"bulk_invite": "批量 邀请",
"contributor": "贡献者",
@@ -2701,10 +2708,10 @@
"manage": "管理",
"manage_team": "管理团队",
"manage_team_disabled": "只有 组织 拥有者、经理 和 团队 管理员 可以 管理 团队。",
"manager_role_description": "经理 可以 访问 所有 项目 并 添加 移除 成员。",
"manager_role_description": "管理员可以访问所有工作空间,并可以添加移除成员。",
"member": "成员",
"member_role_description": "成员 可以 在 选定 项目 中 工作。",
"member_role_info_message": "要 给 新 成员 访问 项目 ,请 将 他们 添加 到 下方 的 团队 。通过 团队可以 管理 谁 可以 访问 哪个 项目 。",
"member_role_description": "成员可以在选定的工作空间中工作。",
"member_role_info_message": "要让新成员访问工作空间,请将他们添加到下面的团队。通过团队,你可以管理谁有权访问哪个工作空间。",
"organization_role": "组织角色",
"owner_role_description": "所有者拥有对组织的完全控制权。",
"please_fill_all_member_fields": "请 填写 所有 字段 以 添加 新 成员。",
@@ -2721,8 +2728,8 @@
"team_settings_description": "管理 团队成员、访问 权限 和 更多。",
"team_updated_successfully": "团队 更新 成功",
"teams": "团队",
"teams_description": "将 成员 分配 到 团队 ,并 给 团队 访问 项目 的 权限。",
"unlock_teams_description": "管理 哪些 组织成员 可以 访问 特定 项目 和 调查。",
"teams_description": "将成员分配到团队中,并授予团队访问工作空间的权限。",
"unlock_teams_description": "管理哪些组织成员可以访问特定的工作空间和调查。",
"unlock_teams_title": "通过 更 高级 划解锁 团队",
"upgrade_plan_notice_message": "解锁更多组织角色功能 通过升级计划。",
"you_are_a_member": "你是 会员"
@@ -3068,7 +3075,7 @@
"options_used_in_logic_bulk_error": "以下选项在逻辑中被使用:{questionIndexes}。请先从逻辑中删除它们。",
"override_theme_with_individual_styles_for_this_survey": "使用 个性化 样式 替代 这份 问卷 的 主题。",
"overwrite_global_waiting_time": "自定义冷却期",
"overwrite_global_waiting_time_description": "仅此调查覆盖项目配置。",
"overwrite_global_waiting_time_description": "仅针对此调查覆盖工作空间配置。",
"overwrite_placement": "覆盖 放置",
"overwrite_survey_logo": "设置自定义调查 logo",
"overwrite_the_global_placement_of_the_survey": "覆盖 全局 调查 放置",
@@ -3749,9 +3756,16 @@
"source_name": "来源名称",
"source_type": "来源类型",
"source_type_cannot_be_changed": "来源类型无法更改",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "错误",
"status_live_sync": "Live sync",
"status_paused": "已暂停",
"status_ready": "Ready",
"submission_id": "提交ID",
"survey_has_no_questions": "该调查没有任何问题",
+26 -12
View File
@@ -148,6 +148,7 @@
"apply_filters": "套用篩選器",
"archived": "已封存",
"are_you_sure": "您確定嗎?",
"ask": "Ask",
"attributes": "屬性",
"back": "返回",
"billing": "帳單",
@@ -213,6 +214,7 @@
"delete_what": "刪除{deleteWhat}",
"description": "描述",
"disable": "停用",
"disabled": "已停用",
"disallow": "不允許",
"discard": "捨棄",
"dismissed": "已關閉",
@@ -1724,6 +1726,7 @@
"failed_to_execute_query": "查詢執行失敗",
"failed_to_load_chart": "載入圖表失敗",
"failed_to_load_chart_data": "載入圖表資料失敗",
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "儲存圖表失敗",
"field": "欄位",
"field_label_average_score": "平均分數",
@@ -1868,6 +1871,9 @@
"app_connection_description": "將您的應用程式或網站連接到 Formbricks。",
"cache_update_delay_description": "當您更新問卷、聯絡人、動作或其他資料時,這些變更最多可能需要 1 分鐘才會反映在執行 Formbricks SDK 的本地應用程式中。",
"cache_update_delay_title": "因快取機制,變更約 1 分鐘後才會反映",
"environment_id_legacy": "環境 ID(舊版)",
"environment_id_legacy_alert": "你現有的 SDK 設定可能仍在使用舊版環境 ID。",
"environment_id_legacy_alert_link": "了解原因及遷移方式。",
"formbricks_sdk_connected": "Formbricks SDK 已連線",
"formbricks_sdk_not_connected": "Formbricks SDK 尚未連線。",
"formbricks_sdk_not_connected_description": "將 Formbricks SDK 加入您的網站或應用程式,以連接至 Formbricks",
@@ -2555,6 +2561,7 @@
"error_directory_name_duplicate": "已存在同名的意見回饋記錄目錄。",
"error_directory_name_required": "目錄名稱為必填項目。",
"error_directory_workspaces_invalid_org": "部分指定的工作區不屬於此組織。",
"error_workspace_already_assigned": "One or more workspaces are already linked to a different active directory. Reassign them first.",
"nav_label": "意見回饋目錄",
"no_access": "您沒有權限管理意見回饋記錄目錄。",
"no_connectors": "此目錄尚未連結任何連接器。",
@@ -2581,13 +2588,13 @@
"cannot_leave_only_organization": "您無法離開此組織,因為它是您唯一的組織。請先建立新組織。",
"copy_invite_link_to_clipboard": "將邀請連結複製到剪貼簿",
"create_new_organization": "建立新組織",
"create_new_organization_description": "建立新組織以處理一組不同的專案。",
"create_new_organization_description": "建立新組織來管理不同的工作區集合。",
"customize_email_with_a_higher_plan": "使用更高等級的方案自訂電子郵件",
"delete_member_confirmation": "刪除的成員將失去存取您組織的所有專案和問卷的權限。",
"delete_member_confirmation": "刪除的成員將無法存取您組織的所有工作區和調查問卷。",
"delete_organization": "刪除組織",
"delete_organization_description": "刪除包含所有專案的組織,包括所有問卷、回、人員、操作和屬性",
"delete_organization_description": "刪除組織及其所有工作區,包括所有調查問卷、回、人員、操作和屬性",
"delete_organization_warning": "在您繼續刪除此組織之前,請注意以下後果:",
"delete_organization_warning_1": "永久移除與此組織相關聯的所有專案。",
"delete_organization_warning_1": "永久移除與此組織連結的所有工作區。",
"delete_organization_warning_2": "此操作無法復原。一旦刪除,即永久消失。",
"delete_organization_warning_3": "請在下列欄位中輸入 '{'organizationName'}' 以確認永久刪除此組織:",
"eliminate_branding_with_whitelabel": "消除 Formbricks 品牌並啟用其他白標自訂選項。",
@@ -2685,7 +2692,7 @@
"add_workspaces_description": "控管團隊成員可存取哪些工作區。",
"all_members_added": "所有成員都已新增至此團隊。",
"all_workspaces_added": "所有工作區都已加入此團隊。",
"are_you_sure_you_want_to_delete_this_team": "確定要刪除團隊嗎這也會移除此團隊相關的所有專案和問卷的存取權限。",
"are_you_sure_you_want_to_delete_this_team": "確定要刪除這個團隊嗎?這也會移除此團隊相關的所有工作區和調查問卷的存取權限。",
"billing_role_description": "只能存取帳單資訊。",
"bulk_invite": "大量邀請",
"contributor": "投稿人",
@@ -2701,10 +2708,10 @@
"manage": "管理",
"manage_team": "管理團隊",
"manage_team_disabled": "只有組織擁有者、管理員和團隊管理員才能管理團隊。",
"manager_role_description": "管理可以存取所有專案,並新增移除成員。",
"manager_role_description": "管理可以存取所有工作區,並可新增移除成員。",
"member": "成員",
"member_role_description": "成員可以在選定的專案中工作。",
"member_role_info_message": "若要授予新成員存取專案的權限,請將他們新增至下方的團隊。藉由團隊,您可以管理誰可以存取哪些專案。",
"member_role_description": "成員可以在選定的工作區中工作。",
"member_role_info_message": "若要新成員存取工作區,請在下方將他們加入團隊。透過團隊功能,你可以管理誰存取哪些工作區。",
"organization_role": "組織角色",
"owner_role_description": "擁有者對組織具有完全控制權。",
"please_fill_all_member_fields": "請填寫所有欄位以新增新成員。",
@@ -2721,8 +2728,8 @@
"team_settings_description": "管理團隊成員、存取權限等。",
"team_updated_successfully": "團隊已成功更新",
"teams": "團隊",
"teams_description": "將成員指派到團隊中,並授予團隊存取專案的權限。",
"unlock_teams_description": "管理哪些組織成員可以存取特定專案和問卷。",
"teams_description": "將成員分配到團隊,並授予團隊存取工作區的權限。",
"unlock_teams_description": "管理哪些組織成員可以存取特定的工作區和調查問卷。",
"unlock_teams_title": "使用更高等級的方案解鎖團隊。",
"upgrade_plan_notice_message": "使用更高等級的方案解鎖組織角色。",
"you_are_a_member": "您是成員"
@@ -3068,7 +3075,7 @@
"options_used_in_logic_bulk_error": "以下選項已用於邏輯中:{questionIndexes}。請先從邏輯中移除它們。",
"override_theme_with_individual_styles_for_this_survey": "使用此問卷的個別樣式覆寫主題。",
"overwrite_global_waiting_time": "自訂冷卻期",
"overwrite_global_waiting_time_description": "僅覆蓋此問卷的專案設定。",
"overwrite_global_waiting_time_description": "僅針對此調查問卷覆寫工作區設定。",
"overwrite_placement": "覆寫位置",
"overwrite_survey_logo": "設定自訂問卷標誌",
"overwrite_the_global_placement_of_the_survey": "覆寫問卷的整體位置",
@@ -3749,9 +3756,16 @@
"source_name": "來源名稱",
"source_type": "來源類型",
"source_type_cannot_be_changed": "來源類型無法變更",
"source_type_label_feedback_form": "Feedback form",
"source_type_label_interview": "Interview",
"source_type_label_nps_campaign": "NPS campaign",
"source_type_label_review": "Review",
"source_type_label_social": "Social",
"source_type_label_support": "Support",
"source_type_label_survey": "Survey",
"source_type_label_usability_test": "Usability test",
"status_error": "錯誤",
"status_live_sync": "Live sync",
"status_paused": "已暫停",
"status_ready": "Ready",
"submission_id": "提交ID",
"survey_has_no_questions": "此問卷沒有任何題目",
@@ -224,7 +224,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
// Fetch updated response with relations for pipeline
const updatedResponseForPipeline = await getResponseForPipeline(params.responseId);
if (updatedResponseForPipeline.ok) {
sendToPipeline({
await sendToPipeline({
event: "responseUpdated",
workspaceId: workspaceIdResult.data.workspaceId,
surveyId: existingResponse.data.surveyId,
@@ -232,7 +232,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
});
if (response.data.finished) {
sendToPipeline({
await sendToPipeline({
event: "responseFinished",
workspaceId: workspaceIdResult.data.workspaceId,
surveyId: existingResponse.data.surveyId,
@@ -1,235 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const {
mockAuthenticatedApiClient,
mockCreateResponseWithQuotaEvaluation,
mockCreatedResponse,
mockFormatValidationErrorsForV2Api,
mockGetResponseForPipeline,
mockGetSurveyQuestions,
mockGetWorkspaceId,
mockHandleApiError,
mockHasPermission,
mockSendToPipeline,
mockValidateFileUploads,
mockValidateOtherOptionLengthForMultipleChoice,
mockValidateResponseData,
} = vi.hoisted(() => ({
mockAuthenticatedApiClient: vi.fn(),
mockCreateResponseWithQuotaEvaluation: vi.fn(),
mockCreatedResponse: vi.fn(),
mockFormatValidationErrorsForV2Api: vi.fn(),
mockGetResponseForPipeline: vi.fn(),
mockGetSurveyQuestions: vi.fn(),
mockGetWorkspaceId: vi.fn(),
mockHandleApiError: vi.fn(),
mockHasPermission: vi.fn(),
mockSendToPipeline: vi.fn(),
mockValidateFileUploads: vi.fn(),
mockValidateOtherOptionLengthForMultipleChoice: vi.fn(),
mockValidateResponseData: vi.fn(),
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mockSendToPipeline,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV2Api: mockFormatValidationErrorsForV2Api,
validateResponseData: mockValidateResponseData,
}));
vi.mock("@/modules/api/v2/auth/authenticated-api-client", () => ({
authenticatedApiClient: mockAuthenticatedApiClient,
}));
vi.mock("@/modules/api/v2/lib/element", () => ({
validateOtherOptionLengthForMultipleChoice: mockValidateOtherOptionLengthForMultipleChoice,
}));
vi.mock("@/modules/api/v2/lib/response", () => ({
responses: {
createdResponse: mockCreatedResponse,
successResponse: vi.fn(),
},
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: mockHandleApiError,
}));
vi.mock("@/modules/api/v2/management/lib/helper", () => ({
getWorkspaceId: mockGetWorkspaceId,
}));
vi.mock("@/modules/api/v2/management/responses/[responseId]/lib/survey", () => ({
getSurveyQuestions: mockGetSurveyQuestions,
}));
vi.mock("@/modules/api/v2/management/responses/[responseId]/lib/response", () => ({
getResponseForPipeline: mockGetResponseForPipeline,
}));
vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({
hasPermission: mockHasPermission,
}));
vi.mock("@/modules/storage/utils", () => ({
resolveStorageUrlsInObject: vi.fn((value) => value),
validateFileUploads: mockValidateFileUploads,
}));
vi.mock("./lib/response", () => ({
createResponseWithQuotaEvaluation: mockCreateResponseWithQuotaEvaluation,
getResponses: vi.fn(),
}));
const workspaceId = "cm9workspace000108l4abcz12";
const surveyId = "cm9survey000108l4abcz12zz";
const responseId = "cm9response000108l4abcz12";
const createdAt = new Date("2026-04-13T10:00:00.000Z");
const createdResponse = {
contactAttributes: null,
contactId: null,
createdAt,
data: {},
displayId: null,
endingId: null,
finished: true,
id: responseId,
language: null,
meta: {},
singleUseId: null,
surveyId,
ttc: {},
updatedAt: createdAt,
variables: {},
};
const responseSnapshot = {
contact: null,
contactAttributes: null,
createdAt,
data: {},
displayId: null,
endingId: null,
finished: true,
id: responseId,
language: null,
meta: {},
singleUseId: null,
surveyId,
tags: [],
ttc: {},
updatedAt: createdAt,
variables: {},
};
describe("POST /modules/api/v2/management/responses", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAuthenticatedApiClient.mockImplementation(
async ({ handler }) =>
await handler({
auditLog: undefined,
authentication: {
workspacePermissions: [{ workspaceId, actions: ["POST"] }],
},
parsedInput: {
body: {
data: {},
finished: true,
surveyId,
},
},
})
);
mockGetWorkspaceId.mockResolvedValue({ data: { workspaceId }, ok: true });
mockHasPermission.mockReturnValue(true);
mockGetSurveyQuestions.mockResolvedValue({ data: { blocks: [], questions: [] }, ok: true });
mockValidateFileUploads.mockReturnValue(true);
mockValidateOtherOptionLengthForMultipleChoice.mockReturnValue(undefined);
mockValidateResponseData.mockReturnValue(null);
mockSendToPipeline.mockResolvedValue(undefined);
mockCreateResponseWithQuotaEvaluation.mockResolvedValue({ data: createdResponse, ok: true });
mockGetResponseForPipeline.mockResolvedValue({ data: responseSnapshot, ok: true });
mockCreatedResponse.mockImplementation((body: unknown) => Response.json(body, { status: 201 }));
mockHandleApiError.mockImplementation((_, error) => Response.json({ error }, { status: 400 }));
});
test("passes the freshly hydrated response snapshot to the pipeline", async () => {
const { POST } = await import("./route");
const response = await POST(
new Request("http://localhost/api/v2/management/responses", { method: "POST" })
);
expect(response.status).toBe(201);
expect(mockGetResponseForPipeline).toHaveBeenCalledWith(responseId);
expect(mockSendToPipeline).toHaveBeenNthCalledWith(1, {
event: "responseCreated",
response: responseSnapshot,
surveyId,
workspaceId,
});
expect(mockSendToPipeline).toHaveBeenNthCalledWith(2, {
event: "responseFinished",
response: responseSnapshot,
surveyId,
workspaceId,
});
});
test("returns 201 when loading the pipeline snapshot throws", async () => {
mockGetResponseForPipeline.mockRejectedValueOnce(new Error("snapshot failed"));
const { POST } = await import("./route");
const response = await POST(
new Request("http://localhost/api/v2/management/responses", { method: "POST" })
);
expect(response.status).toBe(201);
expect(mockSendToPipeline).not.toHaveBeenCalled();
});
test("returns 201 when pipeline dispatch rejects", async () => {
mockSendToPipeline.mockRejectedValueOnce(new Error("pipeline failed"));
const { POST } = await import("./route");
const response = await POST(
new Request("http://localhost/api/v2/management/responses", { method: "POST" })
);
await Promise.resolve();
expect(response.status).toBe(201);
expect(mockSendToPipeline).toHaveBeenCalledTimes(2);
});
test("returns the create-response error payload when response creation fails", async () => {
mockCreateResponseWithQuotaEvaluation.mockResolvedValueOnce({
ok: false,
error: {
type: "bad_request",
details: [{ field: "surveyId", issue: "invalid" }],
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://localhost/api/v2/management/responses", { method: "POST" })
);
expect(response.status).toBe(400);
expect(mockHandleApiError).toHaveBeenCalledWith(
expect.any(Request),
{
type: "bad_request",
details: [{ field: "surveyId", issue: "invalid" }],
},
undefined
);
expect(mockSendToPipeline).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,5 @@
import { Response } from "@prisma/client";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { sendToPipeline } from "@/app/lib/pipelines";
import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
@@ -16,31 +15,6 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
const queueResponsePipelineEvent = ({
event,
response,
surveyId,
workspaceId,
}: Parameters<typeof sendToPipeline>[0]): void => {
void sendToPipeline({
event,
response,
surveyId,
workspaceId,
}).catch((error: unknown) => {
logger.error(
{
err: error,
event,
responseId: response.id,
surveyId,
workspaceId,
},
"Failed to send response event to pipeline"
);
});
};
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
@@ -114,14 +88,13 @@ export const POST = async (request: Request) =>
);
}
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
if (body.createdAt && !body.updatedAt) {
body.updatedAt = body.createdAt;
}
const surveyQuestions = await getSurveyQuestions(body.surveyId);
if (!surveyQuestions.ok) {
return handleApiError(request, surveyQuestions.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
return handleApiError(request, surveyQuestions.error as ApiErrorResponseV2, auditLog); // NOSONAR
}
if (!validateFileUploads(body.data, surveyQuestions.data.questions)) {
@@ -135,7 +108,6 @@ export const POST = async (request: Request) =>
);
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: body.data,
surveyQuestions: surveyQuestions.data.questions,
@@ -157,7 +129,6 @@ export const POST = async (request: Request) =>
});
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
surveyQuestions.data.blocks,
body.data,
@@ -181,37 +152,27 @@ export const POST = async (request: Request) =>
return handleApiError(request, createResponseResult.error, auditLog);
}
// Fetch created response with relations for pipeline
try {
const createdResponseForPipeline = await getResponseForPipeline(createResponseResult.data.id);
if (createdResponseForPipeline.ok) {
queueResponsePipelineEvent({
event: "responseCreated",
workspaceId,
surveyId: body.surveyId,
response: createdResponseForPipeline.data,
});
if (createResponseResult.data.finished) {
queueResponsePipelineEvent({
event: "responseFinished",
getResponseForPipeline(createResponseResult.data.id)
.then((createdResponseForPipeline) => {
if (createdResponseForPipeline.ok) {
sendToPipeline({
event: "responseCreated",
workspaceId,
surveyId: body.surveyId,
response: createdResponseForPipeline.data,
});
}).catch(() => {});
if (createResponseResult.data.finished) {
sendToPipeline({
event: "responseFinished",
workspaceId,
surveyId: body.surveyId,
response: createdResponseForPipeline.data,
}).catch(() => {});
}
}
}
} catch (error) {
logger.error(
{
err: error,
responseId: createResponseResult.data.id,
surveyId: body.surveyId,
workspaceId,
},
"Failed to load response data for pipeline dispatch"
);
}
})
.catch(() => {});
if (auditLog) {
auditLog.targetId = createResponseResult.data.id;
@@ -213,7 +213,6 @@ export const getChartsAction = authenticatedActionClient
const ZExecuteQueryAction = z.object({
workspaceId: ZId,
query: ZChartQuery,
feedbackRecordDirectoryId: ZId,
});
export const executeQueryAction = authenticatedActionClient
@@ -230,7 +229,7 @@ export const executeQueryAction = authenticatedActionClient
validateQueryMembers(parsedInput.query);
const scopedQuery = injectTenantFilter(parsedInput.query, parsedInput.feedbackRecordDirectoryId);
const scopedQuery = injectTenantFilter(parsedInput.query, parsedInput.workspaceId);
try {
return await executeQuery(scopedQuery as Record<string, unknown>);
@@ -280,7 +279,6 @@ const ZGenerateAIQueryResponse = z.object({
const ZGenerateAIChartAction = z.object({
workspaceId: ZId,
prompt: z.string().min(1).max(2000),
feedbackRecordDirectoryId: ZId,
});
export const generateAIChartAction = authenticatedActionClient
@@ -335,10 +333,7 @@ export const generateAIChartAction = authenticatedActionClient
validateQueryMembers(cleanQuery as TChartQuery);
const scopedQuery = injectTenantFilter(
cleanQuery as TChartQuery,
parsedInput.feedbackRecordDirectoryId
);
const scopedQuery = injectTenantFilter(cleanQuery as TChartQuery, parsedInput.workspaceId);
const data = await executeQuery(scopedQuery as Record<string, unknown>);
@@ -30,7 +30,6 @@ interface AdvancedChartBuilderProps {
initialQuery?: TChartQuery;
hidePreview?: boolean;
onChartGenerated?: (data: AnalyticsResponse) => void;
feedbackRecordDirectoryId: string | null;
runQueryCtaLabel?: string;
}
@@ -84,7 +83,6 @@ export function AdvancedChartBuilder({
initialQuery,
hidePreview = false,
onChartGenerated,
feedbackRecordDirectoryId,
runQueryCtaLabel,
}: Readonly<AdvancedChartBuilderProps>) {
const { t } = useTranslation();
@@ -95,11 +93,7 @@ export function AdvancedChartBuilder({
initialQuery ? { ...initialState, ...parsedInitial } : initialState
);
const { chartData, query, isLoading, error, runQuery } = useChartQuery(
workspaceId,
feedbackRecordDirectoryId,
initialQuery
);
const { chartData, query, isLoading, error, runQuery } = useChartQuery(workspaceId, initialQuery);
const currentQuery = useMemo(() => buildCubeQuery(state), [state]);
const hasConfigChanged = useMemo(() => {
@@ -13,14 +13,9 @@ 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>) {
export function AIQuerySection({ workspaceId, onChartGenerated }: Readonly<AIQuerySectionProps>) {
const [userQuery, setUserQuery] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const { t } = useTranslation();
@@ -33,7 +28,6 @@ export function AIQuerySection({
const result = await generateAIChartAction({
workspaceId,
prompt: userQuery.trim(),
feedbackRecordDirectoryId,
});
if (result?.data) {
@@ -47,17 +47,26 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
};
}
void getDashboardsAction({ workspaceId }).then((result) => {
if (cancelled) {
return;
}
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));
}
});
if (result?.data) {
setDashboards(result.data.map((dashboard) => ({ id: dashboard.id, name: dashboard.name })));
} else {
toast.error(getFormattedErrorMessage(result));
}
})
.catch((error) => {
if (cancelled) {
return;
}
const message =
error instanceof Error ? error.message : t("workspace.analysis.charts.failed_to_load_dashboards");
toast.error(message);
});
return () => {
cancelled = true;
@@ -128,6 +137,12 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
setIsAddToDashboardDialogOpen(false);
setSelectedDashboardId(undefined);
router.refresh();
} catch (error) {
const message =
error instanceof Error
? error.message
: t("workspace.analysis.charts.failed_to_add_chart_to_dashboard");
toast.error(message);
} finally {
setIsAddingToDashboard(false);
}
@@ -13,10 +13,9 @@ interface ChartRowProps {
chart: TChartWithCreator;
workspaceId: string;
isReadOnly: boolean;
directories: { id: string; name: string }[];
}
export function ChartRow({ chart, workspaceId, isReadOnly, directories }: Readonly<ChartRowProps>) {
export function ChartRow({ chart, workspaceId, isReadOnly }: Readonly<ChartRowProps>) {
const { t } = useTranslation();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const IconComponent = CHART_TYPE_ICONS[chart.type] ?? BarChart3Icon;
@@ -87,7 +86,6 @@ export function ChartRow({ chart, workspaceId, isReadOnly, directories }: Readon
chartId={chart.id}
initialChart={chart}
onSuccess={() => setIsEditDialogOpen(false)}
directories={directories}
/>
)}
</>
@@ -6,29 +6,20 @@ import { CreateChartButton } from "@/modules/ee/analysis/charts/components/creat
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 { hasWorkspaceFeedbackRecords } 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 ChartsListContent = ({ chartsPromise, workspaceId, isReadOnly }: Readonly<ChartsListContentProps>) => {
const charts = use(chartsPromise);
return (
<ChartsList charts={charts} workspaceId={workspaceId} isReadOnly={isReadOnly} directories={directories} />
);
return <ChartsList charts={charts} workspaceId={workspaceId} isReadOnly={isReadOnly} />;
};
interface ChartsListPageProps {
@@ -38,13 +29,10 @@ interface ChartsListPageProps {
export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPageProps>) {
const t = await getTranslate();
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
const [directories, connectors] = await Promise.all([
getFeedbackRecordDirectoriesByWorkspaceId(workspaceId),
const [hasFeedbackRecords, connectors] = await Promise.all([
hasWorkspaceFeedbackRecords(workspaceId),
getConnectorsWithMappings(workspaceId),
]);
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
directories.map((directory) => directory.id)
);
const chartsPromise = hasFeedbackRecords ? getChartsWithCreator(workspaceId) : null;
return (
@@ -53,20 +41,11 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
workspaceId={workspaceId}
cta={
isReadOnly ? undefined : (
<CreateChartButton
workspaceId={workspaceId}
directories={directories}
buttonProps={{ disabled: !hasFeedbackRecords }}
/>
<CreateChartButton workspaceId={workspaceId} buttonProps={{ disabled: !hasFeedbackRecords }} />
)
}>
{hasFeedbackRecords && chartsPromise ? (
<ChartsListContent
chartsPromise={chartsPromise}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
directories={directories}
/>
<ChartsListContent chartsPromise={chartsPromise} workspaceId={workspaceId} isReadOnly={isReadOnly} />
) : (
<NoFeedbackRecordsState workspaceId={workspaceId} hasFeedbackSources={connectors.length > 0} />
)}
@@ -6,15 +6,9 @@ interface ChartsListProps {
charts: TChartWithCreator[];
workspaceId: string;
isReadOnly: boolean;
directories: { id: string; name: string }[];
}
export const ChartsList = async ({
charts,
workspaceId,
isReadOnly,
directories,
}: Readonly<ChartsListProps>) => {
export const ChartsList = async ({ charts, workspaceId, isReadOnly }: Readonly<ChartsListProps>) => {
const t = await getTranslate();
return (
@@ -32,13 +26,7 @@ export const ChartsList = async ({
</p>
) : (
charts.map((chart) => (
<ChartRow
key={chart.id}
chart={chart}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
directories={directories}
/>
<ChartRow key={chart.id} chart={chart} workspaceId={workspaceId} isReadOnly={isReadOnly} />
))
)}
</div>
@@ -8,7 +8,6 @@ import { Button, type ButtonProps } from "@/modules/ui/components/button";
interface CreateChartButtonProps {
workspaceId: string;
directories: { id: string; name: string }[];
autoAddToDashboardId?: string;
label?: string;
onSuccess?: () => void;
@@ -18,7 +17,6 @@ interface CreateChartButtonProps {
export function CreateChartButton({
workspaceId,
directories,
autoAddToDashboardId,
label,
onSuccess,
@@ -39,7 +37,6 @@ export function CreateChartButton({
onOpenChange={setIsDialogOpen}
workspaceId={workspaceId}
autoAddToDashboardId={autoAddToDashboardId}
directories={directories}
onSuccess={onSuccess}
/>
</>
@@ -11,7 +11,6 @@ export interface CreateChartDialogProps {
autoAddToDashboardId?: string;
initialChart?: TChartWithCreator;
onSuccess?: () => void;
directories: { id: string; name: string }[];
}
export function CreateChartDialog({
@@ -22,7 +21,6 @@ export function CreateChartDialog({
autoAddToDashboardId,
initialChart,
onSuccess,
directories,
}: Readonly<CreateChartDialogProps>) {
return (
<CreateChartView
@@ -33,7 +31,6 @@ export function CreateChartDialog({
initialChart={initialChart}
autoAddToDashboardId={autoAddToDashboardId}
onSuccess={onSuccess}
directories={directories}
/>
);
}
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
@@ -12,7 +11,6 @@ import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manu
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,
@@ -33,7 +31,6 @@ interface CreateChartViewProps {
initialChart?: TChartWithCreator;
autoAddToDashboardId?: string;
onSuccess?: () => void;
directories: { id: string; name: string }[];
}
export function CreateChartView({
@@ -44,7 +41,6 @@ export function CreateChartView({
initialChart,
autoAddToDashboardId,
onSuccess,
directories,
}: Readonly<CreateChartViewProps>) {
const { t } = useTranslation();
const isEditing = !!chartId;
@@ -61,7 +57,6 @@ export function CreateChartView({
handleChartGenerated,
handleSaveChart,
isSaving,
selectedDirectoryId,
handleClose,
} = useChartDialog({
open,
@@ -71,7 +66,6 @@ export function CreateChartView({
initialChart,
autoAddToDashboardId,
onSuccess,
directories,
});
const chartPreviewRef = useRef<HTMLDivElement>(null);
@@ -107,8 +101,7 @@ export function CreateChartView({
);
}
const chartType = selectedChartType ?? (isEditing ? DEFAULT_CHART_TYPE : undefined);
const hasSelectedDirectory = !!selectedDirectoryId;
const chartType = selectedChartType ?? (isEditing ? (initialChart?.type ?? DEFAULT_CHART_TYPE) : undefined);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
@@ -130,76 +123,56 @@ export function CreateChartView({
</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 && (
<>
<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>
<AIQuerySection workspaceId={workspaceId} onChartGenerated={handleChartGenerated} />
{!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 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>
)}
</>
) : (
<Alert variant="error" size="small">
<div>
<p>{t("workspace.analysis.charts.no_data_source_available")}</p>
<Link
className="mt-1 inline-block font-medium underline"
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.analysis.charts.go_to_feedback_record_directories")}
</Link>
</div>
</Alert>
</>
)}
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
{chartType && (
<AdvancedChartBuilder
workspaceId={workspaceId}
chartType={chartType}
initialQuery={chartData?.query ?? initialQuery}
hidePreview={true}
onChartGenerated={handleChartGenerated}
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>
)}
</div>
</DialogBody>
@@ -0,0 +1,345 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const mockCreateChartAction = vi.fn();
const mockUpdateChartAction = vi.fn();
const mockDeleteChartAction = vi.fn();
const mockGetChartAction = vi.fn();
const mockExecuteQueryAction = vi.fn();
const mockAddChartToDashboardAction = vi.fn();
const mockGetDashboardsAction = vi.fn();
const mockToastSuccess = vi.fn();
const mockToastError = vi.fn();
const mockRouterPush = vi.fn();
const mockRouterRefresh = vi.fn();
vi.mock("@/modules/ee/analysis/charts/actions", () => ({
createChartAction: (...args: any[]) => mockCreateChartAction(...args),
updateChartAction: (...args: any[]) => mockUpdateChartAction(...args),
deleteChartAction: (...args: any[]) => mockDeleteChartAction(...args),
getChartAction: (...args: any[]) => mockGetChartAction(...args),
executeQueryAction: (...args: any[]) => mockExecuteQueryAction(...args),
}));
vi.mock("@/modules/ee/analysis/dashboards/actions", () => ({
addChartToDashboardAction: (...args: any[]) => mockAddChartToDashboardAction(...args),
getDashboardsAction: (...args: any[]) => mockGetDashboardsAction(...args),
}));
vi.mock("@/modules/ee/analysis/charts/lib/chart-utils", () => ({
resolveChartType: (type: string) => type,
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: (result: any) => result?.serverError ?? "formatted-error",
}));
vi.mock("react-hot-toast", () => ({
default: {
success: (...args: any[]) => mockToastSuccess(...args),
error: (...args: any[]) => mockToastError(...args),
},
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockRouterPush, refresh: mockRouterRefresh }),
}));
const { useChartDialog } = await import("./use-chart-dialog");
const WORKSPACE_ID = "ws-123";
const CHART_ID = "chart-1";
const NEW_CHART_ID = "chart-new";
const DASHBOARD_ID = "dash-1";
const baseProps = {
open: true,
onOpenChange: vi.fn(),
workspaceId: WORKSPACE_ID,
};
const sampleChartData = {
query: { foo: "bar" },
chartType: "bar" as const,
data: [],
};
const setHookReady = async (result: { current: ReturnType<typeof useChartDialog> }, withChartData = true) => {
await act(async () => {
if (withChartData) {
result.current.handleChartGenerated(sampleChartData as any);
}
result.current.setChartName("My Chart");
});
};
describe("useChartDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("handleSaveChart - create + auto-add", () => {
test("creates chart and adds to dashboard on success without cleanup", async () => {
mockCreateChartAction.mockResolvedValue({ data: { id: NEW_CHART_ID } });
mockAddChartToDashboardAction.mockResolvedValue({ data: { ok: true } });
const onOpenChange = vi.fn();
const onSuccess = vi.fn();
const { result } = renderHook(() =>
useChartDialog({
...baseProps,
onOpenChange,
onSuccess,
autoAddToDashboardId: DASHBOARD_ID,
})
);
await setHookReady(result);
await act(async () => {
await result.current.handleSaveChart();
});
expect(mockCreateChartAction).toHaveBeenCalledTimes(1);
expect(mockAddChartToDashboardAction).toHaveBeenCalledWith({
workspaceId: WORKSPACE_ID,
chartId: NEW_CHART_ID,
dashboardId: DASHBOARD_ID,
});
expect(mockDeleteChartAction).not.toHaveBeenCalled();
expect(mockToastSuccess).toHaveBeenCalledWith("workspace.analysis.charts.chart_added_to_dashboard");
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(mockRouterPush).toHaveBeenCalledWith(`/workspaces/${WORKSPACE_ID}/dashboards/${DASHBOARD_ID}`);
expect(mockRouterRefresh).toHaveBeenCalledTimes(1);
expect(onSuccess).toHaveBeenCalledTimes(1);
});
test("cleans up newly created chart when auto-add fails", async () => {
mockCreateChartAction.mockResolvedValue({ data: { id: NEW_CHART_ID } });
mockAddChartToDashboardAction.mockResolvedValue({ serverError: "boom" });
mockDeleteChartAction.mockResolvedValue({ data: { id: NEW_CHART_ID } });
const { result } = renderHook(() =>
useChartDialog({
...baseProps,
autoAddToDashboardId: DASHBOARD_ID,
})
);
await setHookReady(result);
await act(async () => {
await result.current.handleSaveChart();
});
expect(mockCreateChartAction).toHaveBeenCalledTimes(1);
expect(mockDeleteChartAction).toHaveBeenCalledWith({
workspaceId: WORKSPACE_ID,
chartId: NEW_CHART_ID,
});
expect(mockToastError).toHaveBeenCalled();
});
test("cleans up newly created chart when auto-add throws unexpectedly", async () => {
mockCreateChartAction.mockResolvedValue({ data: { id: NEW_CHART_ID } });
mockAddChartToDashboardAction.mockRejectedValue(new Error("network down"));
mockDeleteChartAction.mockResolvedValue({ data: { id: NEW_CHART_ID } });
const { result } = renderHook(() =>
useChartDialog({
...baseProps,
autoAddToDashboardId: DASHBOARD_ID,
})
);
await setHookReady(result);
await act(async () => {
await result.current.handleSaveChart();
});
expect(mockDeleteChartAction).toHaveBeenCalledWith({
workspaceId: WORKSPACE_ID,
chartId: NEW_CHART_ID,
});
expect(mockToastError).toHaveBeenCalledWith("network down");
});
test("does not delete pre-existing chart when auto-add fails on update path", async () => {
mockUpdateChartAction.mockResolvedValue({ data: { id: CHART_ID } });
mockAddChartToDashboardAction.mockResolvedValue({ serverError: "boom" });
const { result } = renderHook(() =>
useChartDialog({
...baseProps,
chartId: CHART_ID,
autoAddToDashboardId: DASHBOARD_ID,
})
);
await setHookReady(result);
await act(async () => {
await result.current.handleSaveChart();
});
expect(mockUpdateChartAction).toHaveBeenCalledTimes(1);
expect(mockCreateChartAction).not.toHaveBeenCalled();
expect(mockDeleteChartAction).not.toHaveBeenCalled();
expect(mockToastError).toHaveBeenCalled();
});
});
describe("handleAddToDashboard - cleanup behavior", () => {
test("cleans up newly created chart when widget add fails", async () => {
mockCreateChartAction.mockResolvedValue({ data: { id: NEW_CHART_ID } });
mockAddChartToDashboardAction.mockResolvedValue({ serverError: "boom" });
mockDeleteChartAction.mockResolvedValue({ data: { id: NEW_CHART_ID } });
const { result } = renderHook(() => useChartDialog(baseProps));
await setHookReady(result);
await act(async () => {
result.current.setSelectedDashboardId(DASHBOARD_ID);
});
await act(async () => {
await result.current.handleAddToDashboard();
});
expect(mockCreateChartAction).toHaveBeenCalledTimes(1);
expect(mockAddChartToDashboardAction).toHaveBeenCalledWith({
workspaceId: WORKSPACE_ID,
chartId: NEW_CHART_ID,
dashboardId: DASHBOARD_ID,
});
expect(mockDeleteChartAction).toHaveBeenCalledWith({
workspaceId: WORKSPACE_ID,
chartId: NEW_CHART_ID,
});
});
test("does not delete pre-existing chart when widget add fails", async () => {
mockAddChartToDashboardAction.mockResolvedValue({ serverError: "boom" });
const { result } = renderHook(() =>
useChartDialog({
...baseProps,
chartId: CHART_ID,
})
);
// Pre-existing chart has currentChartId set via init. Skip the load-chart branch
// by providing initialChart so the effect short-circuits.
await act(async () => {
result.current.setCurrentChartId(CHART_ID);
result.current.handleChartGenerated(sampleChartData as any);
result.current.setChartName("My Chart");
result.current.setSelectedDashboardId(DASHBOARD_ID);
});
await act(async () => {
await result.current.handleAddToDashboard();
});
expect(mockCreateChartAction).not.toHaveBeenCalled();
expect(mockDeleteChartAction).not.toHaveBeenCalled();
expect(mockToastError).toHaveBeenCalled();
});
});
describe("handleAddToDashboard - validation", () => {
test("toasts and skips when name is empty for new chart", async () => {
const { result } = renderHook(() => useChartDialog(baseProps));
await act(async () => {
result.current.handleChartGenerated(sampleChartData as any);
result.current.setSelectedDashboardId(DASHBOARD_ID);
});
await act(async () => {
await result.current.handleAddToDashboard();
});
expect(mockToastError).toHaveBeenCalledWith("workspace.analysis.charts.please_enter_chart_name");
expect(mockCreateChartAction).not.toHaveBeenCalled();
expect(mockAddChartToDashboardAction).not.toHaveBeenCalled();
});
test("toasts when no dashboard selected", async () => {
const { result } = renderHook(() => useChartDialog(baseProps));
await setHookReady(result);
await act(async () => {
await result.current.handleAddToDashboard();
});
expect(mockToastError).toHaveBeenCalledWith("workspace.analysis.charts.please_select_dashboard");
expect(mockAddChartToDashboardAction).not.toHaveBeenCalled();
});
});
describe("handleSaveChart - validation + error paths", () => {
test("toasts when chartName is empty", async () => {
const { result } = renderHook(() => useChartDialog(baseProps));
await act(async () => {
result.current.handleChartGenerated(sampleChartData as any);
});
await act(async () => {
await result.current.handleSaveChart();
});
expect(mockToastError).toHaveBeenCalledWith("workspace.analysis.charts.please_enter_chart_name");
expect(mockCreateChartAction).not.toHaveBeenCalled();
});
test("toasts when create fails on the create branch", async () => {
mockCreateChartAction.mockResolvedValue({ serverError: "create-failed" });
const { result } = renderHook(() => useChartDialog(baseProps));
await setHookReady(result);
await act(async () => {
await result.current.handleSaveChart();
});
expect(mockToastError).toHaveBeenCalledWith("create-failed");
expect(mockAddChartToDashboardAction).not.toHaveBeenCalled();
});
test("toasts when update fails on the update branch", async () => {
mockUpdateChartAction.mockResolvedValue({ serverError: "update-failed" });
const { result } = renderHook(() =>
useChartDialog({
...baseProps,
chartId: CHART_ID,
})
);
// Skip async load-chart branch by setting currentChartId directly
await act(async () => {
result.current.setCurrentChartId(CHART_ID);
result.current.handleChartGenerated(sampleChartData as any);
result.current.setChartName("My Chart");
});
await act(async () => {
await result.current.handleSaveChart();
});
expect(mockUpdateChartAction).toHaveBeenCalledTimes(1);
expect(mockToastError).toHaveBeenCalledWith("update-failed");
});
});
});
@@ -30,7 +30,6 @@ export interface UseChartDialogProps {
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
initialChart?: TChartWithCreator;
onSuccess?: () => void;
directories?: { id: string; name: string }[];
}
export function useChartDialog({
@@ -41,7 +40,6 @@ export function useChartDialog({
autoAddToDashboardId,
initialChart,
onSuccess,
directories,
}: Readonly<UseChartDialogProps>) {
const { t } = useTranslation();
const router = useRouter();
@@ -55,7 +53,6 @@ export function useChartDialog({
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories?.[0]?.id ?? null);
useEffect(() => {
let cancelled = false;
@@ -84,7 +81,6 @@ export function useChartDialog({
setChartName("");
setSelectedChartType(undefined);
setCurrentChartId(undefined);
setSelectedDirectoryId(directories?.[0]?.id ?? null);
return;
}
@@ -109,12 +105,10 @@ export function useChartDialog({
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;
@@ -167,12 +161,8 @@ export function useChartDialog({
return;
}
if (!selectedDirectoryId) {
toast.error(t("workspace.analysis.charts.select_data_source_first"));
return;
}
setIsSaving(true);
let newlyCreatedChartId: string | null = null;
try {
let savedChartId = currentChartId;
@@ -203,7 +193,6 @@ export function useChartDialog({
type: chartData.chartType,
query: chartData.query,
config: {},
feedbackRecordDirectoryId: selectedDirectoryId,
},
});
@@ -215,6 +204,7 @@ export function useChartDialog({
setCurrentChartId(result.data.id);
savedChartId = result.data.id;
newlyCreatedChartId = result.data.id;
toast.success(t("workspace.analysis.charts.chart_saved_successfully"));
}
@@ -230,6 +220,7 @@ export function useChartDialog({
getFormattedErrorMessage(addResult) ||
t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
);
if (newlyCreatedChartId) await cleanupOrphanChart(newlyCreatedChartId);
return;
}
@@ -246,6 +237,7 @@ export function useChartDialog({
const message =
error instanceof Error ? error.message : t("workspace.analysis.charts.failed_to_save_chart");
toast.error(message);
if (autoAddToDashboardId && newlyCreatedChartId) await cleanupOrphanChart(newlyCreatedChartId);
} finally {
setIsSaving(false);
}
@@ -260,11 +252,6 @@ export function useChartDialog({
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: {
@@ -272,7 +259,6 @@ export function useChartDialog({
type: data.chartType,
query: data.query,
config: {},
feedbackRecordDirectoryId: selectedDirectoryId,
},
});
@@ -345,7 +331,6 @@ export function useChartDialog({
setSelectedChartType(undefined);
setCurrentChartId(undefined);
setChartLoadError(null);
setSelectedDirectoryId(directories?.[0]?.id ?? null);
onOpenChange(false);
}
};
@@ -374,8 +359,6 @@ export function useChartDialog({
isSaving,
isLoadingChart,
chartLoadError,
selectedDirectoryId,
setSelectedDirectoryId,
handleChartGenerated,
handleSaveChart,
handleAddToDashboard,
@@ -13,11 +13,7 @@ export interface QueryResult {
data: TChartDataRow[];
}
export function useChartQuery(
workspaceId: string,
feedbackRecordDirectoryId: string | null,
initialQuery?: TChartQuery
) {
export function useChartQuery(workspaceId: string, initialQuery?: TChartQuery) {
const { t } = useTranslation();
const [chartData, setChartData] = useState<TChartDataRow[] | null>(null);
const [query, setQuery] = useState<TChartQuery | null>(initialQuery ?? null);
@@ -25,12 +21,6 @@ export function useChartQuery(
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);
@@ -38,7 +28,6 @@ export function useChartQuery(
const result = await executeQueryAction({
workspaceId,
query: cubeQuery,
feedbackRecordDirectoryId,
});
if (result?.serverError) {
@@ -119,7 +119,7 @@ export function validateQueryMembers(query: TChartQuery): void {
/**
* Injects a tenant_id filter into a Cube.js query to scope results to a specific
* FeedbackRecordDirectory. Called server-side before every query execution.
* workspace. Called server-side before every query execution.
*/
export function injectTenantFilter(query: TChartQuery, tenantId: string): TChartQuery {
const tenantFilter: TCubeFilter = {
@@ -45,20 +45,16 @@ const selectChart = {
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"),
};
@@ -82,7 +78,6 @@ describe("Chart Service", () => {
type: "bar",
query: { measures: ["Responses.count"] },
config: { showLegend: true },
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
createdBy: mockUserId,
});
@@ -94,7 +89,6 @@ describe("Chart Service", () => {
workspaceId: mockWorkspaceId,
query: { measures: ["Responses.count"] },
config: { showLegend: true },
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
createdBy: mockUserId,
},
select: selectChart,
@@ -114,7 +108,6 @@ describe("Chart Service", () => {
type: "bar",
query: {},
config: {},
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
createdBy: mockUserId,
})
).rejects.toMatchObject({
@@ -133,7 +126,6 @@ describe("Chart Service", () => {
type: "bar",
query: {},
config: {},
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
createdBy: mockUserId,
})
).rejects.toMatchObject({
@@ -348,7 +340,7 @@ describe("Chart Service", () => {
});
describe("getCharts", () => {
test("returns all charts for a project", async () => {
test("returns all charts for a workspace", async () => {
const chartsFromDb = [
{ ...mockChart, creator: { name: "User 1" } },
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: { name: null } },
@@ -371,7 +363,6 @@ describe("Chart Service", () => {
type: true,
query: true,
config: true,
feedbackRecordDirectoryId: true,
createdAt: true,
updatedAt: true,
creator: { select: { name: true } },
@@ -22,7 +22,6 @@ export const selectChart = {
type: true,
query: true,
config: true,
feedbackRecordDirectoryId: true,
createdAt: true,
updatedAt: true,
} as const;
@@ -39,7 +38,6 @@ export const createChart = async (data: TChartCreateInput): Promise<TChart> => {
query: data.query,
config: data.config,
createdBy: data.createdBy,
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
},
select: selectChart,
});
@@ -156,7 +154,6 @@ export const duplicateChart = async (
type: ZChartType.parse(sourceChart.type),
query: ZChartQuery.parse(sourceChart.query),
config: ZChartConfig.parse(sourceChart.config ?? {}),
feedbackRecordDirectoryId: sourceChart.feedbackRecordDirectoryId,
createdBy,
});
} catch (error) {
@@ -27,7 +27,6 @@ interface AddExistingChartsDialogProps {
onOpenChange: (open: boolean) => void;
workspaceId: string;
dashboardId: string;
directories: { id: string; name: string }[];
existingChartIds: string[];
onSuccess: () => void;
}
@@ -42,7 +41,6 @@ export function AddExistingChartsDialog({
onOpenChange,
workspaceId,
dashboardId,
directories,
existingChartIds,
onSuccess,
}: Readonly<AddExistingChartsDialogProps>) {
@@ -151,7 +149,6 @@ export function AddExistingChartsDialog({
<DialogFooter className="sm:justify-between">
<CreateChartButton
workspaceId={workspaceId}
directories={directories}
autoAddToDashboardId={dashboardId}
label={t("workspace.analysis.dashboards.create_new_chart")}
onSuccess={() => {
@@ -15,7 +15,6 @@ import { IconBar } from "@/modules/ui/components/iconbar";
interface DashboardControlBarProps {
workspaceId: string;
dashboardId: string;
directories: { id: string; name: string }[];
existingChartIds: string[];
isEditing: boolean;
isSaving: boolean;
@@ -30,7 +29,6 @@ interface DashboardControlBarProps {
export const DashboardControlBar = ({
workspaceId,
dashboardId,
directories,
existingChartIds,
isEditing,
isSaving,
@@ -133,7 +131,6 @@ export const DashboardControlBar = ({
onOpenChange={setIsAddExistingDialogOpen}
workspaceId={workspaceId}
dashboardId={dashboardId}
directories={directories}
existingChartIds={existingChartIds}
onSuccess={() => {
setIsAddExistingDialogOpen(false);
@@ -28,7 +28,6 @@ interface DashboardDetailClientProps {
workspaceId: string;
dashboard: TDashboardDetail;
widgetDataPromises: Map<string, Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>>;
directories: { id: string; name: string }[];
isReadOnly: boolean;
}
@@ -145,7 +144,6 @@ export function DashboardDetailClient({
workspaceId,
dashboard,
widgetDataPromises,
directories,
isReadOnly,
}: Readonly<DashboardDetailClientProps>) {
const router = useRouter();
@@ -287,7 +285,6 @@ export function DashboardDetailClient({
<DashboardControlBar
workspaceId={workspaceId}
dashboardId={dashboard.id}
directories={directories}
existingChartIds={widgets.map((w) => w.chartId)}
isEditing={isEditing}
isSaving={isSaving}
@@ -358,7 +355,6 @@ export function DashboardDetailClient({
setEditingChartId(null);
router.refresh();
}}
directories={directories}
/>
)}
</PageContentWrapper>
@@ -60,7 +60,6 @@ vi.mock("@/modules/ee/analysis/charts/lib/charts", () => ({
type: true,
query: true,
config: true,
feedbackRecordDirectoryId: true,
createdAt: true,
updatedAt: true,
},
@@ -419,7 +418,7 @@ describe("Dashboard Service", () => {
});
describe("getDashboards", () => {
test("returns all dashboards for a project with creator", async () => {
test("returns all dashboards for a workspace with creator", async () => {
const dashboards = [
{ ...mockDashboard, creator: { name: "Alice" }, _count: { widgets: 3 } },
{ ...mockDashboard, id: "dash-2", name: "Dashboard 2", creator: null, _count: { widgets: 0 } },
@@ -4,7 +4,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
import { injectTenantFilter } from "@/modules/ee/analysis/charts/lib/chart-utils";
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { DashboardDetailClient } from "../components/dashboard-detail-client";
import { getDashboard } from "../lib/dashboards";
@@ -16,10 +15,10 @@ interface WidgetQueryResult {
async function executeWidgetQuery(
query: TChartQuery,
feedbackRecordDirectoryId: string
workspaceId: string
): Promise<WidgetQueryResult | { error: string }> {
try {
const scopedQuery = injectTenantFilter(query, feedbackRecordDirectoryId);
const scopedQuery = injectTenantFilter(query, workspaceId);
const data = await executeQuery(scopedQuery as Record<string, unknown>);
return { data: Array.isArray(data) ? data : [], query };
} catch (error) {
@@ -35,7 +34,6 @@ export async function DashboardDetailPage({
}>) {
const { workspaceId, dashboardId } = await params;
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
let dashboard;
try {
@@ -52,10 +50,7 @@ export async function DashboardDetailPage({
(w): w is typeof w & { chart: NonNullable<typeof w.chart> } => !!w.chart
);
for (const widget of widgetsWithCharts) {
widgetDataPromises.set(
widget.id,
executeWidgetQuery(widget.chart.query, widget.chart.feedbackRecordDirectoryId)
);
widgetDataPromises.set(widget.id, executeWidgetQuery(widget.chart.query, workspaceId));
}
return (
@@ -63,7 +58,6 @@ export async function DashboardDetailPage({
workspaceId={workspaceId}
dashboard={dashboard}
widgetDataPromises={widgetDataPromises}
directories={directories}
isReadOnly={isReadOnly}
/>
);
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("server-only", () => ({}));
const mockListFeedbackRecords = vi.fn();
vi.mock("@/modules/hub/service", () => ({
listFeedbackRecords: (...args: any[]) => mockListFeedbackRecords(...args),
}));
const mockWorkspaceId = "workspace-abc-123";
const recordsResult = (count: number) => ({
data: { data: Array.from({ length: count }, (_, i) => ({ id: `rec-${i}` })) },
error: null,
});
const errorResult = () => ({
data: null,
error: { status: 500, message: "Hub error", detail: null },
});
const nullDataResult = () => ({
data: null,
error: null,
});
describe("hasWorkspaceFeedbackRecords", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns true when workspace has at least one record", async () => {
mockListFeedbackRecords.mockResolvedValueOnce(recordsResult(3));
const { hasWorkspaceFeedbackRecords } = await import("./feedback-records");
const result = await hasWorkspaceFeedbackRecords(mockWorkspaceId);
expect(result).toBe(true);
expect(mockListFeedbackRecords).toHaveBeenCalledWith({ tenant_id: mockWorkspaceId, limit: 1 });
});
test("returns false when workspace has no records", async () => {
mockListFeedbackRecords.mockResolvedValueOnce(recordsResult(0));
const { hasWorkspaceFeedbackRecords } = await import("./feedback-records");
const result = await hasWorkspaceFeedbackRecords(mockWorkspaceId);
expect(result).toBe(false);
expect(mockListFeedbackRecords).toHaveBeenCalledWith({ tenant_id: mockWorkspaceId, limit: 1 });
});
test("returns false when data is null with no error", async () => {
mockListFeedbackRecords.mockResolvedValueOnce(nullDataResult());
const { hasWorkspaceFeedbackRecords } = await import("./feedback-records");
const result = await hasWorkspaceFeedbackRecords(mockWorkspaceId);
expect(result).toBe(false);
});
test("returns true when Hub returns an error (unknown availability does not lock flows)", async () => {
mockListFeedbackRecords.mockResolvedValueOnce(errorResult());
const { hasWorkspaceFeedbackRecords } = await import("./feedback-records");
const result = await hasWorkspaceFeedbackRecords(mockWorkspaceId);
expect(result).toBe(true);
});
});
@@ -1,30 +1,13 @@
"server-only";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import "server-only";
import { listFeedbackRecords } from "@/modules/hub/service";
export const hasFeedbackRecordsInDirectories = async (directoryIds: string[]): Promise<boolean> => {
if (directoryIds.length === 0) {
return false;
}
export const hasWorkspaceFeedbackRecords = async (workspaceId: string): Promise<boolean> => {
const result = await listFeedbackRecords({ tenant_id: workspaceId, limit: 1 });
const results = await Promise.all(
directoryIds.map((directoryId) => listFeedbackRecords({ tenant_id: directoryId, limit: 1 }))
);
const hasRecords = results.some((result) => (result.data?.data?.length ?? 0) > 0);
if (hasRecords) {
if (result.error) {
// Do not lock creation flows when record availability is unknown.
return true;
}
const hasErrors = results.some((result) => Boolean(result.error));
// Do not lock creation flows when record availability is unknown.
return hasErrors;
};
export const hasWorkspaceFeedbackRecords = async (workspaceId: string): Promise<boolean> => {
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
return hasFeedbackRecordsInDirectories(directories.map((directory) => directory.id));
return (result.data?.data?.length ?? 0) > 0;
};
@@ -15,7 +15,6 @@ export const ZChartCreateInput = z.object({
query: ZChartQuery,
config: ZChartConfig,
createdBy: ZId,
feedbackRecordDirectoryId: ZId,
});
export type TChartCreateInput = z.infer<typeof ZChartCreateInput>;
@@ -35,7 +34,6 @@ export const ZChart = z.object({
type: ZChartType,
query: ZChartQuery,
config: ZChartConfig,
feedbackRecordDirectoryId: ZId,
createdAt: z.date(),
updatedAt: z.date(),
});
@@ -314,9 +314,6 @@ export const withAuditLogging = <
case "dashboardWidget":
targetId = auditLoggingCtx.dashboardWidgetId;
break;
case "feedbackRecordDirectory":
targetId = auditLoggingCtx.feedbackRecordDirectoryId;
break;
default:
targetId = UNKNOWN_DATA;
break;
@@ -28,7 +28,6 @@ export const ZAuditTarget = z.enum([
"chart",
"dashboard",
"dashboardWidget",
"feedbackRecordDirectory",
]);
export const ZAuditAction = z.enum([
"created",
@@ -1,112 +0,0 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
createFeedbackRecordDirectory,
getFeedbackRecordDirectoryDetails,
getOrganizationIdFromDirectoryId,
updateFeedbackRecordDirectory,
} from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { ZFeedbackRecordDirectoryUpdateInput } from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
const ZCreateFeedbackRecordDirectoryAction = z.object({
organizationId: ZId,
name: z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED"),
workspaceIds: z.array(ZId).optional(),
});
export const createFeedbackRecordDirectoryAction = authenticatedActionClient
.inputSchema(ZCreateFeedbackRecordDirectoryAction)
.action(
withAuditLogging("created", "feedbackRecordDirectory", async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const result = await createFeedbackRecordDirectory(
parsedInput.organizationId,
parsedInput.name,
parsedInput.workspaceIds
);
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.feedbackRecordDirectoryId = result;
ctx.auditLoggingCtx.newObject = {
...(await getFeedbackRecordDirectoryDetails(result)),
};
return result;
})
);
const ZGetFeedbackRecordDirectoryDetailsAction = z.object({
directoryId: ZId,
});
export const getFeedbackRecordDirectoryDetailsAction = authenticatedActionClient
.inputSchema(ZGetFeedbackRecordDirectoryDetailsAction)
.action(async ({ parsedInput, ctx }) => {
const organizationId = await getOrganizationIdFromDirectoryId(parsedInput.directoryId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
return await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
});
const ZUpdateFeedbackRecordDirectoryAction = z.object({
directoryId: ZId,
data: ZFeedbackRecordDirectoryUpdateInput,
pauseConnectorsInRemovedWorkspaces: z.boolean().optional(),
});
export const updateFeedbackRecordDirectoryAction = authenticatedActionClient
.inputSchema(ZUpdateFeedbackRecordDirectoryAction)
.action(
withAuditLogging("updated", "feedbackRecordDirectory", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromDirectoryId(parsedInput.directoryId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.feedbackRecordDirectoryId = parsedInput.directoryId;
const oldObject = await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
const result = await updateFeedbackRecordDirectory(
parsedInput.directoryId,
organizationId,
parsedInput.data,
{
pauseConnectorsInRemovedWorkspaces: parsedInput.pauseConnectorsInRemovedWorkspaces,
}
);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
return result;
})
);

Some files were not shown because too many files have changed in this diff Show More