Compare commits

..

24 Commits

Author SHA1 Message Date
Johannes 165c427b9f fix: align PR7 locale base with restacked translations
Made-with: Cursor
2026-04-26 20:20:02 +02:00
Johannes 3869ebff51 docs: add CSV mapping UI revamp plan
Made-with: Cursor
2026-04-26 20:19:37 +02:00
Johannes 628c558757 chore: finalize api keys, translations, and docs updates
Apply remaining admin API key UI tweaks, add matching locale copy updates, and include supporting development documentation for the question-bank workflow.

Made-with: Cursor
2026-04-26 20:19:37 +02:00
Johannes e82e0c87a4 fix: remove stale translation keys in PR6
Made-with: Cursor
2026-04-26 20:19:26 +02:00
Johannes f2f2defd10 fix: align Cube dev defaults with local Postgres
Update docker-compose dev defaults and env guidance so Cube connects to the local postgres service by default instead of a non-resolvable host.

Made-with: Cursor
2026-04-26 20:17:00 +02:00
Johannes 128e94e4cf fix: add missing dashboard chart translation keys
Add missing i18n strings for the add-chart label and create-new-chart action so dashboard dialogs render translated copy instead of raw key names.

Made-with: Cursor
2026-04-26 20:17:00 +02:00
Johannes ee0ea7caa6 chore: finalize api keys, translations, and docs updates
Apply remaining admin API key UI tweaks, add matching locale copy updates, and include supporting development documentation for the question-bank workflow.

Made-with: Cursor
2026-04-26 20:17:00 +02:00
Johannes 52dc64ffd2 fix: add missing analysis translation keys in PR5
Made-with: Cursor
2026-04-26 20:16:47 +02:00
Johannes bec3fa2dbd feat: make Add charts a primary dashboard action
Promote Add charts to a labeled primary button in the dashboard control bar and keep secondary actions in the icon toolbar for clearer chart-creation affordance.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes 18a3c4f0f7 fix: pass directories into dashboard detail chart dialogs
Load feedback record directories in dashboard detail page and thread them through dashboard detail and control bar components so chart dialogs no longer crash with an undefined directories reference.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes 6c61afec2f feat: refresh analysis charts and dashboard feedback gating
Unify chart create and edit flows, update dashboard chart interactions, and add feedback-record availability checks with dedicated empty-state handling across analysis entry points.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes ce4d9350e2 fix: add missing feedback record translation keys in PR4
Made-with: Cursor
2026-04-26 20:05:49 +02:00
Johannes 3e6f81268d fix: require frdId when refreshing feedback records
Made-with: Cursor
2026-04-26 19:54:30 +02:00
Johannes 8137de3c80 feat: integrate hub feedback records into unify workspace
Add hub-backed feedback record actions and UI flows under Unify so workspaces can list and manage feedback records from a dedicated drawer and table experience.

Made-with: Cursor
2026-04-26 19:54:30 +02:00
Johannes bf0ad45697 fix: add missing feedback-directory and unify translations
Made-with: Cursor
2026-04-26 19:54:23 +02:00
Johannes b1a4277ca8 feat: wire workspace settings to feedback record directories
Integrate feedback record directory selection into workspace settings and creation flows while updating workspace navigation components to expose the new workspace-level destinations.

Made-with: Cursor
2026-04-26 19:37:33 +02:00
Johannes 1876c13f52 fix: align unify locale keys and regenerate translations
Made-with: Cursor
2026-04-26 19:37:27 +02:00
Johannes 0623bb9ff5 fix: make feedback sources settings card compatible in PR2
Made-with: Cursor
2026-04-26 19:03:23 +02:00
Johannes d37cddaa7e feat: refactor feedback sources UI and routing
Rework the feedback sources flow with new source form helpers, question selection components, and a canonical feedback-sources route while retiring the legacy survey selector.

Made-with: Cursor
2026-04-26 18:55:25 +02:00
Johannes 24f632f9ce fix: align unify connector type literals with schema
Replace legacy formbricks type checks with formbricks_survey across source setup flows so TypeScript and connector creation paths stay consistent.

Made-with: Cursor
2026-04-26 17:14:13 +02:00
Johannes b041e3da86 fix: normalize connector response timestamps for Cube
Normalize imported response timestamps to ISO and fall back from createdAt to updatedAt so collected_at is always valid for time-series charts.

Made-with: Cursor
2026-04-24 12:57:00 +02:00
Johannes 8d91a3db62 fix: map csat and ces connector question types
Add missing hub field mappings for csat and ces survey elements and guard against unmappable selected elements so connector setup fails with a clear validation error instead of a Prisma crash.

Made-with: Cursor
2026-04-24 12:02:51 +02:00
Johannes c05e3f192d fix: accept legacy connector type in create action
Normalize legacy formbricks connector payloads to formbricks_survey during connector creation so client flows continue working while UI migrations roll out.

Made-with: Cursor
2026-04-24 11:41:34 +02:00
Johannes 5b61e00560 refactor: align connector enum with formbricks_survey
Rename connector type usage from formbricks to formbricks_survey across Prisma schema, shared types, and connector service logic to keep enum contracts consistent.

