mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c7112e559 | |||
| f59e9f13ec | |||
| 5169dec510 | |||
| 8442dedf9c | |||
| fbe2a31133 | |||
| 89eb04f813 | |||
| a862b739f7 | |||
| 4e5df85538 | |||
| 727b349086 | |||
| f75db6b1d0 | |||
| 7ffca53577 | |||
| 25614b23fc | |||
| 016e14d0f1 | |||
| 5e76ebdfc1 | |||
| 150f256721 | |||
| da7971328c | |||
| a6cd56b196 | |||
| 7c81cf119e | |||
| 8d29b24352 | |||
| a1ae849496 | |||
| 4d0a686e89 | |||
| 364915e4c8 | |||
| 817b299436 | |||
| c140dae872 | |||
| 21ed383a46 | |||
| 7aa12a4f0c | |||
| 8edef8aede | |||
| 54fb202285 | |||
| c720a462a7 | |||
| 730ab6a609 | |||
| 4e75a57692 |
+7
-7
@@ -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=
|
||||
|
||||
+2
-2
@@ -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 (
|
||||
|
||||
-7
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
+140
-327
@@ -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}>
|
||||
+1
-4
@@ -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}
|
||||
/>
|
||||
+22
-60
@@ -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>;
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import { FEEDBACK_RECORD_FIELDS, TFieldMapping } from "../types";
|
||||
|
||||
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
|
||||
|
||||
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
|
||||
const requiredFieldIds = new Set(
|
||||
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
|
||||
);
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!requiredFieldIds.has(mapping.targetFieldId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mapping.sourceFieldId || mapping.staticValue) {
|
||||
requiredFieldIds.delete(mapping.targetFieldId);
|
||||
}
|
||||
}
|
||||
|
||||
return requiredFieldIds.size === 0;
|
||||
};
|
||||
-26
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+15
-58
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+20
-24
@@ -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>;
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,7 +22,7 @@ export const importCsvData = async (
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords(
|
||||
csvRows,
|
||||
connector.fieldMappings,
|
||||
connector.feedbackRecordDirectoryId
|
||||
connector.workspaceId
|
||||
);
|
||||
|
||||
let successes = 0;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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?");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ const selectWorkspace = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
legacyEnvironmentId: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
languages: true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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": "该调查没有任何问题",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user