Made-with: Cursor
2026-04-24 10:56:57 +02:00
195 changed files with 6203 additions and 3678 deletions
@@ -0,0 +1,72 @@
---
name: csv-mapping-ui
overview: "Simplify the unreleased CSV mapping flow by matching the Formbricks Survey connectors default-heavy behavior: hide predefined/internal fields, auto-map likely CSV columns, and present only the fields users need to review."
todos:
- id: define-csv-field-model
content: Define CSV-specific mapping groups, hidden static fields, required UI fields, aliases, and confidence metadata.
status: pending
- id: build-auto-mapping
content: Implement auto-mapping from CSV headers, sample values, and filename, including `$now`, `csv`, and response-value routing.
status: pending
- id: refactor-mapping-ui
content: Render grouped CSV mapping UI with Basic, Source Context, and collapsed Advanced sections while removing Tenant ID.
status: pending
- id: update-validation-transform
content: Align create/edit validation and import transform with required `field_label`, hidden `source_type`, and synthetic `response_value`.
status: pending
- id: add-utility-tests
content: Add Vitest coverage for auto-mapping, hidden/static mappings, filename defaults, and response-value routing.
status: pending
isProject: false
---
# CSV Mapping UI Plan
## Current Comparison
- Formbricks Survey connector in [`/Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/create-connector-modal.tsx`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/create-connector-modal.tsx>) prepopulates `sourceName`, selects all supported survey questions after survey selection, defaults `importHistorical` to true, and derives Hub `field_type` server-side via [`/Users/johannes/Developer/formbricks/formbricks/apps/web/lib/connector/actions.ts`](/Users/johannes/Developer/formbricks/formbricks/apps/web/lib/connector/actions.ts).
- CSV currently only defaults connector name and directory; it exposes all `FEEDBACK_RECORD_FIELDS`, including `tenant_id`, through [`MappingUI`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/mapping-ui.tsx>).
- CSV import already backfills missing `tenant_id` from `connector.feedbackRecordDirectoryId` in [`/Users/johannes/Developer/formbricks/formbricks/apps/web/lib/connector/csv-import.ts`](/Users/johannes/Developer/formbricks/formbricks/apps/web/lib/connector/csv-import.ts) and [`/Users/johannes/Developer/formbricks/formbricks/apps/web/lib/connector/csv-transform.ts`](/Users/johannes/Developer/formbricks/formbricks/apps/web/lib/connector/csv-transform.ts), so the UI should not ask for it.
## Proposed UI Shape
- Keep a single CSV configuration screen: source name, upload/dropzone, preview, then mapping review. No additional wizard step.
- Replace the raw required/optional target list with grouped sections:
- Basic required: `collected_at`, `field_id`, `field_label`, `field_type`, `response_value`.
- Source context: `source_id`, `source_name`.
- Advanced fields, collapsed: `language`, `user_identifier`, `metadata`, and any less common optional targets.
- Drop `tenant_id` from the UI entirely.
- Hide `source_type` from the UI and save it as static `csv`.
- Prepopulate `source_name` from the uploaded CSV file name, while keeping it editable.
- Auto-map immediately after upload and display review/confidence indicators on mapped fields so users can fix uncertain matches without starting from blank.
## Prepopulation Rules
- `collected_at`: map likely timestamp columns (`timestamp`, `created_at`, `date`, `submitted_at`, etc.); otherwise set static `$now`.
- `source_type`: static `csv`, hidden.
- `source_name`: static uploaded CSV filename, editable.
- `field_id` and `field_label`: both visible and required in the CSV UI; auto-map likely id/question/label columns, but block save if unresolved.
- `field_type`: keep visible and required; auto-suggest from header/sample value, but require a valid enum.
- `response_value`: show one user-facing control and internally route to `value_text`, `value_number`, `value_boolean`, or `value_date` based on selected `field_type`.
- Advanced fields: auto-map if obvious (`language`, `user_id`, `metadata`) but keep them collapsed by default.
## Implementation Approach
- Add CSV-specific field configuration and auto-mapping utilities near [`/Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/types.ts`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/types.ts>) and [`/Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/utils.ts`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/utils.ts>): matching aliases, confidence level, hidden static mappings, and response-value routing.
- Refactor [`MappingUI`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/mapping-ui.tsx>) to render CSV-specific groups instead of `requiredFields` and `optionalFields` directly from `FEEDBACK_RECORD_FIELDS`.
- Update [`CsvConnectorUI`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/csv-connector-ui.tsx>) so file upload triggers auto-mapping using headers, first-row samples, and CSV filename.
- Update create/edit validation in [`CreateConnectorModal`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/create-connector-modal.tsx>), [`EditConnectorModal`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/edit-connector-modal.tsx>), and [`connector-form-utils.ts`](</Users/johannes/Developer/formbricks/formbricks/apps/web/app/(app)/workspaces/[workspaceId]/unify/sources/components/connector-form-utils.ts>) to require the CSV UI basics, including `field_label` and `response_value`, not just the raw backend-required fields.
- Update CSV transform/import logic so the synthetic `response_value` mapping is converted to the correct backend target based on `field_type` before creating feedback records.
- Remove unreleased compatibility shims rather than preserving old UI behavior; existing branch data can be replaced by the new mapping contract.
- Add focused Vitest coverage for the new auto-mapping and response-value routing utilities. Avoid `.tsx` component tests per repo guidance.
## Main Edge Cases To Cover
- CSV has no timestamp column: `collected_at` becomes `$now`.
- CSV has ambiguous timestamp columns: choose highest-confidence alias and mark reviewable.
- CSV has no field label/id columns: save is blocked with clear validation.
- CSV field type conflicts with response sample value: route by `field_type`, surface parse failures in existing import result counts/errors.
- CSV filename changes after remapping: update `source_name` only if the user has not manually edited it.
- Advanced auto-detected fields remain editable even while the section is collapsed.
- Hidden `tenant_id` is never persisted from user input; backend predefined value remains source of truth.
- Hidden `source_type=csv` is included in saved mappings/import payload so rows are valid without user action.
+13 -13
View File
@@ -167,16 +167,16 @@ AZUREAD_TENANT_ID=
# Configure Formbricks AI at the instance level
# Set the provider used for AI features on this instance.
# Accepted values for AI_PROVIDER: aws, google, azure
# Accepted values for AI_PROVIDER: aws, gcp, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
# AI_PROVIDER=google
# AI_PROVIDER=gcp
# AI_MODEL=gemini-2.5-flash
# Google Cloud credentials for Gemini models
# AI_GOOGLE_CLOUD_PROJECT=
# AI_GOOGLE_CLOUD_LOCATION=
# AI_GOOGLE_CLOUD_CREDENTIALS_JSON=
# AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS=
# Google Vertex AI credentials
# AI_GCP_PROJECT=
# AI_GCP_LOCATION=
# AI_GCP_CREDENTIALS_JSON=
# AI_GCP_APPLICATION_CREDENTIALS=
# Amazon Bedrock credentials
# AI_AWS_REGION=
@@ -305,13 +305,13 @@ REDIS_URL=redis://localhost:6379
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
#
# Cube connects to the Hub DB. When using docker-compose.dev.yml with the hub network,
# use the container name and internal port. Hub credentials: formbricks/formbricks_dev, db: hub
# CUBEJS_DB_HOST=formbricks_hub_postgres
# Cube connects to the local Postgres service by default in docker-compose.dev.yml.
# Override these only if your Hub DB runs on a different host.
# CUBEJS_DB_HOST=postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=hub
# CUBEJS_DB_USER=formbricks
# CUBEJS_DB_PASS=formbricks_dev
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
#
# Alternative (when not on same Docker network): host.docker.internal and port 5433
@@ -60,8 +60,8 @@ const mockTemplate: TXMTemplate = {
],
styling: {
brandColor: { light: "#0000FF" },
elementHeadlineColor: { light: "#00FF00" },
inputBgColor: { light: "#FF0000" },
questionColor: { light: "#00FF00" },
inputColor: { light: "#FF0000" },
},
};
@@ -1 +1 @@
export { WorkspaceFeedbackSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
export { WorkspaceSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
@@ -23,7 +23,9 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
import { getOrganizationsByUserId } from "./lib/organization";
import { getWorkspacesByUserId } from "./lib/workspace";
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput;
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
feedbackRecordDirectoryId: ZId.optional(),
});
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
@@ -119,7 +121,7 @@ const ZGetWorkspacesForSwitcherAction = z.object({
});
/**
* Fetches workspaces list for switcher dropdown.
* Fetches projects 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: t("common.ask"),
name: "Ask",
items: [
{
name: t("common.surveys"),
@@ -337,6 +337,15 @@ 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 () => {
@@ -598,20 +607,18 @@ export const MainNavigation = ({
))}
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
<ul>
<NavigationLink
href={configurationNavigationItem.href}
isActive={configurationNavigationItem.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={configurationNavigationItem.disabled}
disabledMessage={
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
}
linkText={configurationNavigationItem.name}>
<configurationNavigationItem.icon strokeWidth={1.5} />
</NavigationLink>
</ul>
<NavigationLink
href={configurationNavigationItem.href}
isActive={configurationNavigationItem.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={configurationNavigationItem.disabled}
disabledMessage={
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
}
linkText={configurationNavigationItem.name}>
<configurationNavigationItem.icon strokeWidth={1.5} />
</NavigationLink>
</li>
</ul>
</div>
@@ -179,6 +179,15 @@ export const OrganizationBreadcrumb = ({
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.title"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
];
return (
@@ -42,6 +42,13 @@ 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"),
@@ -0,0 +1 @@
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";
@@ -1,19 +1,83 @@
"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";
import {
TCreateFeedbackRecordAction,
TRetrieveFeedbackRecordAction,
TUpdateFeedbackRecordAction,
ZCreateFeedbackRecordAction,
ZRetrieveFeedbackRecordAction,
ZUpdateFeedbackRecordAction,
} from "./types";
const ZFeedbackRecordId = z.uuid();
const ZFeedbackRecordFieldType = z.enum([
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
]);
const ZFeedbackRecordMetadata = z.record(z.string(), z.unknown());
const ZFeedbackRecordCreateInput = z.object({
submission_id: z.string().min(1),
tenant_id: ZId,
source_type: z.string().min(1),
field_id: z.string().min(1),
field_type: ZFeedbackRecordFieldType,
collected_at: z.iso.datetime().optional(),
source_id: z.string().optional().nullable(),
source_name: z.string().optional().nullable(),
field_label: z.string().optional().nullable(),
field_group_id: z.string().optional(),
field_group_label: z.string().optional().nullable(),
value_text: z.string().optional().nullable(),
value_number: z.number().optional(),
value_boolean: z.boolean().optional(),
value_date: z.iso.datetime().optional(),
metadata: ZFeedbackRecordMetadata.optional(),
language: z.string().optional(),
user_identifier: z.string().optional(),
});
const ZFeedbackRecordUpdateInput = z
.object({
value_text: z.string().optional().nullable(),
value_number: z.number().optional().nullable(),
value_boolean: z.boolean().optional().nullable(),
value_date: z.iso.datetime().optional().nullable(),
language: z.string().optional().nullable(),
metadata: ZFeedbackRecordMetadata.optional(),
user_identifier: z.string().optional().nullable(),
})
.refine(
(value) => Object.values(value).some((entry) => entry !== undefined),
"At least one field must be provided for update"
);
const ZRetrieveFeedbackRecordAction = z.object({
workspaceId: ZId,
recordId: ZFeedbackRecordId,
});
const ZCreateFeedbackRecordAction = z.object({
workspaceId: ZId,
recordInput: ZFeedbackRecordCreateInput,
});
const ZUpdateFeedbackRecordAction = z.object({
workspaceId: ZId,
recordId: ZFeedbackRecordId,
updateInput: ZFeedbackRecordUpdateInput,
});
const ensureAccess = async (
userId: string,
@@ -38,10 +102,14 @@ const ensureAccess = async (
});
};
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");
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");
}
};
@@ -53,16 +121,17 @@ export const retrieveFeedbackRecordAction = authenticatedActionClient
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TRetrieveFeedbackRecordAction;
parsedInput: z.infer<typeof ZRetrieveFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "read");
const recordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!recordResult.data || recordResult.error) {
throw new Error("Feedback record not found");
throw new Error(recordResult.error?.message || "Failed to retrieve feedback record");
}
assertRecordBelongsToWorkspace(parsedInput.workspaceId, recordResult.data.tenant_id);
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, recordResult.data.tenant_id);
return recordResult.data;
}
@@ -76,35 +145,16 @@ export const createFeedbackRecordAction = authenticatedActionClient
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TCreateFeedbackRecordAction;
parsedInput: z.infer<typeof ZCreateFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
assertRecordBelongsToWorkspace(parsedInput.workspaceId, parsedInput.recordInput.tenant_id);
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
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);
const createResult = await createFeedbackRecord(
parsedInput.recordInput as unknown as FeedbackRecordCreateParams
);
if (!createResult.data || createResult.error) {
throw new Error(createResult.error?.message || "Failed to create feedback record");
}
@@ -121,35 +171,23 @@ export const updateFeedbackRecordAction = authenticatedActionClient
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUpdateFeedbackRecordAction;
parsedInput: z.infer<typeof ZUpdateFeedbackRecordAction>;
}) => {
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!currentRecordResult.data || currentRecordResult.error) {
throw new Error("Feedback record not found");
throw new Error(currentRecordResult.error?.message || "Failed to retrieve feedback record");
}
assertRecordBelongsToWorkspace(parsedInput.workspaceId, currentRecordResult.data.tenant_id);
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
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 updatePayload = Object.fromEntries(
Object.entries(parsedInput.updateInput).filter(([, value]) => value !== undefined)
) as unknown as FeedbackRecordUpdateParams;
const updateResult = await updateFeedbackRecord(parsedInput.recordId, updateParams);
const updateResult = await updateFeedbackRecord(parsedInput.recordId, updatePayload);
if (!updateResult.data || updateResult.error) {
throw new Error(updateResult.error?.message || "Failed to update feedback record");
}
@@ -6,6 +6,8 @@ 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";
@@ -39,25 +41,7 @@ import {
createFeedbackRecordAction,
retrieveFeedbackRecordAction,
updateFeedbackRecordAction,
} 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";
} from "./actions";
type FeedbackRecordDrawerMode = "create" | "edit";
@@ -66,16 +50,210 @@ 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,
@@ -86,7 +264,7 @@ export const FeedbackRecordFormDrawer = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const defaultValues = useMemo(() => getCreateDefaults(workspaceId), [workspaceId]);
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
const form = useForm<TFeedbackRecordFormValues>({
resolver: zodResolver(ZFeedbackRecordFormValues),
@@ -109,12 +287,12 @@ export const FeedbackRecordFormDrawer = ({
const readOnlyMetadataEntries = useMemo(() => (record ? getReadOnlyMetadataEntries(record) : []), [record]);
const resetForCreate = useCallback(() => {
const nextDefaults = getCreateDefaults(workspaceId);
const nextDefaults = getCreateDefaults(directories);
form.reset(nextDefaults);
setRecord(null);
setSourceTypeMode(nextDefaults.source_type);
setCustomSourceType("");
}, [workspaceId, form]);
}, [directories, form]);
useEffect(() => {
if (!open) return;
@@ -133,20 +311,24 @@ 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));
const isPreset = isPresetSourceType(result.data.source_type);
setSourceTypeMode(isPreset ? result.data.source_type : SOURCE_TYPE_CUSTOM_VALUE);
setCustomSourceType(isPreset ? "" : result.data.source_type);
setSourceTypeMode(
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never)
? result.data.source_type
: SOURCE_TYPE_CUSTOM_VALUE
);
setCustomSourceType(
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never) ? "" : result.data.source_type
);
setIsLoadingRecord(false);
};
void loadRecord();
}, [form, mode, onOpenChange, open, recordId, resetForCreate, t, workspaceId]);
}, [form, mode, open, recordId, resetForCreate, t, workspaceId]);
const requestClose = useCallback(() => {
if (form.formState.isDirty && !isSubmitting) {
@@ -178,124 +360,116 @@ export const FeedbackRecordFormDrawer = ({
form.setError(selectedValueField, { type: "manual", message });
};
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;
};
const handleSubmit = form.handleSubmit(async (values) => {
form.clearErrors();
const buildMetadataMap = (values: TFeedbackRecordFormValues): Record<string, string> =>
Object.fromEntries(
if (mode === "create") {
const requiredValueError = t("workspace.unify.feedback_record_value_required");
if (selectedValueField === "value_text" && !values.value_text?.trim()) {
setStrictValueValidationError(requiredValueError);
return;
}
if (selectedValueField === "value_number" && parseNumberValue(values.value_number ?? "") == null) {
setStrictValueValidationError(requiredValueError);
return;
}
if (selectedValueField === "value_boolean" && values.value_boolean === undefined) {
setStrictValueValidationError(requiredValueError);
return;
}
if (selectedValueField === "value_date" && !toISOOrUndefined(values.value_date)) {
setStrictValueValidationError(requiredValueError);
return;
}
}
const metadata = Object.fromEntries(
values.metadataEntries
.map((entry) => ({ key: entry.key.trim(), value: entry.value }))
.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 {
const ok =
mode === "create" ? await submitCreate(values, metadata) : await submitUpdate(values, metadata);
if (!ok) return;
if (mode === "create") {
const sourceTypeValue =
sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE ? customSourceType.trim() : values.source_type;
const createResult = await createFeedbackRecordAction({
workspaceId,
recordInput: {
submission_id: values.submission_id.trim(),
tenant_id: values.tenant_id,
source_type: sourceTypeValue,
source_id: values.source_id?.trim() ? values.source_id.trim() : null,
source_name: values.source_name?.trim() ? values.source_name.trim() : null,
field_id: values.field_id.trim(),
field_label: values.field_label?.trim() ? values.field_label.trim() : null,
field_type: values.field_type,
field_group_id: values.field_group_id?.trim() || undefined,
field_group_label: values.field_group_label?.trim() ? values.field_group_label.trim() : null,
collected_at: toISOOrUndefined(values.collected_at),
value_text: selectedValueField === "value_text" ? (values.value_text ?? "") : null,
value_number:
selectedValueField === "value_number"
? (parseNumberValue(values.value_number ?? "") ?? undefined)
: undefined,
value_boolean: selectedValueField === "value_boolean" ? values.value_boolean : undefined,
value_date: selectedValueField === "value_date" ? toISOOrUndefined(values.value_date) : undefined,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
language: values.language?.trim() || undefined,
user_identifier: values.user_identifier?.trim() || undefined,
},
});
if (!createResult?.data) {
toast.error(getFormattedErrorMessage(createResult));
setIsSubmitting(false);
return;
}
} else {
if (!recordId) {
setIsSubmitting(false);
return;
}
const preservedMetadata = Object.fromEntries(
Object.entries(record?.metadata ?? {}).filter(([, value]) => typeof value !== "string")
);
const updatePayload: Record<string, unknown> = {
language: values.language?.trim() || null,
user_identifier: values.user_identifier?.trim() || null,
metadata: { ...preservedMetadata, ...metadata },
};
if (selectedValueField === "value_text") {
updatePayload.value_text = values.value_text?.trim() ?? "";
} else if (selectedValueField === "value_number") {
updatePayload.value_number = parseNumberValue(values.value_number ?? "");
} else if (selectedValueField === "value_boolean") {
updatePayload.value_boolean = values.value_boolean ?? null;
} else if (selectedValueField === "value_date") {
updatePayload.value_date = toISOOrUndefined(values.value_date) ?? null;
}
const updateResult = await updateFeedbackRecordAction({
workspaceId,
recordId,
updateInput: updatePayload as never,
});
if (!updateResult?.data) {
toast.error(getFormattedErrorMessage(updateResult));
setIsSubmitting(false);
return;
}
}
toast.success(
mode === "create"
@@ -359,9 +533,22 @@ export const FeedbackRecordFormDrawer = ({
name="tenant_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("workspace.unify.workspace")}</FormLabel>
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
<FormControl>
<Input {...field} disabled />
<Select value={field.value} onValueChange={field.onChange} disabled>
<SelectTrigger>
<SelectValue
placeholder={t("workspace.unify.select_feedback_record_directory")}
/>
</SelectTrigger>
<SelectContent>
{directories.map((directory) => (
<SelectItem key={directory.id} value={directory.id}>
{directory.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormError />
</FormItem>
@@ -456,7 +643,7 @@ export const FeedbackRecordFormDrawer = ({
<SelectContent>
{SOURCE_TYPE_PRESET_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{formatSourceType(option, t)}
{option}
</SelectItem>
))}
<SelectItem value={SOURCE_TYPE_CUSTOM_VALUE}>
@@ -4,12 +4,13 @@ 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;
}
@@ -17,6 +18,7 @@ interface FeedbackRecordsPageClientProps {
export function FeedbackRecordsPageClient({
workspaceId,
initialRecords,
frdMap,
csvSources,
canWrite,
}: Readonly<FeedbackRecordsPageClientProps>) {
@@ -31,6 +33,7 @@ export function FeedbackRecordsPageClient({
<FeedbackRecordsTable
workspaceId={workspaceId}
initialRecords={initialRecords}
frdMap={frdMap}
csvSources={csvSources}
canWrite={canWrite}
/>
@@ -12,7 +12,7 @@ import {
TypeIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
@@ -29,8 +29,7 @@ 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 { formatSourceType } from "../lib/utils";
import { CsvImportModal } from "../sources/components/csv-import-modal";
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
const RECORDS_PER_PAGE = 50;
@@ -55,6 +54,18 @@ const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string):
return "—";
};
const formatSourceType = (sourceType: string, t: TFunction): string => {
switch (sourceType) {
case "formbricks":
case "formbricks_survey":
return t("workspace.unify.formbricks_surveys");
case "csv":
return t("workspace.unify.csv_import");
default:
return sourceType;
}
};
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen) + "…";
@@ -63,6 +74,7 @@ function truncate(str: string, maxLen: number): string {
interface FeedbackRecordsTableProps {
workspaceId: string;
initialRecords: FeedbackRecordData[];
frdMap: Record<string, string>;
csvSources: { id: string; name: string }[];
canWrite: boolean;
}
@@ -70,6 +82,7 @@ interface FeedbackRecordsTableProps {
export const FeedbackRecordsTable = ({
workspaceId,
initialRecords,
frdMap,
csvSources,
canWrite,
}: Readonly<FeedbackRecordsTableProps>) => {
@@ -82,19 +95,51 @@ 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 result = await listFeedbackRecordsAction({
workspaceId,
limit: RECORDS_PER_PAGE,
});
const directoryIds = Object.keys(frdMap);
const results = await Promise.all(
directoryIds.map((frdId) =>
listFeedbackRecordsAction({
workspaceId,
frdId,
limit: RECORDS_PER_PAGE,
})
)
);
if (!result?.data) {
const errorMessage = getFormattedErrorMessage(result);
const successfulRecords = results.flatMap((result) => result?.data?.data ?? []);
if (directoryIds.length > 0 && successfulRecords.length === 0) {
const firstErrorResult = results.find((result) => !result?.data);
const errorMessage = firstErrorResult ? getFormattedErrorMessage(firstErrorResult) : undefined;
toast.error(errorMessage ?? t("workspace.unify.failed_to_load_feedback_records"), {
id: toastId,
});
@@ -102,8 +147,8 @@ export const FeedbackRecordsTable = ({
return;
}
const mergedRecords = (result.data.data ?? [])
.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
const mergedRecords = successfulRecords
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.slice(0, RECORDS_PER_PAGE);
setRecords(mergedRecords);
setIsRefreshing(false);
@@ -150,6 +195,7 @@ export const FeedbackRecordsTable = ({
<p className="text-sm text-slate-500">
{t("workspace.unify.showing_count_loaded", {
count: records.length,
directoryName: feedbackDirectoryName,
})}
</p>
)}
@@ -250,6 +296,7 @@ export const FeedbackRecordsTable = ({
open={isDrawerOpen}
onOpenChange={setIsDrawerOpen}
workspaceId={workspaceId}
directories={directories}
canWrite={canWrite}
recordId={drawerMode === "edit" ? drawerRecordId : undefined}
onSuccess={handleRefresh}
@@ -292,17 +339,8 @@ const FeedbackRecordRow = ({
return (
<tr
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();
}
}}>
className="cursor-pointer text-sm text-slate-700 transition-colors hover:bg-slate-50"
onClick={onClick}>
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
</td>
@@ -1,57 +0,0 @@
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>;
@@ -1,168 +0,0 @@
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");
});
});
@@ -1,158 +0,0 @@
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,9 +1,10 @@
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 "./components/feedback-records-page-client";
import { FeedbackRecordsPageClient } from "./feedback-records-page-client";
const INITIAL_PAGE_SIZE = 50;
@@ -26,14 +27,24 @@ export default async function UnifyFeedbackRecordsPage(
return notFound();
}
const [recordsResult, connectors] = await Promise.all([
listFeedbackRecords({ tenant_id: params.workspaceId, limit: INITIAL_PAGE_SIZE }),
const [frds, connectors] = await Promise.all([
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
getConnectorsWithMappings(params.workspaceId),
]);
// Don't crash if Hub is unreachable — show empty state
const initialRecords = recordsResult.error ? [] : (recordsResult.data?.data ?? []);
const results = await Promise.all(
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
);
// Don't crash if Hub is unreachable — show empty state
const successfulResults = results.filter((r) => !r.error);
const merged = successfulResults
.flatMap((r) => r.data?.data ?? [])
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.slice(0, INITIAL_PAGE_SIZE);
const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name]));
const csvSources = connectors
.filter((connector) => connector.type === "csv")
.map((connector) => ({ id: connector.id, name: connector.name }));
@@ -41,7 +52,8 @@ export default async function UnifyFeedbackRecordsPage(
return (
<FeedbackRecordsPageClient
workspaceId={params.workspaceId}
initialRecords={initialRecords}
initialRecords={merged}
frdMap={frdMap}
csvSources={csvSources}
canWrite={canWrite}
/>
@@ -1,80 +0,0 @@
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>;
@@ -0,0 +1,21 @@
import { FEEDBACK_RECORD_FIELDS, TFieldMapping } from "../types";
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
const requiredFieldIds = new Set(
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
);
for (const mapping of mappings) {
if (!requiredFieldIds.has(mapping.targetFieldId)) {
continue;
}
if (mapping.sourceFieldId || mapping.staticValue) {
requiredFieldIds.delete(mapping.targetFieldId);
}
}
return requiredFieldIds.size === 0;
};
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
@@ -13,6 +14,7 @@ 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";
@@ -26,22 +28,34 @@ 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> => {
@@ -50,6 +64,7 @@ export function ConnectorsSection({
connectorInput: {
name: data.name,
type: data.type,
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
},
formbricksMappings:
data.type === "formbricks_survey" && data.surveyMappings?.length ? data.surveyMappings : undefined,
@@ -172,6 +187,16 @@ 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
@@ -180,6 +205,7 @@ export function ConnectorsSection({
onCreateConnector={handleCreateConnector}
surveys={initialSurveys}
workspaceId={workspaceId}
directories={directories}
showTrigger={false}
/>
@@ -71,7 +71,7 @@ export function ConnectorsTableDataRow({
}
return t("workspace.unify.status_live_sync");
case "paused":
return t("common.disabled");
return t("workspace.unify.status_paused");
case "error":
return t("workspace.unify.status_error");
}
@@ -27,7 +27,7 @@ export function ConnectorsTable({
const { t } = useTranslation();
return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">{t("common.type")}</div>
<div className="col-span-5">{t("common.name")}</div>
@@ -7,6 +7,7 @@ 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,
@@ -33,7 +34,6 @@ 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,23 +42,14 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { Switch } from "@/modules/ui/components/switch";
import {
TCreateConnectorStep,
TFieldMapping,
TFormbricksConnectorForm,
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
} from "../types";
import { TCreateConnectorStep, TFieldMapping, TSourceField, TUnifySurvey } 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";
@@ -70,11 +61,13 @@ 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 = (
@@ -107,6 +100,15 @@ 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))
@@ -119,6 +121,7 @@ export const CreateConnectorModal = ({
onCreateConnector,
surveys,
workspaceId,
directories,
}: CreateConnectorModalProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -152,6 +155,7 @@ 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;
@@ -222,6 +226,7 @@ export const CreateConnectorModal = ({
setCsvConnectorName("");
setIsImporting(false);
setIsCreating(false);
setSelectedDirectoryId(directories[0]?.id ?? null);
};
const handleOpenChange = (newOpen: boolean) => {
@@ -316,7 +321,11 @@ export const CreateConnectorModal = ({
};
const handleFormbricksQuestionToggle = (questionId: string) => {
const nextSelection = toggleQuestionId(formbricksForm.getValues("selectedQuestionIds"), questionId);
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
const isSelected = currentSelection.includes(questionId);
const nextSelection = isSelected
? currentSelection.filter((id) => id !== questionId)
: [...currentSelection, questionId];
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
shouldDirty: true,
shouldValidate: true,
@@ -324,11 +333,13 @@ 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 }],
});
@@ -342,7 +353,7 @@ export const CreateConnectorModal = ({
};
const handleCreateCsvConnector = async () => {
if (!isConnectorNameValid(csvConnectorName)) return;
if (!selectedDirectoryId || !isConnectorNameValid(csvConnectorName)) return;
if (csvParsedData.length > 0) {
const errors = validateEnumMappings(mappings, csvParsedData);
if (errors.length > 0) {
@@ -357,6 +368,7 @@ export const CreateConnectorModal = ({
const connectorId = await onCreateConnector({
name: csvConnectorName.trim(),
type: "csv",
feedbackRecordDirectoryId: selectedDirectoryId,
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
@@ -432,6 +444,10 @@ export const CreateConnectorModal = ({
)}
/>
{directories.length === 0 && (
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
)}
<FormField
control={formbricksForm.control}
name="surveyId"
@@ -503,7 +519,9 @@ export const CreateConnectorModal = ({
{currentStep === "mapping" && selectedType === "csv" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
<label htmlFor="connectorName" className="text-sm font-medium text-slate-700">
{t("workspace.unify.source_name")}
</label>
<Input
id="connectorName"
value={csvConnectorName}
@@ -512,6 +530,10 @@ 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}
@@ -579,6 +601,7 @@ export const CreateConnectorModal = ({
disabled={
isCreating ||
isImporting ||
!selectedDirectoryId ||
(selectedType === "formbricks_survey"
? !isConnectorNameValid(formbricksValues.sourceName ?? "") ||
!formbricksValues.surveyId ||
@@ -595,3 +618,23 @@ export const CreateConnectorModal = ({
</>
);
};
interface NoFeedbackRecordDirectoryAlertProps {
workspaceId: string;
t: (key: string) => string;
}
const NoFeedbackRecordDirectoryAlert = ({ workspaceId, t }: NoFeedbackRecordDirectoryAlertProps) => {
return (
<Alert variant="error" size="small">
<div>
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
<a
className="mt-1 inline-block font-medium underline"
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.unify.go_to_feedback_record_directories")}
</a>
</div>
</Alert>
);
};
@@ -4,6 +4,7 @@ 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 {
@@ -23,7 +24,6 @@ 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,21 +31,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import {
SAMPLE_CSV_COLUMNS,
TFieldMapping,
TFormbricksConnectorForm,
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
} from "../types";
import {
areAllRequiredFieldsMapped,
isConnectorNameValid,
parseCSVColumnsToFields,
toggleQuestionId,
} from "../utils";
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
import { parseCSVColumnsToFields } from "../utils";
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
import { FormbricksQuestionList } from "./formbricks-question-list";
import { MappingUI } from "./mapping-ui";
@@ -64,6 +53,15 @@ 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,
@@ -78,8 +76,8 @@ export const EditConnectorModal = ({
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [isUpdating, setIsUpdating] = useState(false);
const formbricksForm = useForm<TFormbricksConnectorForm>({
resolver: zodResolver(ZFormbricksConnectorForm),
const formbricksForm = useForm<TFormbricksEditConnectorForm>({
resolver: zodResolver(ZFormbricksEditConnectorForm),
defaultValues: {
sourceName: "",
surveyId: "",
@@ -171,7 +169,7 @@ export const EditConnectorModal = ({
onOpenChange(newOpen);
};
const handleUpdateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
const handleUpdateFormbricksConnector = async (values: TFormbricksEditConnectorForm) => {
if (connector?.type !== "formbricks_survey") return;
setIsUpdating(true);
await onUpdateConnector({
@@ -200,7 +198,11 @@ export const EditConnectorModal = ({
};
const handleFormbricksQuestionToggle = (questionId: string) => {
const nextSelection = toggleQuestionId(formbricksForm.getValues("selectedQuestionIds"), questionId);
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
const isSelected = currentSelection.includes(questionId);
const nextSelection = isSelected
? currentSelection.filter((id) => id !== questionId)
: [...currentSelection, questionId];
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
shouldDirty: true,
shouldValidate: true,
@@ -322,7 +324,9 @@ export const EditConnectorModal = ({
</div>
<div className="space-y-2">
<Label htmlFor="editConnectorName">{t("workspace.unify.source_name")}</Label>
<label htmlFor="editConnectorName" className="text-sm font-medium text-slate-700">
{t("workspace.unify.source_name")}
</label>
<Input
id="editConnectorName"
value={csvConnectorName}
@@ -80,14 +80,6 @@ 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",
@@ -218,12 +210,3 @@ 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,13 +1,6 @@
import { describe, expect, test } from "vitest";
import { MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
import {
areAllRequiredFieldsMapped,
getConnectorOptions,
isConnectorNameValid,
parseCSVColumnsToFields,
toggleQuestionId,
validateCsvFile,
} from "./utils";
import { MAX_CSV_VALUES, TSourceField } from "./types";
import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } from "./utils";
const mockT = (key: string) => key;
@@ -122,111 +115,3 @@ 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,32 +90,6 @@ 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
@@ -0,0 +1,318 @@
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: {} });
};
@@ -0,0 +1,12 @@
import { z } from "zod";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
import { ZResponse } from "@formbricks/types/responses";
export const ZPipelineInput = z.object({
event: ZWebhook.shape.triggers.element,
response: ZResponse,
workspaceId: z.string(),
surveyId: z.string(),
});
export type TPipelineInput = z.infer<typeof ZPipelineInput>;
@@ -254,7 +254,7 @@ export const putResponseHandler = async ({
const { quotaFull, ...responseData } = updatedResponse;
await sendToPipeline({
sendToPipeline({
event: "responseUpdated",
workspaceId: survey.workspaceId,
surveyId: survey.id,
@@ -262,7 +262,7 @@ export const putResponseHandler = async ({
});
if (updatedResponse.finished) {
await sendToPipeline({
sendToPipeline({
event: "responseFinished",
workspaceId: survey.workspaceId,
surveyId: survey.id,
@@ -186,7 +186,7 @@ export const POST = withV1ApiWrapper({
const { quotaFull, ...responseData } = response;
await sendToPipeline({
sendToPipeline({
event: "responseCreated",
workspaceId,
surveyId: responseData.surveyId,
@@ -194,7 +194,7 @@ export const POST = withV1ApiWrapper({
});
if (responseInput.finished) {
await sendToPipeline({
sendToPipeline({
event: "responseFinished",
workspaceId,
surveyId: responseData.surveyId,
@@ -169,7 +169,7 @@ export const PUT = withV1ApiWrapper({
auditLog.newObject = updated;
}
await sendToPipeline({
sendToPipeline({
event: "responseUpdated",
workspaceId: result.survey.workspaceId,
surveyId: result.survey.id,
@@ -177,7 +177,7 @@ export const PUT = withV1ApiWrapper({
});
if (updated.finished) {
await sendToPipeline({
sendToPipeline({
event: "responseFinished",
workspaceId: result.survey.workspaceId,
surveyId: result.survey.id,
@@ -165,7 +165,7 @@ export const POST = withV1ApiWrapper({
auditLog.newObject = response;
}
await sendToPipeline({
sendToPipeline({
event: "responseCreated",
workspaceId: surveyResult.survey.workspaceId,
surveyId: response.surveyId,
@@ -173,7 +173,7 @@ export const POST = withV1ApiWrapper({
});
if (response.finished) {
await sendToPipeline({
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;
await sendToPipeline({
sendToPipeline({
event: "responseCreated",
workspaceId,
surveyId: responseData.surveyId,
@@ -245,7 +245,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
});
if (responseData.finished) {
await sendToPipeline({
sendToPipeline({
event: "responseFinished",
workspaceId,
surveyId: responseData.surveyId,
+86 -57
View File
@@ -1,84 +1,113 @@
import { PipelineTriggers } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TResponsePipelineJobData, getBackgroundJobProducer } from "@formbricks/jobs";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TResponse } from "@formbricks/types/responses";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getJobsQueueingConfig } from "@/lib/jobs/config";
import { TPipelineInput } from "@/app/lib/types/pipelines";
import { sendToPipeline } from "./pipelines";
const mockEnqueueResponsePipeline = vi.fn();
vi.mock("@formbricks/jobs", () => ({
getBackgroundJobProducer: vi.fn(() => ({
enqueueResponsePipeline: mockEnqueueResponsePipeline,
})),
}));
vi.mock("@/lib/jobs/config", () => ({
getJobsQueueingConfig: vi.fn(),
// Mock the constants module
vi.mock("@/lib/constants", () => ({
CRON_SECRET: "mocked-cron-secret",
WEBAPP_URL: "https://test.formbricks.com",
}));
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("sendToPipeline", () => {
const testData: TResponsePipelineJobData = {
event: PipelineTriggers.responseCreated,
surveyId: "cm8ckvchx000008lb710n0gdn",
workspaceId: "cm8cmp9hp000008jf7l570ml2",
response: { id: "cm8cmpnjj000108jfdr9dfqe6" } as TResponse,
};
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("pipelines", () => {
// Reset mocks before each test
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getJobsQueueingConfig).mockReturnValue({
enabled: true,
redisUrl: "redis://localhost:6379",
});
});
test("enqueues the pipeline job through the BullMQ producer", async () => {
mockEnqueueResponsePipeline.mockResolvedValue({
jobId: "job-1",
jobName: "response-pipeline.process",
queueName: "background-jobs",
// 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 }),
});
// 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);
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,
surveyId: testData.surveyId,
workspaceId: testData.workspaceId,
// 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",
},
"Error queueing pipeline event"
);
body: JSON.stringify({
workspaceId: testData.workspaceId,
surveyId: testData.surveyId,
event: testData.event,
response: testData.response,
}),
});
});
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);
await expect(sendToPipeline(testData)).rejects.toThrow(
"BullMQ response pipeline queueing is not enabled"
);
expect(getBackgroundJobProducer).not.toHaveBeenCalled();
// 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");
});
});
+21 -17
View File
@@ -1,21 +1,25 @@
import { TResponsePipelineJobData, getBackgroundJobProducer } from "@formbricks/jobs";
import { logger } from "@formbricks/logger";
import { getJobsQueueingConfig } from "@/lib/jobs/config";
import { TPipelineInput } from "@/app/lib/types/pipelines";
import { CRON_SECRET, WEBAPP_URL } from "@/lib/constants";
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;
export const sendToPipeline = async ({ event, surveyId, workspaceId, response }: TPipelineInput) => {
if (!CRON_SECRET) {
throw new Error("CRON_SECRET is not set");
}
return fetch(`${WEBAPP_URL}/api/pipeline`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": CRON_SECRET,
},
body: JSON.stringify({
workspaceId,
surveyId,
event,
response,
}),
}).catch((error) => {
logger.error(error, "Error sending event to pipeline");
});
};
+9
View File
@@ -0,0 +1,9 @@
import { PipelineTriggers } from "@prisma/client";
import { TResponse } from "@formbricks/types/responses";
export interface TPipelineInput {
event: PipelineTriggers;
response: TResponse;
workspaceId: string;
surveyId: string;
}
-3
View File
@@ -21,9 +21,6 @@ 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",
},
});
}
+4 -60
View File
@@ -98,7 +98,6 @@ checksums:
common/activity: 1948763de8e531483a798b68195e297e
common/add: 87c4a663507f2bcbbf79934af8164e13
common/add_action: 66fefc4dd6a7b939c2224272cf0d2669
common/add_chart: 0c8539d3ccc83fce87bb1e0dc3e30005
common/add_charts: c377a42e165e8ab67bfbb8ad72026dd8
common/add_existing_chart_description: b1292a1d6df2e03ad7b399689312c37f
common/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
@@ -121,7 +120,6 @@ 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
@@ -187,7 +185,6 @@ checksums:
common/delete_what: 718ddfcc1dec7f3e8b67856fba838267
common/description: e17686a22ffad04cc7bb70524ed4478b
common/disable: 81b754fd7962e0bd9b6ba87f3972e7fc
common/disabled: 0889a3dfd914a7ef638611796b17bf72
common/disallow: 01c8ed3ce545ed836d3ccffc562c8a0c
common/discard: de83a114a79d086e372c43dbfe9f47b4
common/dismissed: f0e21b3fe28726c577a7238a63cc29c2
@@ -307,7 +304,6 @@ checksums:
common/not_authenticated: fed6c62208524ea6782b5f9c07a95a4f
common/not_authorized: 4be80383fe1a6f52c61138f1aa8d01d4
common/not_connected: 91ebf07fff6b2ead94d85bd17212e0ba
common/not_set: 380482630d60ee2d1531b31246caa467
common/note: e0337f202c911423275f834edeffc54b
common/notifications: c52df856139b50dbb1cae7bfb1cf73bb
common/number: 2789f8391f63e7200a5521078aab017d
@@ -367,7 +363,6 @@ checksums:
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_trial_license: 560df1240ef621f7c60d3f7d65422ccd
common/reset_to_default: 68ee98b46677392f44b505b268053b26
common/resize: 20887e5af5294f08bc72cdedeee6e7a8
common/response: c7a9d88269d8ff117abcbc0d97f88b2c
common/response_id: 73375099cc976dc7203b8e27f5f709e0
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
@@ -409,7 +404,6 @@ checksums:
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
common/soon: b12e79beb0aef9414a445a1b95dd4322
common/sort_by: 8adf3dbc5668379558957662f0c43563
common/start_free_trial: e346e4ed7d138dcc873db187922369da
common/status: 4e1fcce15854d824919b4a582c697c90
@@ -1661,7 +1655,6 @@ 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
@@ -1723,10 +1716,8 @@ checksums:
workspace/analysis/charts/please_select_dashboard: 8f062db96f815ed8268584dd8d292fa6
workspace/analysis/charts/predefined_measures: 7651141f62c991954edcff70899b2a8b
workspace/analysis/charts/preset: a17bb0bf56f3326c9567be3ea896ee19
workspace/analysis/charts/preview_chart: 8db30f87ba44165401f340a1ee7f549b
workspace/analysis/charts/query_executed_successfully: 9d6f9dad526fcfe0161757c2d2fe2c69
workspace/analysis/charts/reset_to_ai_suggestion: 51ced8dd7c0eea8b7fc4e08b35cfbf30
workspace/analysis/charts/save_and_add_to_dashboard: a76ed91c62dae10c5f8a9d48cbacd566
workspace/analysis/charts/save_chart: 2e4505f7bf3d1c35b0b37b1e9d3dc566
workspace/analysis/charts/save_chart_dialog_title: 2e4505f7bf3d1c35b0b37b1e9d3dc566
workspace/analysis/charts/select_data_source: 983394bc0182b65ec68f713a46b97302
@@ -1740,7 +1731,6 @@ checksums:
workspace/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84
workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
workspace/analysis/charts/update_chart: 7d9223335d9f0c5938ec30356d7034a9
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
@@ -1749,7 +1739,6 @@ checksums:
workspace/analysis/dashboards/create_dashboard: bedb308708fe9c576e161a2fa16d3439
workspace/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
workspace/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
workspace/analysis/dashboards/create_new_chart: e03c0fdf4b861454c09707d66fb9bf4c
workspace/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
workspace/analysis/dashboards/dashboard: c9380ea68c8c76ea451bd9613329a07c
workspace/analysis/dashboards/dashboard_delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
@@ -1764,13 +1753,11 @@ checksums:
workspace/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
workspace/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
workspace/analysis/dashboards/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
workspace/analysis/dashboards/no_charts_available_description: 796ed01bcb53f770e5f627002839dcb4
workspace/analysis/dashboards/no_charts_to_add_message: ad4cec703aa7d59c407bbb021dce4273
workspace/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
workspace/analysis/dashboards/no_data_message: 464d50cf30281a5b6af2726846eb14b4
workspace/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
workspace/analysis/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8
workspace/analysis/no_feedback_records_message: 67d6ebb9c040304789017d795ca474fc
workspace/analysis/no_feedback_records_with_sources_message: 4b72636a55afb4dcf977161ad5a15467
workspace/analysis/setup_feedback_source: 7cc5855a2b0c762fe2ae13b4921f3e92
workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da
workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985
workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c
@@ -1799,9 +1786,6 @@ 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
@@ -2454,18 +2438,13 @@ 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
workspace/settings/feedback_record_directories/pause_connectors_confirmation_description: a3c2c56daed9f2a9e6a853cb8b924bad
workspace/settings/feedback_record_directories/pause_connectors_confirmation_title: 09041363c55fb2686f8115df6fa2afc1
workspace/settings/feedback_record_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
workspace/settings/feedback_record_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
workspace/settings/feedback_record_directories/title: e3d425c27f80162f29ce094e31a3fd8f
workspace/settings/feedback_record_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
workspace/settings/feedback_record_directories/unarchive_workspace_conflict: 82f4b8ebaf41589cfb96e6398dafcc76
workspace/settings/feedback_record_directories/workspace_access: 32407b39cf878fb579559c1ed3660892
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
workspace/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
workspace/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
@@ -3474,15 +3453,12 @@ checksums:
workspace/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
workspace/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
workspace/teams/team_settings_description: 52f91883b9ceb6de83efbf8efd4f11c0
workspace/unify/add_feedback_record: 19cf2b1fef0ca1400f2400e7ee681ea0
workspace/unify/add_feedback_record_description: 94bca46246ba7353049b33742554b4c0
workspace/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
workspace/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
workspace/unify/api_ingestion: a14642d27bbb6843f9f4903b6555dfbb
workspace/unify/api_ingestion_manage_api_keys: 116786a004fb7b16ead8a5b7a6a2debe
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
workspace/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
workspace/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
@@ -3507,12 +3483,8 @@ checksums:
workspace/unify/csv_import_duplicate_warning: 56625e4613b93690e95661e5faaa4b27
workspace/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
workspace/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
workspace/unify/custom_source_type: d931a8a74d3a5becd568e398107979da
workspace/unify/custom_source_type_placeholder: f139e3e5d70dbf426d7c6b5ab2b198cc
workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941
workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
workspace/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9
@@ -3522,23 +3494,15 @@ checksums:
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
workspace/unify/feedback_record_created_successfully: 0ff30472085f1313a5ad53837c83e7c1
workspace/unify/feedback_record_details: 823f3353db049a9d263ef31405054cda
workspace/unify/feedback_record_details_description: 0b6f908154161241ce6bdeb4a2acaecd
workspace/unify/feedback_record_directory: 89a08a540d1c6eb9f0b1a4b8f56e8aca
workspace/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
workspace/unify/feedback_record_mcp: cdddbef2944489820fd7f376a49c2803
workspace/unify/feedback_record_updated_successfully: cb40ef4b924e21fa627ebe6809d1d826
workspace/unify/feedback_record_value_required: b54d4d86f82071a93dc979e8eb359cf0
workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388
workspace/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
workspace/unify/feedback_sources: e58ec9be19db8789e7096a756d24f2b2
workspace/unify/feedback_sources_directory_access_multiple: 11d613bc1e9825aa6faa3db17ae678eb
workspace/unify/feedback_sources_directory_access_single: c9da6b30d410a0ca6302a00a5747dc19
workspace/unify/feedback_sources_settings_description: 45f162f2f81cd195c23cb3ec490bb3df
workspace/unify/field_group_id: 17024bb46ff1e088afb6a279dc85aad4
workspace/unify/field_group_label: 3df09c3b6fd22310359cf955ecff5c8e
workspace/unify/field_id: 7791b5d581b7a525dcadf11ec73c6ab7
workspace/unify/field_label: 6384505ca0e40010c666b712511132a6
workspace/unify/field_type: 2581066dc304c853a4a817c20996fa08
workspace/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
@@ -3549,18 +3513,12 @@ checksums:
workspace/unify/import_historical_responses: d7941f65344b6bfba56a40cc53a063b4
workspace/unify/import_historical_responses_description: c860f7c6dbe8b74383ecf9cae9c219a0
workspace/unify/import_rows: d2963498a7d2766264c4d67db677e8ff
workspace/unify/import_via_source_name: eae32ae2fc87f925ca016fe8283bcbfd
workspace/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984
workspace/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20
workspace/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6
workspace/unify/invalid_values_found: 5011dc9c0294a222033f9910ea919b8a
workspace/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
workspace/unify/manage_directories: 460e00e1cbf1f51de57a2548546e33d7
workspace/unify/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8
workspace/unify/metadata: 695d4f7da261ba76e3be4de495491028
workspace/unify/metadata_key: c478d228673f59fa556208ece60452f6
workspace/unify/metadata_read_only_entries: 1934fee46c0a117f4926b61cc3d2d602
workspace/unify/metadata_value: 8d69be1f5a20d9473a33c35670dff216
workspace/unify/missing_feedback_source_title: 9ab1b8d54b4da72dd00ce03fe3b698b5
workspace/unify/no_feedback_record_directory_available: b8126ef5d6276d9655a9b27ffcaca824
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
@@ -3577,7 +3535,6 @@ checksums:
workspace/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
workspace/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
workspace/unify/select_feedback_record_directory: 88afbf2c2a322249908ee5d00ec5f65d
workspace/unify/select_feedback_record_source_type: 10997fcbea2f93e756888cf7a7476fdf
workspace/unify/select_questions: 13c79b8c284423eb6140534bf2137e56
workspace/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc
workspace/unify/select_survey: bac52e59c7847417bef6fe7b7096b475
@@ -3592,24 +3549,15 @@ checksums:
workspace/unify/source_connect_feedback_record_mcp_description: a3f56e2a6e403f4021e83f1b1a466d95
workspace/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
workspace/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
workspace/unify/source_id: 134a9a7d473508c5623ac724a5ba4be9
workspace/unify/source_name: 157675beca12efcd8ec512c5256b1a61
workspace/unify/source_type: d1ff69af76c687eb189db72030717570
workspace/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
workspace/unify/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/sources: ecbbe6e49baa335c5afd7b04b609d006
workspace/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
workspace/unify/status_live_sync: 7e794257419414f57d34845ef38d0939
workspace/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
workspace/unify/status_ready: 437c0eea608e15ad5cdab94bde2f4b48
workspace/unify/submission_id: 02edf76883b47079dbe20f3f36b7c1a7
workspace/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d
workspace/unify/topics_and_subtopics: 1148eca01a1993fadca932efcdea7641
workspace/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
workspace/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
workspace/unify/updated_at: 8fdb85248e591254973403755dcc3724
@@ -3617,10 +3565,6 @@ checksums:
workspace/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
workspace/unify/user_identifier: 61073457a5c3901084b557d065f876be
workspace/unify/value: 34b0eaa85808b15cbc4be94c64d0146b
workspace/unify/value_boolean: bbdcd3f46954b6304b9069e94e1371ab
workspace/unify/value_date: c8d705d1975affc01c002324725fec3f
workspace/unify/value_number: 1f14da79d14bd7b1c2324141f4470675
workspace/unify/value_text: e097a597cc507c716401ad18255de578
workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9
workspace/xm-templates/ces_description: c8d9794dd17d5ab85a979f1b3e1bc935
workspace/xm-templates/csat: fdfc1dc6214cce661dcdc32a71d80337
+7 -7
View File
@@ -39,12 +39,12 @@ vi.mock("@formbricks/logger", () => ({
vi.mock("@/lib/env", () => ({
env: {
AI_PROVIDER: "google",
AI_PROVIDER: "gcp",
AI_MODEL: "gemini-2.5-flash",
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_GCP_PROJECT: "vertex-project",
AI_GCP_LOCATION: "us-central1",
AI_GCP_CREDENTIALS_JSON: undefined,
AI_GCP_APPLICATION_CREDENTIALS: "/tmp/vertex.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: "google",
AI_PROVIDER: "gcp",
AI_MODEL: "gemini-2.5-flash",
AI_GOOGLE_CLOUD_PROJECT: "google-cloud-project",
AI_GCP_PROJECT: "vertex-project",
})
);
});
+40 -5
View File
@@ -1,6 +1,7 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import {
@@ -23,6 +24,7 @@ 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";
@@ -123,15 +125,23 @@ const ZFormbricksSurveyMapping = z.object({
elementIds: z.array(z.string()).min(1),
});
// Temporary compatibility to support legacy client payloads using `formbricks`.
const ZConnectorCreateInputWithLegacyType = ZConnectorCreateInput.extend({
type: z.enum(["formbricks_survey", "csv", "formbricks"]),
});
const ZCreateConnectorWithMappingsAction = z
.object({
workspaceId: ZId,
connectorInput: ZConnectorCreateInput,
connectorInput: ZConnectorCreateInputWithLegacyType,
formbricksMappings: z.array(ZFormbricksSurveyMapping).optional(),
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
})
.superRefine((data, ctx) => {
if (data.connectorInput.type === "formbricks_survey") {
const normalizedType =
data.connectorInput.type === "formbricks" ? "formbricks_survey" : data.connectorInput.type;
if (normalizedType === "formbricks_survey") {
if (!data.formbricksMappings?.length) {
ctx.addIssue({
code: "custom",
@@ -139,7 +149,7 @@ const ZCreateConnectorWithMappingsAction = z
message: "At least one survey mapping is required for Formbricks connectors",
});
}
} else if (data.connectorInput.type === "csv") {
} else if (normalizedType === "csv") {
if (!data.fieldMappings?.length) {
ctx.addIssue({
code: "custom",
@@ -153,6 +163,14 @@ const ZCreateConnectorWithMappingsAction = z
export const createConnectorWithMappingsAction = authenticatedActionClient
.inputSchema(ZCreateConnectorWithMappingsAction)
.action(async ({ ctx, parsedInput }): Promise<TConnectorWithMappings> => {
const connectorInput = ZConnectorCreateInput.parse({
...parsedInput.connectorInput,
type:
parsedInput.connectorInput.type === "formbricks"
? "formbricks_survey"
: parsedInput.connectorInput.type,
});
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -170,6 +188,15 @@ 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;
@@ -191,7 +218,7 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
return createConnectorWithMappings(
parsedInput.workspaceId,
{ ...parsedInput.connectorInput, createdBy: ctx.user.id },
{ ...connectorInput, createdBy: ctx.user.id },
mappingsInput
);
});
@@ -322,6 +349,7 @@ export const duplicateConnectorAction = authenticatedActionClient
{
name: `${source.name} (copy)`,
type: source.type,
feedbackRecordDirectoryId: source.feedbackRecordDirectoryId,
createdBy: ctx.user.id,
},
mappingsInput
@@ -464,6 +492,7 @@ export const importCsvDataAction = authenticatedActionClient
const ZListFeedbackRecordsAction = z.object({
workspaceId: ZId,
frdId: ZId,
limit: z.number().min(1).max(1000).optional(),
cursor: z.string().optional(),
sourceType: z.string().optional(),
@@ -501,8 +530,14 @@ 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.workspaceId,
tenant_id: parsedInput.frdId,
limit: parsedInput.limit ?? 50,
};
if (parsedInput.cursor) params.cursor = parsedInput.cursor;
+1 -1
View File
@@ -22,7 +22,7 @@ export const importCsvData = async (
const { records, skipped } = transformCsvRowsToFeedbackRecords(
csvRows,
connector.fieldMappings,
connector.workspaceId
connector.feedbackRecordDirectoryId
);
let successes = 0;
+15 -86
View File
@@ -3,7 +3,6 @@ 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,
@@ -35,7 +34,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
timestamp: "2026-01-15T10:00:00Z",
};
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
expect(result).not.toBeNull();
expect(result!.source_type).toBe("survey");
@@ -43,77 +42,13 @@ 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, 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);
const result = transformCsvRowToFeedbackRecord(row, mappings);
expect(result).toBeNull();
});
@@ -126,7 +61,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
rating: "4.5",
};
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
const result = transformCsvRowToFeedbackRecord(row, mappings);
expect(result!.value_number).toBe(4.5);
});
@@ -139,7 +74,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
rating: "not-a-number",
};
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
const result = transformCsvRowToFeedbackRecord(row, mappings);
expect(result!.value_number).toBeUndefined();
});
@@ -149,24 +84,21 @@ describe("transformCsvRowToFeedbackRecord", () => {
expect(
transformCsvRowToFeedbackRecord(
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "true" },
mappings,
TENANT
mappings
)!.value_boolean
).toBe(true);
expect(
transformCsvRowToFeedbackRecord(
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "0" },
mappings,
TENANT
mappings
)!.value_boolean
).toBe(false);
expect(
transformCsvRowToFeedbackRecord(
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "yes" },
mappings,
TENANT
mappings
)!.value_boolean
).toBe(true);
});
@@ -182,7 +114,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
makeMapping("", "collected_at", "$now"),
];
const result = transformCsvRowToFeedbackRecord({ question: "q1" }, mappings, TENANT);
const result = transformCsvRowToFeedbackRecord({ question: "q1" }, mappings);
expect(result!.collected_at).toBe(NOW.toISOString());
vi.useRealTimers();
@@ -197,7 +129,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
];
const row = { question: "q1", type_column: "review", timestamp: "2026-01-15" };
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
const result = transformCsvRowToFeedbackRecord(row, mappings);
expect(result!.source_type).toBe("always_survey");
});
@@ -208,7 +140,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
timestamp: "2026-01-15T10:00:00Z",
};
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
expect(result!.value_text).toBeUndefined();
});
@@ -221,7 +153,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
meta: '{"device":"mobile","version":"2.1"}',
};
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
const result = transformCsvRowToFeedbackRecord(row, mappings);
expect(result!.metadata).toEqual({ device: "mobile", version: "2.1" });
});
@@ -234,7 +166,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
meta: "just a string",
};
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
const result = transformCsvRowToFeedbackRecord(row, mappings);
expect(result!.metadata).toEqual({ raw: "just a string" });
});
@@ -245,7 +177,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
timestamp: "not-a-date",
};
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
expect(result!.collected_at).toBeUndefined();
});
});
@@ -266,19 +198,16 @@ describe("transformCsvRowsToFeedbackRecords", () => {
makeMapping("timestamp", "collected_at"),
];
const { records, skipped } = transformCsvRowsToFeedbackRecords(rows, mappings, TENANT);
const { records, skipped } = transformCsvRowsToFeedbackRecords(rows, mappings);
expect(records).toHaveLength(2);
expect(skipped).toBe(1);
expect(records[0].field_id).toBe("q1");
expect(records[1].field_id).toBe("q2");
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, TENANT);
const { records, skipped } = transformCsvRowsToFeedbackRecords([], baseMappings);
expect(records).toHaveLength(0);
expect(skipped).toBe(0);
});
+2 -17
View File
@@ -1,4 +1,3 @@
import { randomUUID } from "crypto";
import { TConnectorFieldMapping, THubTargetField } from "@formbricks/types/connector";
import { FeedbackRecordCreateParams } from "@/modules/hub";
@@ -51,10 +50,8 @@ const resolveValue = (
/**
* Transform a single CSV row into a FeedbackRecord using field mappings.
*
* 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.
* Each mapping maps a CSV column (sourceFieldId) or a static value to a target field.
* Returns null if required fields (source_type, field_id, field_type) are missing after mapping.
*/
export const transformCsvRowToFeedbackRecord = (
row: Record<string, string>,
@@ -86,18 +83,6 @@ 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;
};
+6 -1
View File
@@ -50,7 +50,12 @@ 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.workspaceId);
const batch = await processBatch(
responses,
survey,
connector.formbricksMappings,
connector.feedbackRecordDirectoryId
);
successes += batch.successes;
failures += batch.failures;
skipped += batch.skipped;
@@ -56,6 +56,7 @@ function createConnector(
type: "formbricks_survey",
status: "active",
workspaceId: "env-1",
feedbackRecordDirectoryId: "frd-1",
lastSyncAt: null,
formbricksMappings: [
{
@@ -119,7 +120,7 @@ describe("handleConnectorPipeline", () => {
mockResponse,
mockSurvey,
connector.formbricksMappings,
"env-1"
"frd-1"
);
expect(mockCreateFeedbackRecordsBatch).not.toHaveBeenCalled();
expect(updateConnector).not.toHaveBeenCalled();
+3 -3
View File
@@ -34,14 +34,14 @@ const logFailedRecords = (
const processConnector = async (
connector: TConnectorWithMappings,
response: TResponse,
survey: Pick<TSurvey, "id" | "name" | "blocks">,
survey: TSurvey,
workspaceId: string
): Promise<void> => {
const feedbackRecords = transformResponseToFeedbackRecords(
response,
survey,
connector.formbricksMappings,
connector.workspaceId
connector.feedbackRecordDirectoryId
);
if (feedbackRecords.length === 0) {
@@ -94,7 +94,7 @@ const processConnector = async (
*/
export const handleConnectorPipeline = async (
response: TResponse,
survey: Pick<TSurvey, "id" | "name" | "blocks">,
survey: TSurvey,
workspaceId: string
): Promise<void> => {
try {
+9 -5
View File
@@ -39,6 +39,7 @@ 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 = {
@@ -303,6 +304,7 @@ describe("createConnectorWithMappings", () => {
const result = await createConnectorWithMappings(ENV_ID, {
name: "New",
type: "formbricks_survey",
feedbackRecordDirectoryId: FRD_ID,
});
expect(tx.connector.create).toHaveBeenCalledWith(
@@ -311,6 +313,7 @@ describe("createConnectorWithMappings", () => {
name: "New",
type: "formbricks_survey",
workspaceId: ENV_ID,
feedbackRecordDirectoryId: FRD_ID,
},
})
);
@@ -327,7 +330,7 @@ describe("createConnectorWithMappings", () => {
await createConnectorWithMappings(
ENV_ID,
{ name: "FB", type: "formbricks_survey" },
{ name: "FB", type: "formbricks_survey", feedbackRecordDirectoryId: FRD_ID },
{
type: "formbricks_survey",
mappings: [
@@ -363,7 +366,7 @@ describe("createConnectorWithMappings", () => {
await createConnectorWithMappings(
ENV_ID,
{ name: "CSV", type: "csv" },
{ name: "CSV", type: "csv", feedbackRecordDirectoryId: FRD_ID },
{
type: "field",
mappings: [{ sourceFieldId: "col-1", targetFieldId: "value_text" }],
@@ -395,6 +398,7 @@ describe("createConnectorWithMappings", () => {
createConnectorWithMappings(ENV_ID, {
name: "Dup",
type: "formbricks_survey",
feedbackRecordDirectoryId: FRD_ID,
})
).rejects.toThrow(InvalidInputError);
});
@@ -407,9 +411,9 @@ describe("createConnectorWithMappings", () => {
})
);
await expect(createConnectorWithMappings(ENV_ID, { name: "Fail", type: "csv" })).rejects.toThrow(
DatabaseError
);
await expect(
createConnectorWithMappings(ENV_ID, { name: "Fail", type: "csv", feedbackRecordDirectoryId: FRD_ID })
).rejects.toThrow(DatabaseError);
});
});
+3
View File
@@ -26,6 +26,7 @@ const selectConnectorWithMappings = {
type: true,
status: true,
workspaceId: true,
feedbackRecordDirectoryId: true,
lastSyncAt: true,
createdBy: true,
creator: { select: { name: true } },
@@ -62,6 +63,7 @@ const selectConnector = {
type: true,
status: true,
workspaceId: true,
feedbackRecordDirectoryId: true,
lastSyncAt: true,
createdBy: true,
} satisfies Prisma.ConnectorSelect;
@@ -236,6 +238,7 @@ export const createConnectorWithMappings = async (
name: data.name,
type: data.type,
workspaceId,
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
createdBy: data.createdBy,
},
});
+1 -1
View File
@@ -96,7 +96,7 @@ const convertValueToHubFields = (
*/
export function transformResponseToFeedbackRecords(
response: TResponse,
survey: Pick<TSurvey, "id" | "name" | "blocks">,
survey: TSurvey,
mappings: TConnectorFormbricksMapping[],
tenantId: string
): FeedbackRecordCreateParams[] {
+28 -47
View File
@@ -6,10 +6,10 @@ const ZActiveAIProvider = z.enum(AI_PROVIDERS);
const ZAIConfigurationEnv = z.object({
AI_PROVIDER: ZActiveAIProvider.optional(),
AI_MODEL: z.string().optional(),
AI_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_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_AWS_REGION: z.string().optional(),
AI_AWS_ACCESS_KEY_ID: z.string().optional(),
AI_AWS_SECRET_ACCESS_KEY: z.string().optional(),
@@ -20,9 +20,6 @@ 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",
@@ -51,44 +48,28 @@ const validateAwsAIConfiguration = (values: TAIConfigurationEnv, ctx: z.Refineme
}
};
const validateGoogleAIConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
if (!values.AI_GOOGLE_CLOUD_PROJECT) {
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) {
addEnvIssue(
ctx,
"AI_GOOGLE_CLOUD_PROJECT",
"AI_GOOGLE_CLOUD_PROJECT is required when AI_PROVIDER=google"
"AI_GCP_CREDENTIALS_JSON",
"AI_GCP_CREDENTIALS_JSON or AI_GCP_APPLICATION_CREDENTIALS is required when AI_PROVIDER=gcp"
);
}
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) {
if (values.AI_GCP_CREDENTIALS_JSON) {
try {
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");
}
JSON.parse(values.AI_GCP_CREDENTIALS_JSON);
} catch {
addEnvIssue(
ctx,
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON",
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON must be a valid JSON object"
);
addEnvIssue(ctx, "AI_GCP_CREDENTIALS_JSON", "AI_GCP_CREDENTIALS_JSON must be valid JSON");
}
}
};
@@ -119,7 +100,7 @@ const validateActiveAIProviderConfiguration = (values: TAIConfigurationEnv, ctx:
(values: TAIConfigurationEnv, ctx: z.RefinementCtx) => void
> = {
aws: validateAwsAIConfiguration,
google: validateGoogleAIConfiguration,
gcp: validateGcpAIConfiguration,
azure: validateAzureAIConfiguration,
};
@@ -179,10 +160,10 @@ const parsedEnv = createEnv({
GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: 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_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(),
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
@@ -334,10 +315,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_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,
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,
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,
+1 -3
View File
@@ -6,8 +6,6 @@ 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,
@@ -36,7 +34,7 @@ export const convertResponseValue = (
};
export const getElementResponseMapping = (
survey: TElementResponseMappingSurvey,
survey: TSurvey,
response: TResponse
): { element: string; response: string | string[]; type: TSurveyElementTypeEnum }[] => {
const elementResponseMapping: {
+47 -6
View File
@@ -4,8 +4,8 @@ import { isLight, mixColor } from "@/lib/utils/colors";
export const COLOR_DEFAULTS = {
brandColor: "#64748b",
elementHeadlineColor: "#2b2524",
inputBgColor: "#ffffff",
questionColor: "#2b2524",
inputColor: "#ffffff",
inputBorderColor: "#cbd5e1",
cardBackgroundColor: "#ffffff",
cardBorderColor: "#f8fafc",
@@ -40,8 +40,10 @@ export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) =>
return {
// General
"brandColor.light": brandColor,
"questionColor.light": questionColor,
// Headlines & Descriptions
// Headlines & Descriptions — use questionColor to match the legacy behaviour
// where all text elements derived their color from questionColor.
"elementHeadlineColor.light": questionColor,
"elementDescriptionColor.light": questionColor,
"elementUpperLabelColor.light": questionColor,
@@ -51,7 +53,7 @@ export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) =>
"buttonTextColor.light": isLight(brandColor) ? "#0f172a" : "#ffffff",
// Inputs
"inputBgColor.light": inputBg,
"inputColor.light": inputBg,
"inputBorderColor.light": inputBorder,
"inputTextColor.light": questionColor,
@@ -92,6 +94,8 @@ 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"] },
@@ -113,7 +117,6 @@ export const STYLE_DEFAULTS: TWorkspaceStyling = {
elementUpperLabelFontWeight: 400,
// Inputs
inputBgColor: { light: _colors["inputBgColor.light"] },
inputTextColor: { light: _colors["inputTextColor.light"] },
inputBorderRadius: 8,
inputHeight: 20,
@@ -148,6 +151,43 @@ 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.
*
@@ -163,12 +203,13 @@ 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"] },
inputBgColor: { light: colors["inputBgColor.light"] },
inputColor: { light: colors["inputColor.light"] },
inputBorderColor: { light: colors["inputBorderColor.light"] },
inputTextColor: { light: colors["inputTextColor.light"] },
optionBgColor: { light: colors["optionBgColor.light"] },
@@ -25,6 +25,7 @@ export type AuditLoggingCtx = {
chartId?: string;
dashboardId?: string;
dashboardWidgetId?: string;
feedbackRecordDirectoryId?: string;
};
export type ActionClientCtx = {
+8 -8
View File
@@ -36,13 +36,13 @@ describe("Template Utilities", () => {
} as unknown as TSurveyElement;
const workspace = {
name: "TestWorkspace",
name: "TestProject",
} 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 TestWorkspace?");
expect(result.headline?.default).toBe("How do you like TestProject?");
});
test("replaces workspaceName placeholder in subheader", () => {
@@ -53,13 +53,13 @@ describe("Template Utilities", () => {
} as unknown as TSurveyElement;
const workspace = {
name: "TestWorkspace",
name: "TestProject",
} as unknown as TWorkspace;
const result = replaceElementPresetPlaceholders(element, workspace);
expect(result.headline?.default).toBe("Question");
expect(result.subheader?.default).toBe("Subheader for TestWorkspace");
expect(result.subheader?.default).toBe("Subheader for TestProject");
});
test("handles missing headline and subheader", () => {
@@ -68,7 +68,7 @@ describe("Template Utilities", () => {
} as unknown as TSurveyElement;
const workspace = {
name: "TestWorkspace",
name: "TestProject",
} as unknown as TWorkspace;
const result = replaceElementPresetPlaceholders(element, workspace);
@@ -106,14 +106,14 @@ describe("Template Utilities", () => {
} as unknown as TTemplate;
const workspace = {
name: "TestWorkspace",
name: "TestProject",
} as TWorkspace;
const result = replacePresetPlaceholders(mockTemplate, workspace);
expect(structuredClone).toHaveBeenCalledWith(mockTemplate.preset);
expect(result.preset.name).toBe("TestWorkspace Feedback");
expect(result.preset.blocks[0].elements[0].headline?.default).toBe("How would you rate TestWorkspace?");
expect(result.preset.name).toBe("TestProject Feedback");
expect(result.preset.blocks[0].elements[0].headline?.default).toBe("How would you rate TestProject?");
});
});
});
-1
View File
@@ -12,7 +12,6 @@ const selectWorkspace = {
id: true,
createdAt: true,
updatedAt: true,
legacyEnvironmentId: true,
name: true,
organizationId: true,
languages: true,
+9 -25
View File
@@ -148,7 +148,6 @@
"apply_filters": "Filter anwenden",
"archived": "Archiviert",
"are_you_sure": "Bist du sicher?",
"ask": "Ask",
"attributes": "Attribute",
"back": "Zurück",
"billing": "Abrechnung",
@@ -164,7 +163,7 @@
"choice_n": "Auswahl {n}",
"choices": "Entscheidungen",
"choose_organization": "Organisation auswählen",
"choose_workspace": "Workspace auswählen",
"choose_workspace": "Projekt auswählen",
"clear_all": "Alles löschen",
"clear_filters": "Filter löschen",
"clear_selection": "Auswahl aufheben",
@@ -214,7 +213,6 @@
"delete_what": "{deleteWhat} löschen",
"description": "Beschreibung",
"disable": "Deaktivieren",
"disabled": "Deaktiviert",
"disallow": "Nicht erlauben",
"discard": "Verwerfen",
"dismissed": "Verworfen",
@@ -244,7 +242,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": "Workspaces konnten nicht geladen werden",
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
"failed_to_parse_csv": "CSV-Analyse fehlgeschlagen",
"field_placeholder": "Platzhalter für {field}",
"filter": "Filter",
@@ -507,13 +505,13 @@
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workspace": "Arbeitsbereich",
"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_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_name_placeholder": "z. B. Formbricks",
"workspaces": "Workspaces",
"workspaces": "Projekte",
"years": "Jahre",
"yes": "Ja",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
@@ -1726,7 +1724,6 @@
"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",
@@ -1835,9 +1832,7 @@
"no_data_message": "Keine Daten. Es gibt derzeit keine Informationen zum Anzeigen. Füge Diagramme hinzu, um dein Dashboard zu erstellen.",
"please_enter_name": "Bitte gib einen Dashboard-Namen ein"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Sie haben keine Feedback-Datensätze, über die Sie berichten können. Richten Sie Feedbackquellen ein, um Daten in das System einzuspeisen.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Richten Sie Feedbackquellen ein"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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",
@@ -2561,7 +2553,6 @@
"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.",
@@ -3756,16 +3747,9 @@
"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",
+23 -39
View File
@@ -148,7 +148,6 @@
"apply_filters": "Apply filters",
"archived": "Archived",
"are_you_sure": "Are you sure?",
"ask": "Ask",
"attributes": "Attributes",
"back": "Back",
"billing": "Billing",
@@ -214,7 +213,6 @@
"delete_what": "Delete {deleteWhat}",
"description": "Description",
"disable": "Disable",
"disabled": "Disabled",
"disallow": "Do not allow",
"discard": "Discard",
"dismissed": "Dismissed",
@@ -1726,7 +1724,6 @@
"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",
@@ -1792,7 +1789,7 @@
"query_executed_successfully": "Query executed successfully",
"reset_to_ai_suggestion": "Reset to AI suggestion",
"save_and_add_to_dashboard": "Save & add to dashboard",
"save_chart": "Save Chart",
"save_chart": "Save chart",
"save_chart_dialog_title": "Save Chart",
"select_data_source": "Select a data source",
"select_data_source_first": "Please select a data source first",
@@ -1835,9 +1832,7 @@
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
"please_enter_name": "Please enter a dashboard name"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "You don't have Feedback Records to report on. Setup Feedback Sources to feed data into the system.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Setup feedback sources"
},
"api_keys": {
@@ -1853,7 +1848,7 @@
"duplicate_access": "Duplicate workspace access not allowed",
"duplicate_directory_access": "Duplicate feedback record directory access not allowed",
"feedback_record_directory_access": "Feedback Record Directory Access",
"no_api_keys_yet": "You do not have any API keys yet",
"no_api_keys_yet": "No API keys found. Create an API key to get started.",
"no_directory_permissions_found": "No feedback record directory permissions found",
"no_workspace_permissions_found": "No Workspace permissions found",
"organization_access": "Organization Access",
@@ -1871,9 +1866,6 @@
"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",
@@ -2544,14 +2536,14 @@
"archive_directory": "Archive Directory",
"archive_not_allowed": "You are not allowed to archive this directory.",
"are_you_sure_you_want_to_archive": "Are you sure you want to archive this directory? Workspaces will no longer have access to it.",
"assign_workspaces_description": "Control which workspaces can access this feedback record directory.",
"assign_workspaces_description": "Control which workspaces can access this directory. Each workspace can only access one directory.",
"connectors_description": "Connectors that send feedback records to this directory.",
"create_feedback_directory": "Create feedback directory",
"description": "Manage feedback record directories and their workspace assignments.",
"directory_archived_successfully": "Directory archived successfully",
"directory_created_successfully": "Directory created successfully",
"directory_id": "Directory ID",
"directory_name": "Directory Name",
"directory_name": "Directory name",
"directory_settings_description": "Manage directory name, workspace assignments, and more.",
"directory_settings_title": "{directoryName} Settings",
"directory_unarchived_successfully": "Directory unarchived successfully",
@@ -2561,17 +2553,16 @@
"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.",
"pause_connectors_confirmation_description": "Pausing these connectors will stop new records from being added.",
"pause_connectors_confirmation_title": "Pause linked connectors?",
"pause_connectors_confirmation_description": "{count, plural, one {1 connector will be paused because its workspace no longer has access to this directory. Continue?} other {{count} connectors will be paused because their workspaces no longer have access to this directory. Continue?}}",
"pause_connectors_confirmation_title": "Pause affected connectors?",
"select_workspaces_placeholder": "Select workspaces...",
"show_archived": "Show archived",
"title": "Feedback Record Directories",
"unarchive": "Unarchive",
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more assigned workspaces are archived.",
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more workspaces are already assigned to another Feedback Directory.",
"workspace_access": "Workspace access"
},
"general": {
@@ -3615,7 +3606,7 @@
"add_tag": "Add Tag",
"count": "Count",
"delete_tag_confirmation": "Are you sure you want to delete this tag?",
"manage_tags": "Manage Tags",
"manage_tags": "Manage tags",
"manage_tags_description": "Merge and remove response tags.",
"merge": "Merge",
"no_tag_found": "No tag found",
@@ -3641,14 +3632,14 @@
"allowed_values": "Allowed values: {values}",
"api_ingestion": "API ingestion",
"api_ingestion_manage_api_keys": "Manage API keys",
"api_ingestion_settings_description": "Send feedback records using the Management API.",
"api_ingestion_settings_description": "Send feedback records directly to Formbricks via HTTP.",
"auto_generated": "Auto-generated",
"change_file": "Change file",
"click_load_sample_csv": "Click 'Load sample CSV' to see columns",
"click_to_upload": "Click to upload",
"collected_at": "Collected At",
"configure_import": "Configure import",
"configure_mapping": "Configure Mapping",
"configure_mapping": "Configure mapping",
"connector_created_successfully": "Connector created successfully",
"connector_deleted_successfully": "Connector deleted successfully",
"connector_duplicated_successfully": "Connector duplicated successfully",
@@ -3662,7 +3653,7 @@
"csv_empty_column_headers": "CSV contains empty column headers. All columns must have a name.",
"csv_file_too_large": "CSV file is too large. Maximum size is 2MB.",
"csv_files_only": "CSV files only",
"csv_import": "CSV Import",
"csv_import": "CSV import",
"csv_import_complete": "CSV import complete: {successes} succeeded, {failures} failed, {skipped} skipped",
"csv_import_duplicate_warning": "Importing data twice will create duplicate records.",
"csv_inconsistent_columns": "Row {row} has inconsistent columns. All rows must have the same headers.",
@@ -3693,21 +3684,21 @@
"feedback_records": "Feedback Records",
"feedback_records_refreshed": "Feedback records refreshed",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
"feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}",
"feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.",
"feedback_sources_directory_access_multiple": "This workspace has access to the {directoryNames} feedback directories.",
"feedback_sources_directory_access_single": "This workspace has access to the {directoryNames} feedback directory.",
"feedback_sources_settings_description": "Connect and manage the sources that feed your feedback records.",
"field_group_id": "Field Group ID",
"field_group_label": "Field Group Label",
"field_id": "Field ID",
"field_label": "Field Label",
"field_type": "Field Type",
"formbricks_surveys": "Formbricks Surveys",
"formbricks_surveys": "Formbricks survey",
"go_to_feedback_record_directories": "Go to directories settings",
"historical_import_complete": "Import complete: {successes} succeeded, {failures} failed, {skipped} skipped (no data)",
"import_csv_data": "Import feedback",
"import_feedback": "Import feedback",
"import_historical_responses": "Import historical responses",
"import_historical_responses_description": "Import existing responses from this survey now.",
"import_historical_responses_description": "Creates one feedback record for each answer to each question.",
"import_rows": "Import {count} rows",
"import_via_source_name": "Import via \"{sourceName}\"",
"importing_data": "Importing data...",
@@ -3721,7 +3712,7 @@
"metadata_key": "Metadata key",
"metadata_read_only_entries": "Read-only metadata values (non-string)",
"metadata_value": "Metadata value",
"missing_feedback_source_title": "Missing feedback source?",
"missing_feedback_source_title": "Missing a feedback source?",
"no_feedback_record_directory_available": "No feedback record directory assigned to this workspace. Create or assign one first.",
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
"no_source_fields_loaded": "No source fields loaded yet",
@@ -3731,7 +3722,7 @@
"question_type_not_supported": "This question type is not supported",
"refresh_feedback_records": "Refresh feedback records",
"refreshing_feedback_records": "Refreshing feedback records...",
"request_feedback_source": "Request source integration",
"request_feedback_source": "Request it and we will build it!",
"required": "Required",
"save_changes": "Save changes",
"select_a_survey_to_see_questions": "Select a survey to see its questions",
@@ -3745,27 +3736,20 @@
"select_survey_questions_description": "Choose which survey questions should create FeedbackRecords.",
"set_value": "set value",
"setup_connection": "Setup connection",
"showing_count_loaded": "Showing {count} records",
"showing_count_loaded": "Showing {count} records from Feedback Directory {directoryName}",
"showing_rows": "Showing 3 of {count} rows",
"source": "source",
"source_connect_csv_description": "Import feedback from CSV files",
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
"source_connect_formbricks_description": "Connect feedback from your Formbricks surveys",
"source_connect_feedback_record_mcp_description": "Connect feedback records via the Formbricks MCP.",
"source_connect_formbricks_description": "Connect feedback from your Formbricks survey",
"source_fields": "Source Fields",
"source_id": "Source ID",
"source_name": "Source Name",
"source_type": "Source Type",
"source_type_cannot_be_changed": "Source type cannot be changed",
"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_live_sync": "Live Sync",
"status_paused": "Paused",
"status_ready": "Ready",
"submission_id": "Submission ID",
"survey_has_no_questions": "This survey has no questions",
+37 -53
View File
@@ -131,9 +131,9 @@
"add_filter": "Añadir filtro",
"add_logo": "Añadir logotipo",
"add_member": "Añadir miembro",
"add_new_workspace": "Añadir nuevo espacio de trabajo",
"add_new_workspace": "Añadir proyecto nuevo",
"add_to_team": "Añadir al equipo",
"add_workspace": "Añadir espacio de trabajo",
"add_workspace": "Añadir proyecto",
"all": "Todos",
"all_questions": "Todas las preguntas",
"allow": "Permitir",
@@ -148,7 +148,6 @@
"apply_filters": "Aplicar filtros",
"archived": "Archivado",
"are_you_sure": "¿Estás seguro?",
"ask": "Ask",
"attributes": "Atributos",
"back": "Atrás",
"billing": "Facturación",
@@ -164,7 +163,7 @@
"choice_n": "Opción {n}",
"choices": "Opciones",
"choose_organization": "Elegir organización",
"choose_workspace": "Elegir espacio de trabajo",
"choose_workspace": "Elegir proyecto",
"clear_all": "Borrar todo",
"clear_filters": "Borrar filtros",
"clear_selection": "Borrar selección",
@@ -199,7 +198,7 @@
"create_new_organization": "Crear organización nueva",
"create_segment": "Crear segmento",
"create_survey": "Crear encuesta",
"create_workspace": "Crear espacio de trabajo",
"create_workspace": "Crear proyecto",
"created": "Creado",
"created_at": "Creado el",
"created_by": "Creado por",
@@ -214,7 +213,6 @@
"delete_what": "Eliminar {deleteWhat}",
"description": "Descripción",
"disable": "Desactivar",
"disabled": "Desactivado",
"disallow": "No permitir",
"discard": "Descartar",
"dismissed": "Descartado",
@@ -244,7 +242,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 espacios de trabajo",
"failed_to_load_workspaces": "Error al cargar los proyectos",
"failed_to_parse_csv": "Error al analizar el CSV",
"field_placeholder": "Marcador de posición de {field}",
"filter": "Filtro",
@@ -480,7 +478,7 @@
"type": "Tipo",
"unify": "Unificar",
"unknown_survey": "Encuesta desconocida",
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más espacios de trabajo con un plan superior.",
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más proyectos con un plan superior.",
"update": "Actualizar",
"updated": "Actualizado",
"updated_at": "Actualizado el",
@@ -507,13 +505,13 @@
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"workspace": "Espacio de trabajo",
"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_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_name_placeholder": "p. ej. Formbricks",
"workspaces": "Espacios de trabajo",
"workspaces": "Proyectos",
"years": "años",
"yes": "Sí",
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
@@ -1726,7 +1724,6 @@
"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",
@@ -1835,9 +1832,7 @@
"no_data_message": "Sin datos. Actualmente no hay información que mostrar. Añade gráficos para crear tu panel.",
"please_enter_name": "Por favor, introduce un nombre para el panel de control"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "No tienes registros de comentarios sobre los que informar. Configure fuentes de comentarios para introducir datos en el sistema.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurar fuentes de comentarios"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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",
@@ -1997,7 +1989,7 @@
},
"formbricks_logo": "Logo de Formbricks",
"general": {
"cannot_delete_only_workspace": "Este es tu único espacio de trabajo, no se puede eliminar. Crea primero un nuevo espacio de trabajo.",
"cannot_delete_only_workspace": "Este es tu único proyecto, no se puede eliminar. Crea primero un proyecto nuevo.",
"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.",
@@ -2005,20 +1997,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 espacio de trabajo",
"delete_workspace": "Eliminar proyecto",
"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": "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",
"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",
"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 espacio de trabajo?",
"workspace_deleted_successfully": "Espacio de trabajo eliminado correctamente",
"workspace_name_settings_description": "Cambia el nombre de tu espacio de trabajo.",
"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.",
"workspace_name_updated_successfully": "Nombre del espacio de trabajo actualizado correctamente"
},
"integrations": {
@@ -2458,7 +2450,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": "Espacios de trabajo ilimitados",
"unlimited_workspaces": "Proyectos ilimitados",
"upgrade": "Actualizar",
"upgrade_now": "Actualizar ahora",
"usage_cycle": "Usage cycle",
@@ -2481,7 +2473,7 @@
"pretty_url": "URL bonita",
"survey_name": "Nombre de la encuesta",
"title": "URL bonitas",
"workspace": "Espacio de trabajo"
"workspace": "Proyecto"
},
"enterprise": {
"audit_logs": "Registros de auditoría",
@@ -2561,7 +2553,6 @@
"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.",
@@ -2588,13 +2579,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 espacios de trabajo.",
"create_new_organization_description": "Crea una nueva organización para gestionar un conjunto diferente de proyectos.",
"customize_email_with_a_higher_plan": "Personaliza el correo electrónico con un plan superior",
"delete_member_confirmation": "Los miembros eliminados perderán el acceso a todos los espacios de trabajo y encuestas de tu organización.",
"delete_member_confirmation": "Los miembros eliminados perderán acceso a todos los proyectos y encuestas de tu organización.",
"delete_organization": "Eliminar organización",
"delete_organization_description": "Elimina la organización con todos sus espacios de trabajo, incluyendo todas las encuestas, respuestas, personas, acciones y atributos",
"delete_organization_description": "Eliminar organización con todos sus proyectos 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 espacios de trabajo vinculados a esta organización.",
"delete_organization_warning_1": "Eliminación permanente de todos los proyectos 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.",
@@ -2689,10 +2680,10 @@
},
"teams": {
"add_members_description": "Añade miembros al equipo y determina su rol.",
"add_workspaces_description": "Controla a qué espacios de trabajo pueden acceder los miembros del equipo.",
"add_workspaces_description": "Controla a qué proyectos pueden acceder los miembros del equipo.",
"all_members_added": "Todos los miembros añadidos a 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.",
"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.",
"billing_role_description": "Solo tienen acceso a la información de facturación.",
"bulk_invite": "Invitación masiva",
"contributor": "Colaborador",
@@ -2708,14 +2699,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 espacios de trabajo y añadir o eliminar miembros.",
"manager_role_description": "Los gestores pueden acceder a todos los proyectos y añadir y eliminar miembros.",
"member": "Miembro",
"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.",
"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.",
"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 nuevo espacio de trabajo.",
"please_fill_all_workspace_fields": "Por favor, rellena todos los campos para añadir un proyecto nuevo.",
"read": "Lectura",
"read_write": "Lectura y escritura",
"team_admin": "Administrador de equipo",
@@ -2728,8 +2719,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 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.",
"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.",
"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"
@@ -3075,7 +3066,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": "Anula la configuración del espacio de trabajo solo para esta encuesta.",
"overwrite_global_waiting_time_description": "Anular la configuración del proyecto 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",
@@ -3756,16 +3747,9 @@
"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",
+30 -46
View File
@@ -131,9 +131,9 @@
"add_filter": "Ajouter un filtre",
"add_logo": "Ajouter un logo",
"add_member": "Ajouter un membre",
"add_new_workspace": "Ajouter un nouvel espace de travail",
"add_new_workspace": "Ajouter un nouveau projet",
"add_to_team": "Ajouter à l'équipe",
"add_workspace": "Ajouter un espace de travail",
"add_workspace": "Ajouter un projet",
"all": "Tout",
"all_questions": " toutes les questions",
"allow": "Autoriser",
@@ -148,7 +148,6 @@
"apply_filters": "Appliquer des filtres",
"archived": "Archivé",
"are_you_sure": "Es-tu sûr ?",
"ask": "Ask",
"attributes": "Attributs",
"back": "Retour",
"billing": "Facturation",
@@ -164,7 +163,7 @@
"choice_n": "Choix {n}",
"choices": "Choix",
"choose_organization": "Choisir l'organisation",
"choose_workspace": "Choisir un espace de travail",
"choose_workspace": "Choisir un projet",
"clear_all": "Tout effacer",
"clear_filters": "Effacer les filtres",
"clear_selection": "Effacer la sélection",
@@ -199,7 +198,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 espace de travail",
"create_workspace": "Créer un projet",
"created": "Créé",
"created_at": "Créé le",
"created_by": "Créé par",
@@ -214,7 +213,6 @@
"delete_what": "Supprimer {deleteWhat}",
"description": "Description",
"disable": "Désactiver",
"disabled": "Désactivé",
"disallow": "Ne pas autoriser",
"discard": "Annuler",
"dismissed": "Rejeté",
@@ -244,7 +242,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 espaces de travail",
"failed_to_load_workspaces": "Échec du chargement des projets",
"failed_to_parse_csv": "Échec de l'analyse du CSV",
"field_placeholder": "Espace réservé pour {field}",
"filter": "Filtre",
@@ -480,7 +478,7 @@
"type": "Type",
"unify": "Unifier",
"unknown_survey": "Enquête inconnue",
"unlock_more_workspaces_with_a_higher_plan": "Débloque plus d'espaces de travail avec un forfait supérieur.",
"unlock_more_workspaces_with_a_higher_plan": "Débloquez plus de projets avec un forfait supérieur.",
"update": "Mise à jour",
"updated": "Mise à jour",
"updated_at": "Mis à jour à",
@@ -507,13 +505,13 @@
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"workspace": "Espace de travail",
"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_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_name_placeholder": "par ex. Formbricks",
"workspaces": "Espaces de travail",
"workspaces": "Projets",
"years": "années",
"yes": "Oui",
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
@@ -1726,7 +1724,6 @@
"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",
@@ -1835,9 +1832,7 @@
"no_data_message": "Aucune donnée. Il n'y a actuellement aucune information à afficher. Ajoute des graphiques pour construire ton tableau de bord.",
"please_enter_name": "Veuillez saisir un nom de tableau de bord"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Vous n'avez pas d'enregistrements de commentaires sur lesquels créer des rapports. Configurez des sources de commentaires pour introduire des données dans le système.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurer les sources de commentaires"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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",
@@ -1997,7 +1989,7 @@
},
"formbricks_logo": "Logo Formbricks",
"general": {
"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.",
"cannot_delete_only_workspace": "Il s'agit de votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.",
"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.",
@@ -2005,21 +1997,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 l'espace de travail",
"delete_workspace": "Supprimer le projet",
"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 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",
"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",
"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 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"
"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"
},
"integrations": {
"activepieces_integration_description": "Connectez instantanément Formbricks à des applications populaires pour automatiser des tâches sans effectuer de codage.",
@@ -2177,7 +2169,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. Ajoute plutôt la langue avec cet identifiant à ton espace de travail 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. Veuillez plutôt ajouter la langue avec cet identifiant à votre projet 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",
@@ -2458,7 +2450,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": "Espaces de travail illimités",
"unlimited_workspaces": "Projets illimités",
"upgrade": "Mise à niveau",
"upgrade_now": "Passer à la formule supérieure maintenant",
"usage_cycle": "Usage cycle",
@@ -2481,7 +2473,7 @@
"pretty_url": "URL personnalisée",
"survey_name": "Nom de l'enquête",
"title": "URL personnalisées",
"workspace": "Espace de travail"
"workspace": "Projet"
},
"enterprise": {
"audit_logs": "Journaux d'audit",
@@ -2561,7 +2553,6 @@
"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.",
@@ -2588,13 +2579,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ée une nouvelle organisation pour gérer un ensemble différent d'espaces de travail.",
"create_new_organization_description": "Créez une nouvelle organisation pour gérer un ensemble différent de projets.",
"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 espaces de travail et enquêtes de votre organisation.",
"delete_member_confirmation": "Les membres supprimés perdront l'accès à tous les projets et enquêtes de votre organisation.",
"delete_organization": "Supprimer l'organisation",
"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_description": "Supprimer l'organisation avec tous ses projets, y compris 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 espaces de travail liés à cette organisation.",
"delete_organization_warning_1": "Suppression définitive de tous les projets 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.",
@@ -2689,7 +2680,7 @@
},
"teams": {
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
"add_workspaces_description": "Contrôlez les espaces de travail auxquels les membres de l'équipe peuvent accéder.",
"add_workspaces_description": "Contrôlez les projets 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.",
@@ -3756,16 +3747,9 @@
"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",
+1 -17
View File
@@ -148,7 +148,6 @@
"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",
@@ -214,7 +213,6 @@
"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",
@@ -1726,7 +1724,6 @@
"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",
@@ -1835,9 +1832,7 @@
"no_data_message": "Nincsenek adatok. Jelenleg nincsenek megjeleníthető információk. Adjon hozzá diagramokat az irányítópult felépítéséhez.",
"please_enter_name": "Kérjük, adjon nevet a vezérlőpultnak"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Nincsenek visszajelzési rekordjai, amelyekről jelentést tehetne. Állítsa be a visszacsatolási forrásokat, hogy adatokat tápláljon be a rendszerbe.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Visszajelzési források beállítása"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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",
@@ -2561,7 +2553,6 @@
"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.",
@@ -3756,16 +3747,9 @@
"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",
+1 -17
View File
@@ -148,7 +148,6 @@
"apply_filters": "フィルターを適用",
"archived": "アーカイブ済み",
"are_you_sure": "よろしいですか?",
"ask": "Ask",
"attributes": "属性",
"back": "戻る",
"billing": "請求",
@@ -214,7 +213,6 @@
"delete_what": "{deleteWhat}を削除",
"description": "説明",
"disable": "無効にする",
"disabled": "無効",
"disallow": "許可しない",
"discard": "破棄",
"dismissed": "非表示",
@@ -1726,7 +1724,6 @@
"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": "平均スコア",
@@ -1835,9 +1832,7 @@
"no_data_message": "データがありません。現在表示する情報がありません。ダッシュボードを構築するにはチャートを追加してください。",
"please_enter_name": "ダッシュボード名を入力してください"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "レポートするフィードバック レコードがありません。データをシステムにフィードするためのフィードバック ソースをセットアップします。",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "フィードバックソースのセットアップ"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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と接続してください",
@@ -2561,7 +2553,6 @@
"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": "このディレクトリにリンクされているコネクタはまだありません。",
@@ -3756,16 +3747,9 @@
"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": "このアンケートには質問がありません",
+21 -37
View File
@@ -148,7 +148,6 @@
"apply_filters": "Pas filters toe",
"archived": "Gearchiveerd",
"are_you_sure": "Weet je het zeker?",
"ask": "Ask",
"attributes": "Kenmerken",
"back": "Rug",
"billing": "Facturering",
@@ -214,7 +213,6 @@
"delete_what": "Verwijder {deleteWhat}",
"description": "Beschrijving",
"disable": "Uitzetten",
"disabled": "Uitgeschakeld",
"disallow": "Niet toestaan",
"discard": "Weggooien",
"dismissed": "Afgewezen",
@@ -508,7 +506,7 @@
"welcome_card": "Welkomstkaart",
"workspace": "Werkruimte",
"workspace_configuration": "Werkruimte-configuratie",
"workspace_created_successfully": "Werkruimte succesvol aangemaakt",
"workspace_created_successfully": "Project succesvol aangemaakt",
"workspace_creation_description": "Organiseer enquêtes in werkruimtes voor beter toegangsbeheer.",
"workspace_id": "Werkruimte-ID",
"workspace_name": "Werkruimtenaam",
@@ -1726,7 +1724,6 @@
"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",
@@ -1835,9 +1832,7 @@
"no_data_message": "Geen gegevens. Er is momenteel geen informatie om weer te geven. Voeg grafieken toe om je dashboard op te bouwen.",
"please_enter_name": "Voer een dashboardnaam in"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "U heeft geen feedbackrecords om over te rapporteren. Stel feedbackbronnen in om gegevens in het systeem in te voeren.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Feedbackbronnen instellen"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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",
@@ -1997,7 +1989,7 @@
},
"formbricks_logo": "Formbricks-logo",
"general": {
"cannot_delete_only_workspace": "Dit is je enige werkruimte en kan niet worden verwijderd. Maak eerst een nieuwe werkruimte aan.",
"cannot_delete_only_workspace": "Dit is uw enige project, het kan niet worden verwijderd. Maak eerst een nieuw project 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.",
@@ -2005,20 +1997,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": "Werkruimte verwijderen",
"delete_workspace": "Project 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 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",
"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",
"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 je werkruimte?",
"workspace_deleted_successfully": "Werkruimte succesvol verwijderd",
"workspace_name_settings_description": "Wijzig de naam van je werkruimte.",
"whats_your_workspace_called": "Hoe heet uw project?",
"workspace_deleted_successfully": "Project succesvol verwijderd",
"workspace_name_settings_description": "Wijzig de naam van uw project.",
"workspace_name_updated_successfully": "Werkruimtenaam succesvol bijgewerkt"
},
"integrations": {
@@ -2561,7 +2553,6 @@
"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.",
@@ -2588,13 +2579,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": "Maak een nieuwe organisatie aan om een andere set werkruimtes te beheren.",
"create_new_organization_description": "Creëer een nieuwe organisatie om een andere reeks projecten af te handelen.",
"customize_email_with_a_higher_plan": "Pas e-mail aan met een hoger abonnement",
"delete_member_confirmation": "Verwijderde leden verliezen toegang tot alle werkruimtes en enquêtes van je organisatie.",
"delete_member_confirmation": "Verwijderde leden verliezen de toegang tot alle projecten en enquêtes van uw organisatie.",
"delete_organization": "Organisatie verwijderen",
"delete_organization_description": "Verwijder organisatie met al haar werkruimtes inclusief alle enquêtes, reacties, personen, acties en attributen",
"delete_organization_description": "Verwijder de organisatie met al haar projecten, inclusief alle enquêtes, reacties, mensen, 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 werkruimtes die aan deze organisatie zijn gekoppeld.",
"delete_organization_warning_1": "Permanente verwijdering van alle projecten 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.",
@@ -2692,7 +2683,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 je zeker dat je dit team wilt verwijderen? Dit verwijdert ook de toegang tot alle werkruimtes en enquêtes die bij dit team horen.",
"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.",
"billing_role_description": "U heeft alleen toegang tot factuurgegevens.",
"bulk_invite": "Bulk-uitnodiging",
"contributor": "Bijdrager",
@@ -2708,10 +2699,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 werkruimtes en kunnen leden toevoegen en verwijderen.",
"manager_role_description": "Managers hebben toegang tot alle projecten en kunnen leden toevoegen en verwijderen.",
"member": "Lid",
"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.",
"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.",
"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.",
@@ -2728,8 +2719,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 werkruimtes.",
"unlock_teams_description": "Beheer welke organisatieleden toegang hebben tot specifieke werkruimtes en enquêtes.",
"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.",
"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"
@@ -3075,7 +3066,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 werkruimte-instellingen alleen voor deze enquête.",
"overwrite_global_waiting_time_description": "Overschrijf de projectconfiguratie 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",
@@ -3756,16 +3747,9 @@
"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",
+30 -46
View File
@@ -131,9 +131,9 @@
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logo",
"add_member": "Adicionar membro",
"add_new_workspace": "Adicionar novo workspace",
"add_new_workspace": "Adicionar novo projeto",
"add_to_team": "Adicionar à equipe",
"add_workspace": "Adicionar workspace",
"add_workspace": "Adicionar projeto",
"all": "Todos",
"all_questions": "Todas as perguntas",
"allow": "permitir",
@@ -148,7 +148,6 @@
"apply_filters": "Aplicar filtros",
"archived": "Arquivado",
"are_you_sure": "Certeza?",
"ask": "Ask",
"attributes": "atributos",
"back": "Voltar",
"billing": "Faturamento",
@@ -164,7 +163,7 @@
"choice_n": "Escolha {n}",
"choices": "Escolhas",
"choose_organization": "Escolher organização",
"choose_workspace": "Escolher workspace",
"choose_workspace": "Escolher projeto",
"clear_all": "Limpar tudo",
"clear_filters": "Limpar filtros",
"clear_selection": "Limpar seleção",
@@ -199,7 +198,7 @@
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar pesquisa",
"create_workspace": "Criar workspace",
"create_workspace": "Criar projeto",
"created": "Criado",
"created_at": "Data de criação",
"created_by": "Criado por",
@@ -214,7 +213,6 @@
"delete_what": "Excluir {deleteWhat}",
"description": "Descrição",
"disable": "desativar",
"disabled": "Desativado",
"disallow": "Não permita",
"discard": "Descartar",
"dismissed": "Dispensado",
@@ -244,7 +242,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 workspaces",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"failed_to_parse_csv": "Falha ao analisar CSV",
"field_placeholder": "Espaço reservado de {field}",
"filter": "Filtro",
@@ -480,7 +478,7 @@
"type": "Tipo",
"unify": "Unificar",
"unknown_survey": "Pesquisa desconhecida",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais workspaces com um plano superior.",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
"update": "atualizar",
"updated": "atualizado",
"updated_at": "Atualizado em",
@@ -507,13 +505,13 @@
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workspace": "Espaço de trabalho",
"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_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_name_placeholder": "ex: Formbricks",
"workspaces": "Workspaces",
"workspaces": "Projetos",
"years": "anos",
"yes": "Sim",
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
@@ -1726,7 +1724,6 @@
"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",
@@ -1835,9 +1832,7 @@
"no_data_message": "Sem Dados. Não há informações para exibir no momento. Adicione gráficos para construir seu painel.",
"please_enter_name": "Por favor, digite um nome para o painel"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Você não tem registros de feedback para relatar. Configure fontes de feedback para alimentar dados no sistema.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurar fontes de feedback"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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",
@@ -1997,7 +1989,7 @@
},
"formbricks_logo": "Logo da Formbricks",
"general": {
"cannot_delete_only_workspace": "Este é o seu único workspace, não pode ser excluído. Crie um novo workspace primeiro.",
"cannot_delete_only_workspace": "Este é seu único projeto, ele não pode ser excluído. Crie um novo projeto 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.",
@@ -2005,21 +1997,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 Workspace",
"delete_workspace": "Excluir projeto",
"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 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",
"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",
"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 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"
"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"
},
"integrations": {
"activepieces_integration_description": "Conecte o Formbricks instantaneamente com aplicativos populares para automatizar tarefas sem codificação.",
@@ -2177,7 +2169,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. Por favor, adicione o idioma com este identificador ao seu workspace 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. Adicione o idioma com este identificador ao seu projeto 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",
@@ -2458,7 +2450,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": "Workspaces Ilimitados",
"unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar",
"upgrade_now": "Fazer upgrade agora",
"usage_cycle": "Usage cycle",
@@ -2481,7 +2473,7 @@
"pretty_url": "URL amigável",
"survey_name": "Nome da Pesquisa",
"title": "URLs amigáveis",
"workspace": "Workspace"
"workspace": "Projeto"
},
"enterprise": {
"audit_logs": "Registros de Auditoria",
@@ -2561,7 +2553,6 @@
"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.",
@@ -2588,13 +2579,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 workspaces.",
"create_new_organization_description": "Crie uma nova organização para gerenciar um conjunto diferente de projetos.",
"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 workspaces e pesquisas da sua organização.",
"delete_member_confirmation": "Membros excluídos perderão acesso a todos os projetos e pesquisas da sua organização.",
"delete_organization": "Excluir Organização",
"delete_organization_description": "Excluir organização com todos os seus workspaces, incluindo todas as pesquisas, respostas, pessoas, ações e atributos",
"delete_organization_description": "Excluir organização com todos os seus projetos, 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 workspaces vinculados a esta organização.",
"delete_organization_warning_1": "Remoção permanente de todos os projetos 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.",
@@ -2689,7 +2680,7 @@
},
"teams": {
"add_members_description": "Adicione membros à equipe e determine sua função.",
"add_workspaces_description": "Controle quais workspaces os membros da equipe podem acessar.",
"add_workspaces_description": "Controle quais projetos 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.",
@@ -3756,16 +3747,9 @@
"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",
+30 -46
View File
@@ -131,9 +131,9 @@
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logótipo",
"add_member": "Adicionar membro",
"add_new_workspace": "Adicionar novo espaço de trabalho",
"add_new_workspace": "Adicionar novo projeto",
"add_to_team": "Adicionar à equipa",
"add_workspace": "Adicionar espaço de trabalho",
"add_workspace": "Adicionar projeto",
"all": "Todos",
"all_questions": "Todas as perguntas",
"allow": "Permitir",
@@ -148,7 +148,6 @@
"apply_filters": "Aplicar filtros",
"archived": "Arquivado",
"are_you_sure": "Tem a certeza?",
"ask": "Ask",
"attributes": "Atributos",
"back": "Voltar",
"billing": "Faturação",
@@ -164,7 +163,7 @@
"choice_n": "Escolha {n}",
"choices": "Escolhas",
"choose_organization": "Escolher organização",
"choose_workspace": "Escolher espaço de trabalho",
"choose_workspace": "Escolher projeto",
"clear_all": "Limpar tudo",
"clear_filters": "Limpar filtros",
"clear_selection": "Limpar seleção",
@@ -199,7 +198,7 @@
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar inquérito",
"create_workspace": "Criar espaço de trabalho",
"create_workspace": "Criar projeto",
"created": "Criado",
"created_at": "Criado em",
"created_by": "Criado por",
@@ -214,7 +213,6 @@
"delete_what": "Eliminar {deleteWhat}",
"description": "Descrição",
"disable": "Desativar",
"disabled": "Desativado",
"disallow": "Não permitir",
"discard": "Descartar",
"dismissed": "Dispensado",
@@ -244,7 +242,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 espaços de trabalho",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"failed_to_parse_csv": "Falha ao analisar o CSV",
"field_placeholder": "Espaço reservado de {field}",
"filter": "Filtro",
@@ -480,7 +478,7 @@
"type": "Tipo",
"unify": "Unificar",
"unknown_survey": "Inquérito desconhecido",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueia mais espaços de trabalho com um plano superior.",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
"update": "Atualizar",
"updated": "Atualizado",
"updated_at": "Atualizado em",
@@ -507,13 +505,13 @@
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workspace": "Espaço de trabalho",
"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_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_name_placeholder": "ex. Formbricks",
"workspaces": "Espaços de Trabalho",
"workspaces": "Projetos",
"years": "anos",
"yes": "Sim",
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
@@ -1726,7 +1724,6 @@
"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",
@@ -1835,9 +1832,7 @@
"no_data_message": "Sem Dados. Atualmente não há informação para apresentar. Adiciona gráficos para construir o teu painel.",
"please_enter_name": "Por favor, introduza um nome para o painel"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Você não tem registros de feedback para relatar. Configure fontes de feedback para alimentar dados no sistema.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurar fontes de feedback"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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",
@@ -1997,7 +1989,7 @@
},
"formbricks_logo": "Logotipo do Formbricks",
"general": {
"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.",
"cannot_delete_only_workspace": "Este é o seu único projeto, não pode ser eliminado. Crie primeiro um novo projeto.",
"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.",
@@ -2005,21 +1997,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 Espaço de Trabalho",
"delete_workspace": "Eliminar projeto",
"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": "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",
"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",
"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 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"
"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"
},
"integrations": {
"activepieces_integration_description": "Conecte instantaneamente o Formbricks com apps populares para automatizar tarefas sem codificação.",
@@ -2177,7 +2169,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 possui este identificador. Por favor, adiciona o idioma com este identificador ao teu espaço de trabalho para evitar inconsistências.",
"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.",
"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",
@@ -2458,7 +2450,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": "Espaços de Trabalho Ilimitados",
"unlimited_workspaces": "Projetos ilimitados",
"upgrade": "Atualizar",
"upgrade_now": "Fazer upgrade agora",
"usage_cycle": "Usage cycle",
@@ -2481,7 +2473,7 @@
"pretty_url": "URL amigável",
"survey_name": "Nome do inquérito",
"title": "URLs amigáveis",
"workspace": "Espaço de Trabalho"
"workspace": "Projeto"
},
"enterprise": {
"audit_logs": "Registos de Auditoria",
@@ -2561,7 +2553,6 @@
"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.",
@@ -2588,13 +2579,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": "Cria uma nova organização para gerir um conjunto diferente de espaços de trabalho.",
"create_new_organization_description": "Crie uma nova organização para gerir um conjunto diferente de projetos.",
"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 espaços de trabalho e inquéritos da tua organização.",
"delete_member_confirmation": "Os membros eliminados perderão o acesso a todos os projetos e inquéritos da sua organização.",
"delete_organization": "Eliminar Organização",
"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_description": "Eliminar organização com todos os seus projetos, 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 espaços de trabalho associados a esta organização.",
"delete_organization_warning_1": "Remoção permanente de todos os projetos 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.",
@@ -2689,7 +2680,7 @@
},
"teams": {
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
"add_workspaces_description": "Controla a quais espaços de trabalho os membros da equipa podem aceder.",
"add_workspaces_description": "Controle a quais projetos 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.",
@@ -3756,16 +3747,9 @@
"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",
+11 -27
View File
@@ -148,7 +148,6 @@
"apply_filters": "Aplică filtre",
"archived": "Arhivat",
"are_you_sure": "Ești sigur?",
"ask": "Ask",
"attributes": "Atribute",
"back": "Înapoi",
"billing": "Facturare",
@@ -214,7 +213,6 @@
"delete_what": "Șterge {deleteWhat}",
"description": "Descriere",
"disable": "Dezactivează",
"disabled": "Dezactivat",
"disallow": "Nu permite",
"discard": "Renunță",
"dismissed": "Respins",
@@ -1726,7 +1724,6 @@
"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",
@@ -1835,9 +1832,7 @@
"no_data_message": "Fără date. În prezent nu există informații de afișat. Adaugă grafice pentru a-ți construi tabloul de bord.",
"please_enter_name": "Te rugăm să introduci un nume pentru tablou de bord"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Nu aveți înregistrări de feedback despre care să raportați. Configurați sursele de feedback pentru a introduce date în sistem.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Configurați sursele de feedback"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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",
@@ -1997,7 +1989,7 @@
},
"formbricks_logo": "Logo Formbricks",
"general": {
"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.",
"cannot_delete_only_workspace": "Acesta este singurul tău proiect, nu poate fi șters. Creează mai întâi un proiect 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.",
@@ -2005,21 +1997,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 Spațiul de Lucru",
"delete_workspace": "Șterge proiectul",
"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 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",
"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",
"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 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"
"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"
},
"integrations": {
"activepieces_integration_description": "Conectați instantaneu Formbricks cu aplicații populare pentru a automatiza sarcini fără codare.",
@@ -2177,7 +2169,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. Te rugăm să adaugi în schimb limba cu acest identificator în spațiul tău de lucru 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. Adaugă limba cu acest identificator în proiectul tău 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",
@@ -2561,7 +2553,6 @@
"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ă.",
@@ -3756,16 +3747,9 @@
"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",
+21 -37
View File
@@ -148,7 +148,6 @@
"apply_filters": "Применить фильтры",
"archived": "Архивный",
"are_you_sure": "Вы уверены?",
"ask": "Ask",
"attributes": "Атрибуты",
"back": "Назад",
"billing": "Оплата",
@@ -214,7 +213,6 @@
"delete_what": "Удалить {deleteWhat}",
"description": "Описание",
"disable": "Отключить",
"disabled": "Отключено",
"disallow": "Не разрешать",
"discard": "Отменить",
"dismissed": "Отклонено",
@@ -508,7 +506,7 @@
"welcome_card": "Приветственная карточка",
"workspace": "Рабочее пространство",
"workspace_configuration": "Настройка рабочего пространства",
"workspace_created_successfully": "Рабочее пространство успешно создано",
"workspace_created_successfully": "Рабочий проект успешно создан",
"workspace_creation_description": "Организуйте опросы в рабочих пространствах для лучшего контроля доступа.",
"workspace_id": "ID рабочего пространства",
"workspace_name": "Название рабочего пространства",
@@ -1726,7 +1724,6 @@
"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": "Средний балл",
@@ -1835,9 +1832,7 @@
"no_data_message": "Нет данных. В настоящее время нет информации для отображения. Добавьте графики, чтобы построить свой дашборд.",
"please_enter_name": "Пожалуйста, введите название панели управления"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "У вас нет записей обратной связи, о которых можно сообщить. Настройте источники обратной связи для подачи данных в систему.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Настройка источников обратной связи"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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",
@@ -1997,7 +1989,7 @@
},
"formbricks_logo": "Логотип Formbricks",
"general": {
"cannot_delete_only_workspace": "Это ваше единственное рабочее пространство, его нельзя удалить. Сначала создайте новое рабочее пространство.",
"cannot_delete_only_workspace": "Это ваш единственный рабочий проект, его нельзя удалить. Сначала создайте новый проект.",
"custom_scripts": "Пользовательские скрипты",
"custom_scripts_card_description": "Добавьте трекинговые скрипты и пиксели ко всем опросам по ссылке в этом рабочем пространстве.",
"custom_scripts_description": "Скрипты будут внедряться в <head> всех страниц опросов по ссылке.",
@@ -2005,20 +1997,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": {
@@ -2561,7 +2553,6 @@
"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": "К этому каталогу пока не привязано ни одного коннектора.",
@@ -2588,13 +2579,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 кастомизации.",
@@ -2692,7 +2683,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": "Участник",
@@ -2708,10 +2699,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": "Пожалуйста, заполните все поля для добавления нового участника.",
@@ -2728,8 +2719,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": "Вы участник"
@@ -3075,7 +3066,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": "Переопределить глобальное размещение опроса",
@@ -3756,16 +3747,9 @@
"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": "В этом опросе нет вопросов",
+12 -28
View File
@@ -148,7 +148,6 @@
"apply_filters": "Tillämpa filter",
"archived": "Arkiverad",
"are_you_sure": "Är du säker?",
"ask": "Ask",
"attributes": "Attribut",
"back": "Tillbaka",
"billing": "Fakturering",
@@ -214,7 +213,6 @@
"delete_what": "Ta bort {deleteWhat}",
"description": "Beskrivning",
"disable": "Inaktivera",
"disabled": "Inaktiverad",
"disallow": "Tillåt inte",
"discard": "Förkasta",
"dismissed": "Avvisad",
@@ -1726,7 +1724,6 @@
"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",
@@ -1835,9 +1832,7 @@
"no_data_message": "Ingen data. Det finns för närvarande ingen information att visa. Lägg till diagram för att bygga din instrumentpanel.",
"please_enter_name": "Ange ett namn på instrumentpanelen"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Du har inga feedbackposter att rapportera om. Ställ in återkopplingskällor för att mata in data i systemet.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Ställ in feedbackkällor"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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",
@@ -2561,7 +2553,6 @@
"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.",
@@ -2588,13 +2579,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 arbetsytor.",
"create_new_organization_description": "Skapa en ny organisation för att hantera en annan uppsättning projekt.",
"customize_email_with_a_higher_plan": "Anpassa e-post med en högre plan",
"delete_member_confirmation": "Borttagna medlemmar förlorar åtkomst till alla arbetsytor och undersökningar i din organisation.",
"delete_member_confirmation": "Borttagna medlemmar förlorar åtkomst till alla projekt och enkäter i din organisation.",
"delete_organization": "Ta bort organisation",
"delete_organization_description": "Ta bort organisation med alla dess arbetsytor inklusive alla undersökningar, svar, personer, åtgärder och attribut",
"delete_organization_description": "Ta bort organisation med alla dess projekt inklusive alla enkäter, 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 arbetsytor kopplade till denna organisation.",
"delete_organization_warning_1": "Permanent borttagning av alla projekt 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.",
@@ -2692,7 +2683,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 det här teamet? Detta tar även bort åtkomsten till alla arbetsytor och undersökningar som är kopplade till teamet.",
"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.",
"billing_role_description": "Har endast åtkomst till faktureringsinformation.",
"bulk_invite": "Massinbjudning",
"contributor": "Bidragsgivare",
@@ -2708,10 +2699,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 arbetsytor och lägga till samt ta bort medlemmar.",
"manager_role_description": "Administratörer kan komma åt alla projekt och lägga till och ta bort medlemmar.",
"member": "Medlem",
"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.",
"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.",
"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.",
@@ -2728,8 +2719,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 arbetsytor.",
"unlock_teams_description": "Hantera vilka organisationsmedlemmar som har åtkomst till specifika arbetsytor och undersökningar.",
"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.",
"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"
@@ -3075,7 +3066,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 arbetsytans konfiguration för enbart denna undersökning.",
"overwrite_global_waiting_time_description": "Åsidosätt projektkonfigurationen endast för denna enkät.",
"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",
@@ -3756,16 +3747,9 @@
"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",
+59 -75
View File
@@ -148,7 +148,6 @@
"apply_filters": "Filtreleri uygula",
"archived": "Arşivlenmiş",
"are_you_sure": "Emin misiniz?",
"ask": "Ask",
"attributes": "Öznitelikler",
"back": "Geri",
"billing": "Faturalandırma",
@@ -214,7 +213,6 @@
"delete_what": "{deleteWhat} sil",
"description": "Açıklama",
"disable": "Devre dışı bırak",
"disabled": "Devre Dışı",
"disallow": "İzin verme",
"discard": "İptal et",
"dismissed": "Reddedildi",
@@ -518,7 +516,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": "{workspaceLimit} çalışma alanı limitine ulaştınız.",
"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_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."
@@ -716,10 +714,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": "$[workspaceName] ürününün özellikleri ve işlevselliğinden ne kadar memnunsunuz?",
"build_product_roadmap_question_1_headline": "$[projectName] özelliklerinden 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": "$[workspaceName] deneyiminizi en çok geliştirebilmemiz için yapabileceğimiz TEK değişiklik nedir?",
"build_product_roadmap_question_2_headline": "$[projectName] deneyiminizi en çok iyileştirecek TEK değişiklik ne olurdu?",
"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.",
@@ -752,10 +750,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": "$[workspaceName] bünyesinde kişisel ve profesyonel gelişim fırsatlarından memnunum.",
"career_development_survey_question_1_headline": "$[projectName] bünyesindeki kişisel ve mesleki 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": "$[workspaceName] bünyesinde bana sunulan kariyer ilerleme fırsatlarından memnunum.",
"career_development_survey_question_2_headline": "$[projectName] 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.",
@@ -785,7 +783,7 @@
"ces_lower_label": "Çok zor",
"ces_upper_label": "Çok kolay",
"cess_survey_name": "CES Survey",
"cess_survey_question_1_headline": "$[workspaceName], [HEDEF EKLE] konusunda işimi kolaylaştırıyor",
"cess_survey_question_1_headline": "$[projectName] benim için [HEDEF EKLE] işlemini 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?",
@@ -812,7 +810,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": "$[workspaceName] ürününü kullanımını kolaylaştıracak ne olabilirdi?",
"churn_survey_question_2_headline": "$[projectName] kullanımını sizin için ne kolaylaştırırdı?",
"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>",
@@ -853,7 +851,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 $[workspaceName] ürününü bir arkadaşına veya meslektaşına tavsiye etme olasılığın ne kadar?",
"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_lower_label": "Olası değil",
"csat_question_1_upper_label": "Çok olası",
"csat_question_2_choice_1": "Oldukça memnun",
@@ -861,7 +859,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 $[workspaceName] ürünümüzden ne kadar memnunsunuz veya memnun değilsiniz?",
"csat_question_2_headline": "Genel olarak $[projectName] ürünümüzden ne kadar memnun veya memnuniyetsizsiniz?",
"csat_question_2_subheader": "Lütfen birini seçin:",
"csat_question_3_choice_1": "Etkisiz",
"csat_question_3_choice_10": "Benzersiz",
@@ -873,28 +871,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": "$[workspaceName] ürünümüzü tanımlamak için aşağıdaki kelimelerden hangilerini kullanırsınız?",
"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_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": "$[workspaceName] ürünümüz ihtiyaçlarınızı ne kadar karşılıyor?",
"csat_question_4_headline": "$[projectName] ürünlerimiz 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": "$[workspaceName] ürününün kalitesini nasıl değerlendirirsiniz?",
"csat_question_5_headline": "$[projectName] ü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": "$[workspaceName] ürününün fiyat-performans değerini nasıl değerlendirirsiniz?",
"csat_question_6_headline": "$[projectName] ürününün fiyat/performans oranını 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ı",
@@ -908,17 +906,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 $[workspaceName] müşterisisiniz?",
"csat_question_8_headline": "Ne kadar süredir $[projectName] 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": "$[workspaceName] ürünlerimizden herhangi birini tekrar satın alma olasılığınız nedir?",
"csat_question_9_headline": "$[projectName] ü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": "$[workspaceName] CSAT",
"csat_survey_question_1_headline": "$[workspaceName] deneyiminizden ne kadar memnunsunuz?",
"csat_survey_name": "$[projectName] CSAT",
"csat_survey_question_1_headline": "$[projectName] 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ı?",
@@ -933,7 +931,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": "$[workspaceName], [HEDEF EKLE] konusunda işimi kolaylaştırıyor",
"customer_effort_score_question_1_headline": "$[projectName] benim için [HEDEF EKLE] işlemini 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?",
@@ -957,14 +955,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": "$[workspaceName] ürününü başkalarına aktif olarak tavsiye ettiniz mi?",
"earned_advocacy_score_question_1_headline": "$[projectName] ü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ı $[workspaceName] ürününü seçmekten aktif olarak caydırdınız mı?",
"earned_advocacy_score_question_4_headline": "Başkalarını $[projectName] tercih etmekten 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.",
@@ -1013,7 +1011,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": "$[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_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_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",
@@ -1095,13 +1093,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": "$[workspaceName] ürününü kullanmaktaki birincil hedefin nedir?",
"identify_customer_goals_question_1_headline": "$[projectName] kullanmanın birincil hedefin ne?",
"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": "$[workspaceName] ürününe kaydolma olasılığın ne kadar?",
"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_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",
@@ -1109,8 +1107,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": "$[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_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_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…",
@@ -1129,7 +1127,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 $[workspaceName] kullanarak haftada kaç saat tasarruf ediyor?",
"identify_upsell_opportunities_question_1_headline": "Ekibiniz $[projectName] 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",
@@ -1137,10 +1135,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": "$[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_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_2_placeholder": "Cevabınızı buraya yazın…",
"improve_activation_rate_question_3_headline": "$[workspaceName]'i kurarken veya kullanırken neyi zor buldunuz?",
"improve_activation_rate_question_3_headline": "$[projectName] kurulumunda veya kullanımında zor olan neydi?",
"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…",
@@ -1169,9 +1167,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": "Bunu duyduğumuza üzüldük. $[workspaceName] kullanırken karşılaştığınız en büyük sorun neydi?",
"improve_trial_conversion_question_2_headline": "Üzgünüz. $[projectName] kullanırken en büyük sorun neydi?",
"improve_trial_conversion_question_3_button_label": "İleri",
"improve_trial_conversion_question_3_headline": "$[workspaceName]'in ne yapmasını bekliyordunuz?",
"improve_trial_conversion_question_3_headline": "$[projectName]'in ne yapmasını bekliyordun?",
"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>",
@@ -1187,7 +1185,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": "$[workspaceName] ile başka hangi araçları kullanmak istersiniz?",
"integration_setup_survey_question_3_headline": "$[projectName] ile birlikte 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",
@@ -1198,23 +1196,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": "$[workspaceName]'den genel olarak ne kadar memnunsunuz?",
"long_term_retention_check_in_question_1_headline": "Genel olarak $[projectName] ürününden 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": "$[workspaceName]'in en değerli bulduğunuz yanı nedir?",
"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_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": "$[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_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_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": "$[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_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_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": "$[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_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_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",
@@ -1223,7 +1221,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": "$[workspaceName] hakkında bir şeyi değiştirebilseydiniz, bu ne olurdu?",
"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_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",
@@ -1242,8 +1240,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": "$[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_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_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",
@@ -1288,12 +1286,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": "$[workspaceName]'i bir arkadaşınıza veya iş arkadaşınıza tavsiye etme olasılığınız nedir?",
"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_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": "$[workspaceName]'i bir arkadaşınıza veya iş arkadaşınıza tavsiye etme olasılığınız nedir?",
"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_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?",
@@ -1327,7 +1325,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": "{workspaceName}'i nasıl değerlendirirsiniz?",
"preview_survey_question_1_headline": "{projectName} ürününü 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",
@@ -1351,16 +1349,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": "$[workspaceName] deneyiminizi geliştirmek için başka ne yapabiliriz?",
"prioritize_features_question_3_headline": "$[projectName] deneyiminizi başka nasıl iyileştirebiliriz?",
"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": "$[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_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_subheader": "Lütfen aşağıdaki seçeneklerden birini seçin:",
"product_market_fit_short_question_2_headline": "$[workspaceName]'i sizin için nasıl geliştirebiliriz?",
"product_market_fit_short_question_2_headline": "$[projectName] ürününü 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.",
@@ -1370,7 +1368,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": "$[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_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_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",
@@ -1379,9 +1377,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": "$[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_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_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",
@@ -1452,7 +1450,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": "$[workspaceName] hakkında ne düşünüyorsunuz?",
"review_prompt_question_1_headline": "$[projectName] ürününü nasıl buluyorsunuz?",
"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",
@@ -1495,7 +1493,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": "$[workspaceName] hakkında ne düşünüyorsunuz?",
"smileys_survey_question_1_headline": "$[projectName] ürününü nasıl buluyorsunuz?",
"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",
@@ -1505,8 +1503,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": "$[workspaceName] Değerlendirme Anketi",
"star_rating_survey_question_1_headline": "$[workspaceName] hakkında ne düşünüyorsunuz?",
"star_rating_survey_name": "$[projectName] Puanlama Anketi",
"star_rating_survey_question_1_headline": "$[projectName] ürününü nasıl buluyorsunuz?",
"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",
@@ -1539,7 +1537,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": "$[workspaceName] uygulamasında en çok neye değer veriyorsunuz?",
"uncover_strengths_and_weaknesses_question_1_headline": "$[projectName] ürününde 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",
@@ -1555,8 +1553,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 $[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_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_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…",
@@ -1726,7 +1724,6 @@
"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",
@@ -1835,9 +1832,7 @@
"no_data_message": "Veri Yok. Şu anda görüntülenecek bilgi bulunmuyor. Gösterge panelini oluşturmak için grafik ekle.",
"please_enter_name": "Lütfen bir gösterge paneli adı gir"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "Raporlayabileceğiniz Geri Bildirim Kayıtlarınız yok. Verileri sisteme beslemek için Geri Bildirim Kaynaklarını ayarlayın.",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "Geri bildirim kaynaklarını ayarlayın"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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",
@@ -2561,7 +2553,6 @@
"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.",
@@ -3756,16 +3747,9 @@
"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",
+12 -28
View File
@@ -148,7 +148,6 @@
"apply_filters": "应用 筛选",
"archived": "已归档",
"are_you_sure": "你 确定 吗?",
"ask": "Ask",
"attributes": "属性",
"back": "返回",
"billing": "账单",
@@ -214,7 +213,6 @@
"delete_what": "删除{deleteWhat}",
"description": "描述",
"disable": "禁用",
"disabled": "已禁用",
"disallow": "不允许",
"discard": "丢弃",
"dismissed": "忽略",
@@ -1726,7 +1724,6 @@
"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": "平均分",
@@ -1835,9 +1832,7 @@
"no_data_message": "暂无数据。当前没有可显示的信息。请添加图表来构建你的仪表板。",
"please_enter_name": "请输入 Dashboard 名称"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "您没有可供报告的反馈记录。设置反馈源以将数据输入系统。",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "设置反馈源"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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 的连接。",
@@ -2561,7 +2553,6 @@
"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": "此目录尚未链接任何连接器。",
@@ -2588,13 +2579,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 品牌 并启用 额外的 白标 自定义 选项",
@@ -2692,7 +2683,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": "贡献者",
@@ -2708,10 +2699,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": "请 填写 所有 字段 以 添加 新 成员。",
@@ -2728,8 +2719,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": "你是 会员"
@@ -3075,7 +3066,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": "覆盖 全局 调查 放置",
@@ -3756,16 +3747,9 @@
"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": "该调查没有任何问题",
+12 -28
View File
@@ -148,7 +148,6 @@
"apply_filters": "套用篩選器",
"archived": "已封存",
"are_you_sure": "您確定嗎?",
"ask": "Ask",
"attributes": "屬性",
"back": "返回",
"billing": "帳單",
@@ -214,7 +213,6 @@
"delete_what": "刪除{deleteWhat}",
"description": "描述",
"disable": "停用",
"disabled": "已停用",
"disallow": "不允許",
"discard": "捨棄",
"dismissed": "已關閉",
@@ -1726,7 +1724,6 @@
"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": "平均分數",
@@ -1835,9 +1832,7 @@
"no_data_message": "無資料。目前沒有可顯示的資訊。請新增圖表來建立你的儀表板。",
"please_enter_name": "請輸入儀表板名稱"
},
"manage_feedback_sources": "Manage feedback sources",
"no_feedback_records_message": "您沒有可供報告的回饋記錄。設定回饋來源以將資料輸入系統。",
"no_feedback_records_with_sources_message": "No feedback records yet. Records will appear here once your feedback sources start sending data.",
"setup_feedback_source": "設定反饋源"
},
"api_keys": {
@@ -1871,9 +1866,6 @@
"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",
@@ -2561,7 +2553,6 @@
"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": "此目錄尚未連結任何連接器。",
@@ -2588,13 +2579,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 品牌並啟用其他白標自訂選項。",
@@ -2692,7 +2683,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": "投稿人",
@@ -2708,10 +2699,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": "請填寫所有欄位以新增新成員。",
@@ -2728,8 +2719,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": "您是成員"
@@ -3075,7 +3066,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": "覆寫問卷的整體位置",
@@ -3756,16 +3747,9 @@
"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) {
await sendToPipeline({
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) {
await sendToPipeline({
sendToPipeline({
event: "responseFinished",
workspaceId: workspaceIdResult.data.workspaceId,
surveyId: existingResponse.data.surveyId,
@@ -0,0 +1,235 @@
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,5 +1,6 @@
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";
@@ -15,6 +16,31 @@ 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,
@@ -88,13 +114,14 @@ 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
return handleApiError(request, surveyQuestions.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
}
if (!validateFileUploads(body.data, surveyQuestions.data.questions)) {
@@ -108,6 +135,7 @@ 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,
@@ -129,6 +157,7 @@ export const POST = async (request: Request) =>
});
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
surveyQuestions.data.blocks,
body.data,
@@ -152,27 +181,37 @@ export const POST = async (request: Request) =>
return handleApiError(request, createResponseResult.error, auditLog);
}
getResponseForPipeline(createResponseResult.data.id)
.then((createdResponseForPipeline) => {
if (createdResponseForPipeline.ok) {
sendToPipeline({
event: "responseCreated",
// 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",
workspaceId,
surveyId: body.surveyId,
response: createdResponseForPipeline.data,
}).catch(() => {});
if (createResponseResult.data.finished) {
sendToPipeline({
event: "responseFinished",
workspaceId,
surveyId: body.surveyId,
response: createdResponseForPipeline.data,
}).catch(() => {});
}
});
}
})
.catch(() => {});
}
} catch (error) {
logger.error(
{
err: error,
responseId: createResponseResult.data.id,
surveyId: body.surveyId,
workspaceId,
},
"Failed to load response data for pipeline dispatch"
);
}
if (auditLog) {
auditLog.targetId = createResponseResult.data.id;
@@ -213,6 +213,7 @@ export const getChartsAction = authenticatedActionClient
const ZExecuteQueryAction = z.object({
workspaceId: ZId,
query: ZChartQuery,
feedbackRecordDirectoryId: ZId,
});
export const executeQueryAction = authenticatedActionClient
@@ -229,7 +230,7 @@ export const executeQueryAction = authenticatedActionClient
validateQueryMembers(parsedInput.query);
const scopedQuery = injectTenantFilter(parsedInput.query, parsedInput.workspaceId);
const scopedQuery = injectTenantFilter(parsedInput.query, parsedInput.feedbackRecordDirectoryId);
try {
return await executeQuery(scopedQuery as Record<string, unknown>);
@@ -279,6 +280,7 @@ const ZGenerateAIQueryResponse = z.object({
const ZGenerateAIChartAction = z.object({
workspaceId: ZId,
prompt: z.string().min(1).max(2000),
feedbackRecordDirectoryId: ZId,
});
export const generateAIChartAction = authenticatedActionClient
@@ -333,7 +335,10 @@ export const generateAIChartAction = authenticatedActionClient
validateQueryMembers(cleanQuery as TChartQuery);
const scopedQuery = injectTenantFilter(cleanQuery as TChartQuery, parsedInput.workspaceId);
const scopedQuery = injectTenantFilter(
cleanQuery as TChartQuery,
parsedInput.feedbackRecordDirectoryId
);
const data = await executeQuery(scopedQuery as Record<string, unknown>);
@@ -30,6 +30,7 @@ interface AdvancedChartBuilderProps {
initialQuery?: TChartQuery;
hidePreview?: boolean;
onChartGenerated?: (data: AnalyticsResponse) => void;
feedbackRecordDirectoryId: string | null;
runQueryCtaLabel?: string;
}
@@ -83,6 +84,7 @@ export function AdvancedChartBuilder({
initialQuery,
hidePreview = false,
onChartGenerated,
feedbackRecordDirectoryId,
runQueryCtaLabel,
}: Readonly<AdvancedChartBuilderProps>) {
const { t } = useTranslation();
@@ -93,7 +95,11 @@ export function AdvancedChartBuilder({
initialQuery ? { ...initialState, ...parsedInitial } : initialState
);
const { chartData, query, isLoading, error, runQuery } = useChartQuery(workspaceId, initialQuery);
const { chartData, query, isLoading, error, runQuery } = useChartQuery(
workspaceId,
feedbackRecordDirectoryId,
initialQuery
);
const currentQuery = useMemo(() => buildCubeQuery(state), [state]);
const hasConfigChanged = useMemo(() => {
@@ -13,9 +13,14 @@ import { Input } from "@/modules/ui/components/input";
interface AIQuerySectionProps {
workspaceId: string;
onChartGenerated: (data: AnalyticsResponse) => void;
feedbackRecordDirectoryId: string;
}
export function AIQuerySection({ workspaceId, onChartGenerated }: Readonly<AIQuerySectionProps>) {
export function AIQuerySection({
workspaceId,
onChartGenerated,
feedbackRecordDirectoryId,
}: Readonly<AIQuerySectionProps>) {
const [userQuery, setUserQuery] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const { t } = useTranslation();
@@ -28,6 +33,7 @@ export function AIQuerySection({ workspaceId, onChartGenerated }: Readonly<AIQue
const result = await generateAIChartAction({
workspaceId,
prompt: userQuery.trim(),
feedbackRecordDirectoryId,
});
if (result?.data) {
@@ -47,26 +47,17 @@ 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));
}
})
.catch((error) => {
if (cancelled) {
return;
}
const message =
error instanceof Error ? error.message : t("workspace.analysis.charts.failed_to_load_dashboards");
toast.error(message);
});
if (result?.data) {
setDashboards(result.data.map((dashboard) => ({ id: dashboard.id, name: dashboard.name })));
} else {
toast.error(getFormattedErrorMessage(result));
}
});
return () => {
cancelled = true;
@@ -137,12 +128,6 @@ 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,9 +13,10 @@ interface ChartRowProps {
chart: TChartWithCreator;
workspaceId: string;
isReadOnly: boolean;
directories: { id: string; name: string }[];
}
export function ChartRow({ chart, workspaceId, isReadOnly }: Readonly<ChartRowProps>) {
export function ChartRow({ chart, workspaceId, isReadOnly, directories }: Readonly<ChartRowProps>) {
const { t } = useTranslation();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const IconComponent = CHART_TYPE_ICONS[chart.type] ?? BarChart3Icon;
@@ -86,6 +87,7 @@ export function ChartRow({ chart, workspaceId, isReadOnly }: Readonly<ChartRowPr
chartId={chart.id}
initialChart={chart}
onSuccess={() => setIsEditDialogOpen(false)}
directories={directories}
/>
)}
</>
@@ -1,25 +1,33 @@
import { use } from "react";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getTranslate } from "@/lingodotdev/server";
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
import { hasWorkspaceFeedbackRecords } from "@/modules/ee/analysis/lib/feedback-records";
import { hasFeedbackRecordsInDirectories } from "@/modules/ee/analysis/lib/feedback-records";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
interface ChartsListContentProps {
chartsPromise: Promise<TChartWithCreator[]>;
workspaceId: string;
isReadOnly: boolean;
directories: { id: string; name: string }[];
}
const ChartsListContent = ({ chartsPromise, workspaceId, isReadOnly }: Readonly<ChartsListContentProps>) => {
const ChartsListContent = ({
chartsPromise,
workspaceId,
isReadOnly,
directories,
}: Readonly<ChartsListContentProps>) => {
const charts = use(chartsPromise);
return <ChartsList charts={charts} workspaceId={workspaceId} isReadOnly={isReadOnly} />;
return (
<ChartsList charts={charts} workspaceId={workspaceId} isReadOnly={isReadOnly} directories={directories} />
);
};
interface ChartsListPageProps {
@@ -29,10 +37,10 @@ interface ChartsListPageProps {
export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPageProps>) {
const t = await getTranslate();
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
const [hasFeedbackRecords, connectors] = await Promise.all([
hasWorkspaceFeedbackRecords(workspaceId),
getConnectorsWithMappings(workspaceId),
]);
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
directories.map((directory) => directory.id)
);
const chartsPromise = hasFeedbackRecords ? getChartsWithCreator(workspaceId) : null;
return (
@@ -41,13 +49,22 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
workspaceId={workspaceId}
cta={
isReadOnly ? undefined : (
<CreateChartButton workspaceId={workspaceId} buttonProps={{ disabled: !hasFeedbackRecords }} />
<CreateChartButton
workspaceId={workspaceId}
directories={directories}
buttonProps={{ disabled: !hasFeedbackRecords }}
/>
)
}>
{hasFeedbackRecords && chartsPromise ? (
<ChartsListContent chartsPromise={chartsPromise} workspaceId={workspaceId} isReadOnly={isReadOnly} />
<ChartsListContent
chartsPromise={chartsPromise}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
directories={directories}
/>
) : (
<NoFeedbackRecordsState workspaceId={workspaceId} hasFeedbackSources={connectors.length > 0} />
<NoFeedbackRecordsState workspaceId={workspaceId} />
)}
</AnalysisPageLayout>
);
@@ -6,9 +6,15 @@ interface ChartsListProps {
charts: TChartWithCreator[];
workspaceId: string;
isReadOnly: boolean;
directories: { id: string; name: string }[];
}
export const ChartsList = async ({ charts, workspaceId, isReadOnly }: Readonly<ChartsListProps>) => {
export const ChartsList = async ({
charts,
workspaceId,
isReadOnly,
directories,
}: Readonly<ChartsListProps>) => {
const t = await getTranslate();
return (
@@ -26,7 +32,13 @@ export const ChartsList = async ({ charts, workspaceId, isReadOnly }: Readonly<C
</p>
) : (
charts.map((chart) => (
<ChartRow key={chart.id} chart={chart} workspaceId={workspaceId} isReadOnly={isReadOnly} />
<ChartRow
key={chart.id}
chart={chart}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
directories={directories}
/>
))
)}
</div>
@@ -8,6 +8,7 @@ import { Button, type ButtonProps } from "@/modules/ui/components/button";
interface CreateChartButtonProps {
workspaceId: string;
directories: { id: string; name: string }[];
autoAddToDashboardId?: string;
label?: string;
onSuccess?: () => void;
@@ -17,6 +18,7 @@ interface CreateChartButtonProps {
export function CreateChartButton({
workspaceId,
directories,
autoAddToDashboardId,
label,
onSuccess,
@@ -37,6 +39,7 @@ export function CreateChartButton({
onOpenChange={setIsDialogOpen}
workspaceId={workspaceId}
autoAddToDashboardId={autoAddToDashboardId}
directories={directories}
onSuccess={onSuccess}
/>
</>
@@ -11,6 +11,7 @@ export interface CreateChartDialogProps {
autoAddToDashboardId?: string;
initialChart?: TChartWithCreator;
onSuccess?: () => void;
directories: { id: string; name: string }[];
}
export function CreateChartDialog({
@@ -21,6 +22,7 @@ export function CreateChartDialog({
autoAddToDashboardId,
initialChart,
onSuccess,
directories,
}: Readonly<CreateChartDialogProps>) {
return (
<CreateChartView
@@ -31,6 +33,7 @@ export function CreateChartDialog({
initialChart={initialChart}
autoAddToDashboardId={autoAddToDashboardId}
onSuccess={onSuccess}
directories={directories}
/>
);
}
@@ -11,6 +11,7 @@ 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,
@@ -31,6 +32,7 @@ interface CreateChartViewProps {
initialChart?: TChartWithCreator;
autoAddToDashboardId?: string;
onSuccess?: () => void;
directories: { id: string; name: string }[];
}
export function CreateChartView({
@@ -41,6 +43,7 @@ export function CreateChartView({
initialChart,
autoAddToDashboardId,
onSuccess,
directories,
}: Readonly<CreateChartViewProps>) {
const { t } = useTranslation();
const isEditing = !!chartId;
@@ -57,6 +60,7 @@ export function CreateChartView({
handleChartGenerated,
handleSaveChart,
isSaving,
selectedDirectoryId,
handleClose,
} = useChartDialog({
open,
@@ -66,6 +70,7 @@ export function CreateChartView({
initialChart,
autoAddToDashboardId,
onSuccess,
directories,
});
const chartPreviewRef = useRef<HTMLDivElement>(null);
@@ -101,7 +106,8 @@ export function CreateChartView({
);
}
const chartType = selectedChartType ?? (isEditing ? (initialChart?.type ?? DEFAULT_CHART_TYPE) : undefined);
const chartType = selectedChartType ?? (isEditing ? DEFAULT_CHART_TYPE : undefined);
const hasSelectedDirectory = !!selectedDirectoryId;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
@@ -123,56 +129,76 @@ export function CreateChartView({
</DialogHeader>
<DialogBody>
<div className="grid gap-4">
<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 && (
{hasSelectedDirectory ? (
<>
<AIQuerySection workspaceId={workspaceId} onChartGenerated={handleChartGenerated} />
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-2 text-sm text-gray-500">
{t("workspace.analysis.charts.OR")}
</span>
</div>
<div className="space-y-2">
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
<Input
id="create-chart-name"
value={chartName}
onChange={(event) => setChartName(event.target.value)}
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
maxLength={255}
required
/>
</div>
{!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>
)}
</>
)}
<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>
) : (
<Alert variant="error" size="small">
<div>
<p>{t("workspace.analysis.charts.no_data_source_available")}</p>
<a
className="mt-1 inline-block font-medium underline"
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.analysis.charts.go_to_feedback_record_directories")}
</a>
</div>
</Alert>
)}
</div>
</DialogBody>
@@ -1,345 +0,0 @@
/**
* @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,6 +30,7 @@ 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({
@@ -40,6 +41,7 @@ export function useChartDialog({
autoAddToDashboardId,
initialChart,
onSuccess,
directories,
}: Readonly<UseChartDialogProps>) {
const { t } = useTranslation();
const router = useRouter();
@@ -53,6 +55,7 @@ export function useChartDialog({
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories?.[0]?.id ?? null);
useEffect(() => {
let cancelled = false;
@@ -81,6 +84,7 @@ export function useChartDialog({
setChartName("");
setSelectedChartType(undefined);
setCurrentChartId(undefined);
setSelectedDirectoryId(directories?.[0]?.id ?? null);
return;
}
@@ -105,10 +109,12 @@ 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;
@@ -161,8 +167,12 @@ 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;
@@ -193,6 +203,7 @@ export function useChartDialog({
type: chartData.chartType,
query: chartData.query,
config: {},
feedbackRecordDirectoryId: selectedDirectoryId,
},
});
@@ -204,7 +215,6 @@ export function useChartDialog({
setCurrentChartId(result.data.id);
savedChartId = result.data.id;
newlyCreatedChartId = result.data.id;
toast.success(t("workspace.analysis.charts.chart_saved_successfully"));
}
@@ -220,7 +230,6 @@ export function useChartDialog({
getFormattedErrorMessage(addResult) ||
t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
);
if (newlyCreatedChartId) await cleanupOrphanChart(newlyCreatedChartId);
return;
}
@@ -237,7 +246,6 @@ 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);
}
@@ -252,6 +260,11 @@ 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: {
@@ -259,6 +272,7 @@ export function useChartDialog({
type: data.chartType,
query: data.query,
config: {},
feedbackRecordDirectoryId: selectedDirectoryId,
},
});
@@ -331,6 +345,7 @@ export function useChartDialog({
setSelectedChartType(undefined);
setCurrentChartId(undefined);
setChartLoadError(null);
setSelectedDirectoryId(directories?.[0]?.id ?? null);
onOpenChange(false);
}
};
@@ -359,6 +374,8 @@ export function useChartDialog({
isSaving,
isLoadingChart,
chartLoadError,
selectedDirectoryId,
setSelectedDirectoryId,
handleChartGenerated,
handleSaveChart,
handleAddToDashboard,
@@ -13,7 +13,11 @@ export interface QueryResult {
data: TChartDataRow[];
}
export function useChartQuery(workspaceId: string, initialQuery?: TChartQuery) {
export function useChartQuery(
workspaceId: string,
feedbackRecordDirectoryId: string | null,
initialQuery?: TChartQuery
) {
const { t } = useTranslation();
const [chartData, setChartData] = useState<TChartDataRow[] | null>(null);
const [query, setQuery] = useState<TChartQuery | null>(initialQuery ?? null);
@@ -21,6 +25,12 @@ export function useChartQuery(workspaceId: string, initialQuery?: TChartQuery) {
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);
@@ -28,6 +38,7 @@ export function useChartQuery(workspaceId: string, initialQuery?: TChartQuery) {
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
* workspace. Called server-side before every query execution.
* FeedbackRecordDirectory. Called server-side before every query execution.
*/
export function injectTenantFilter(query: TChartQuery, tenantId: string): TChartQuery {
const tenantFilter: TCubeFilter = {
@@ -45,16 +45,20 @@ 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"),
};
@@ -78,6 +82,7 @@ describe("Chart Service", () => {
type: "bar",
query: { measures: ["Responses.count"] },
config: { showLegend: true },
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
createdBy: mockUserId,
});
@@ -89,6 +94,7 @@ describe("Chart Service", () => {
workspaceId: mockWorkspaceId,
query: { measures: ["Responses.count"] },
config: { showLegend: true },
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
createdBy: mockUserId,
},
select: selectChart,
@@ -108,6 +114,7 @@ describe("Chart Service", () => {
type: "bar",
query: {},
config: {},
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
createdBy: mockUserId,
})
).rejects.toMatchObject({
@@ -126,6 +133,7 @@ describe("Chart Service", () => {
type: "bar",
query: {},
config: {},
feedbackRecordDirectoryId: mockFeedbackRecordDirectoryId,
createdBy: mockUserId,
})
).rejects.toMatchObject({
@@ -340,7 +348,7 @@ describe("Chart Service", () => {
});
describe("getCharts", () => {
test("returns all charts for a workspace", async () => {
test("returns all charts for a project", async () => {
const chartsFromDb = [
{ ...mockChart, creator: { name: "User 1" } },
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: { name: null } },
@@ -363,6 +371,7 @@ describe("Chart Service", () => {
type: true,
query: true,
config: true,
feedbackRecordDirectoryId: true,
createdAt: true,
updatedAt: true,
creator: { select: { name: true } },
@@ -22,6 +22,7 @@ export const selectChart = {
type: true,
query: true,
config: true,
feedbackRecordDirectoryId: true,
createdAt: true,
updatedAt: true,
} as const;
@@ -38,6 +39,7 @@ export const createChart = async (data: TChartCreateInput): Promise<TChart> => {
query: data.query,
config: data.config,
createdBy: data.createdBy,
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
},
select: selectChart,
});
@@ -154,6 +156,7 @@ 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) {
@@ -5,13 +5,9 @@ import { Button } from "@/modules/ui/components/button";
interface NoFeedbackRecordsStateProps {
workspaceId: string;
hasFeedbackSources?: boolean;
}
export const NoFeedbackRecordsState = async ({
workspaceId,
hasFeedbackSources = false,
}: Readonly<NoFeedbackRecordsStateProps>) => {
export const NoFeedbackRecordsState = async ({ workspaceId }: Readonly<NoFeedbackRecordsStateProps>) => {
const t = await getTranslate();
return (
@@ -19,15 +15,11 @@ export const NoFeedbackRecordsState = async ({
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 text-center">
<MessageSquareDashedIcon className="h-8 w-8 text-slate-400" />
<p className="text-balance text-sm text-slate-600">
{hasFeedbackSources
? t("workspace.analysis.no_feedback_records_with_sources_message")
: t("workspace.analysis.no_feedback_records_message")}
{t("workspace.analysis.no_feedback_records_message")}
</p>
<Button asChild size="sm">
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
{hasFeedbackSources
? t("workspace.analysis.manage_feedback_sources")
: t("workspace.analysis.setup_feedback_source")}
{t("workspace.analysis.setup_feedback_source")}
</Link>
</Button>
</div>
@@ -292,6 +292,9 @@ export const addChartToDashboardAction = authenticatedActionClient
layout: parsedInput.layout,
});
revalidatePath(`/workspaces/${workspaceId}/dashboards`);
revalidatePath(`/workspaces/${workspaceId}/dashboards/${parsedInput.dashboardId}`);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.workspaceId = workspaceId;
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
@@ -2,7 +2,7 @@
import { Loader2Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
@@ -27,6 +27,7 @@ interface AddExistingChartsDialogProps {
onOpenChange: (open: boolean) => void;
workspaceId: string;
dashboardId: string;
directories: { id: string; name: string }[];
existingChartIds: string[];
onSuccess: () => void;
}
@@ -41,6 +42,7 @@ export function AddExistingChartsDialog({
onOpenChange,
workspaceId,
dashboardId,
directories,
existingChartIds,
onSuccess,
}: Readonly<AddExistingChartsDialogProps>) {
@@ -51,18 +53,13 @@ export function AddExistingChartsDialog({
const [isLoading, setIsLoading] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const existingChartIdsRef = useRef(existingChartIds);
existingChartIdsRef.current = existingChartIds;
const loadCharts = useCallback(async () => {
setIsLoading(true);
setSelectedChartIds([]);
try {
const result = await getChartsAction({ workspaceId });
if (result?.data) {
const availableCharts = result.data.filter(
(chart) => !existingChartIdsRef.current.includes(chart.id)
);
const availableCharts = result.data.filter((chart) => !existingChartIds.includes(chart.id));
setChartOptions(availableCharts.map((chart) => ({ value: chart.id, label: chart.name })));
} else {
const errorMessage = getFormattedErrorMessage(result);
@@ -73,7 +70,7 @@ export function AddExistingChartsDialog({
} finally {
setIsLoading(false);
}
}, [workspaceId, t]);
}, [workspaceId, existingChartIds, t]);
useEffect(() => {
if (!open) return;
@@ -149,6 +146,7 @@ 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,6 +15,7 @@ import { IconBar } from "@/modules/ui/components/iconbar";
interface DashboardControlBarProps {
workspaceId: string;
dashboardId: string;
directories: { id: string; name: string }[];
existingChartIds: string[];
isEditing: boolean;
isSaving: boolean;
@@ -29,6 +30,7 @@ interface DashboardControlBarProps {
export const DashboardControlBar = ({
workspaceId,
dashboardId,
directories,
existingChartIds,
isEditing,
isSaving,
@@ -131,6 +133,7 @@ export const DashboardControlBar = ({
onOpenChange={setIsAddExistingDialogOpen}
workspaceId={workspaceId}
dashboardId={dashboardId}
directories={directories}
existingChartIds={existingChartIds}
onSuccess={() => {
setIsAddExistingDialogOpen(false);
@@ -28,6 +28,7 @@ interface DashboardDetailClientProps {
workspaceId: string;
dashboard: TDashboardDetail;
widgetDataPromises: Map<string, Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>>;
directories: { id: string; name: string }[];
isReadOnly: boolean;
}
@@ -126,7 +127,7 @@ const MemoizedWidgetItem = memo(function WidgetItem({
onResize?: () => void;
onRemove?: () => void;
}>) {
const title = widget.chart?.name ?? "";
const title = widget.chart.name;
return (
<DashboardWidget
@@ -144,6 +145,7 @@ export function DashboardDetailClient({
workspaceId,
dashboard,
widgetDataPromises,
directories,
isReadOnly,
}: Readonly<DashboardDetailClientProps>) {
const router = useRouter();
@@ -285,13 +287,17 @@ export function DashboardDetailClient({
<DashboardControlBar
workspaceId={workspaceId}
dashboardId={dashboard.id}
directories={directories}
existingChartIds={widgets.map((w) => w.chartId)}
isEditing={isEditing}
isSaving={isSaving}
hasChanges={hasChanges}
isReadOnly={isReadOnly}
onRefresh={() => router.refresh()}
onEditToggle={handleEnterEditMode}
onEditToggle={() => {
setDraftWidgets(dashboard.widgets);
setIsEditing(true);
}}
onSave={handleSave}
onCancel={handleCancel}
/>
@@ -355,6 +361,7 @@ export function DashboardDetailClient({
setEditingChartId(null);
router.refresh();
}}
directories={directories}
/>
)}
</PageContentWrapper>
@@ -60,6 +60,7 @@ vi.mock("@/modules/ee/analysis/charts/lib/charts", () => ({
type: true,
query: true,
config: true,
feedbackRecordDirectoryId: true,
createdAt: true,
updatedAt: true,
},
@@ -418,7 +419,7 @@ describe("Dashboard Service", () => {
});
describe("getDashboards", () => {
test("returns all dashboards for a workspace with creator", async () => {
test("returns all dashboards for a project 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,6 +4,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
import { injectTenantFilter } from "@/modules/ee/analysis/charts/lib/chart-utils";
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { DashboardDetailClient } from "../components/dashboard-detail-client";
import { getDashboard } from "../lib/dashboards";
@@ -15,15 +16,14 @@ interface WidgetQueryResult {
async function executeWidgetQuery(
query: TChartQuery,
workspaceId: string
): Promise<WidgetQueryResult | { error: string }> {
feedbackRecordDirectoryId: string
): Promise<WidgetQueryResult | null> {
try {
const scopedQuery = injectTenantFilter(query, workspaceId);
const scopedQuery = injectTenantFilter(query, feedbackRecordDirectoryId);
const data = await executeQuery(scopedQuery as Record<string, unknown>);
return { data: Array.isArray(data) ? data : [], query };
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load chart data";
return { error: message };
} catch {
return null;
}
}
@@ -34,6 +34,7 @@ export async function DashboardDetailPage({
}>) {
const { workspaceId, dashboardId } = await params;
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
let dashboard;
try {
@@ -45,19 +46,28 @@ export async function DashboardDetailPage({
throw error;
}
const widgetDataPromises = new Map<string, Promise<WidgetQueryResult | { error: string }>>();
const widgetDataPromises = new Map<string, Promise<WidgetQueryResult>>();
const widgetsWithCharts = dashboard.widgets.filter(
(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, workspaceId));
}
const queryPromises = widgetsWithCharts.map((widget) => ({
widgetId: widget.id,
promise: executeWidgetQuery(widget.chart.query, widget.chart.feedbackRecordDirectoryId),
}));
const results = await Promise.all(queryPromises.map((q) => q.promise));
queryPromises.forEach(({ widgetId }, i: number) => {
const result = results[i];
if (result) {
widgetDataPromises.set(widgetId, Promise.resolve(result));
}
});
return (
<DashboardDetailClient
workspaceId={workspaceId}
dashboard={dashboard}
widgetDataPromises={widgetDataPromises}
directories={directories}
isReadOnly={isReadOnly}
/>
);
@@ -1,5 +1,4 @@
import { use } from "react";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getTranslate } from "@/lingodotdev/server";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
@@ -34,10 +33,7 @@ export const DashboardsListPage = async ({ workspaceId }: Readonly<DashboardsLis
const t = await getTranslate();
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
const [hasFeedbackRecords, connectors] = await Promise.all([
hasWorkspaceFeedbackRecords(workspaceId),
getConnectorsWithMappings(workspaceId),
]);
const hasFeedbackRecords = await hasWorkspaceFeedbackRecords(workspaceId);
const dashboardsPromise = hasFeedbackRecords ? getDashboards(workspaceId) : null;
return (
@@ -56,7 +52,7 @@ export const DashboardsListPage = async ({ workspaceId }: Readonly<DashboardsLis
isReadOnly={isReadOnly}
/>
) : (
<NoFeedbackRecordsState workspaceId={workspaceId} hasFeedbackSources={connectors.length > 0} />
<NoFeedbackRecordsState workspaceId={workspaceId} />
)}
</AnalysisPageLayout>
);
@@ -1,70 +0,0 @@
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,13 +1,30 @@
import "server-only";
"server-only";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { listFeedbackRecords } from "@/modules/hub/service";
export const hasWorkspaceFeedbackRecords = async (workspaceId: string): Promise<boolean> => {
const result = await listFeedbackRecords({ tenant_id: workspaceId, limit: 1 });
export const hasFeedbackRecordsInDirectories = async (directoryIds: string[]): Promise<boolean> => {
if (directoryIds.length === 0) {
return false;
}
if (result.error) {
// Do not lock creation flows when record availability is unknown.
const results = await Promise.all(
directoryIds.map((directoryId) => listFeedbackRecords({ tenant_id: directoryId, limit: 1 }))
);
const hasRecords = results.some((result) => (result.data?.data?.length ?? 0) > 0);
if (hasRecords) {
return true;
}
return (result.data?.data?.length ?? 0) > 0;
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));
};

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