Compare commits

..

11 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
128 changed files with 2697 additions and 2542 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";
@@ -2,6 +2,7 @@
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";
@@ -106,10 +107,9 @@ const getWorkspaceDirectoryIds = async (workspaceId: string): Promise<Set<string
return new Set(directories.map((directory) => directory.id));
};
const assertRecordBelongsToWorkspace = (directoryIds: Set<string>, tenantId: string): void => {
const assertWorkspaceDirectoryAccess = (directoryIds: Set<string>, tenantId: string): void => {
if (!directoryIds.has(tenantId)) {
// Throw a generic error indistinguishable from "not found" to prevent IDOR
throw new Error("Feedback record not found");
throw new AuthorizationError("Invalid feedback record directory for this workspace");
}
};
@@ -123,17 +123,15 @@ export const retrieveFeedbackRecordAction = authenticatedActionClient
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZRetrieveFeedbackRecordAction>;
}) => {
const [, workspaceDirectoryIds] = await Promise.all([
ensureAccess(ctx.user.id, parsedInput.workspaceId, "read"),
getWorkspaceDirectoryIds(parsedInput.workspaceId),
]);
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(workspaceDirectoryIds, recordResult.data.tenant_id);
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, recordResult.data.tenant_id);
return recordResult.data;
}
@@ -152,31 +150,11 @@ export const createFeedbackRecordAction = authenticatedActionClient
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
assertRecordBelongsToWorkspace(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
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");
}
@@ -195,36 +173,21 @@ export const updateFeedbackRecordAction = authenticatedActionClient
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateFeedbackRecordAction>;
}) => {
const [, workspaceDirectoryIds] = await Promise.all([
ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite"),
getWorkspaceDirectoryIds(parsedInput.workspaceId),
]);
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(workspaceDirectoryIds, 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,24 +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";
} from "./actions";
type FeedbackRecordDrawerMode = "create" | "edit";
@@ -71,6 +56,198 @@ interface FeedbackRecordFormDrawerProps {
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,
@@ -139,9 +316,14 @@ export const FeedbackRecordFormDrawer = ({
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);
};
@@ -260,15 +442,7 @@ export const FeedbackRecordFormDrawer = ({
Object.entries(record?.metadata ?? {}).filter(([, value]) => typeof value !== "string")
);
const updatePayload: {
language: string | null;
user_identifier: string | null;
metadata: Record<string, unknown>;
value_text?: string;
value_number?: number | null;
value_boolean?: boolean | null;
value_date?: string | null;
} = {
const updatePayload: Record<string, unknown> = {
language: values.language?.trim() || null,
user_identifier: values.user_identifier?.trim() || null,
metadata: { ...preservedMetadata, ...metadata },
@@ -287,7 +461,7 @@ export const FeedbackRecordFormDrawer = ({
const updateResult = await updateFeedbackRecordAction({
workspaceId,
recordId,
updateInput: updatePayload,
updateInput: updatePayload as never,
});
if (!updateResult?.data) {
@@ -4,7 +4,7 @@ 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 {
@@ -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) + "…";
@@ -91,6 +102,22 @@ export const FeedbackRecordsTable = ({
.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);
@@ -121,7 +148,7 @@ export const FeedbackRecordsTable = ({
}
const mergedRecords = successfulRecords
.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.slice(0, RECORDS_PER_PAGE);
setRecords(mergedRecords);
setIsRefreshing(false);
@@ -168,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>
)}
@@ -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().min(1),
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,169 +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 first directory as tenant_id", () => {
const dirs = [{ id: "dir-1", name: "Dir 1" }];
const result = getCreateDefaults(dirs);
expect(result.tenant_id).toBe("dir-1");
expect(result.submission_id).toBe("mock-uuid-v7");
expect(result.field_type).toBe("text");
expect(result.metadataEntries).toEqual([]);
});
test("handles empty directories", () => {
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,143 +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 = (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: [],
};
};
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");
default:
return sourceType;
}
};
@@ -4,7 +4,7 @@ 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;
@@ -41,7 +41,7 @@ export default async function UnifyFeedbackRecordsPage(
const merged = successfulResults
.flatMap((r) => r.data?.data ?? [])
.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
.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]));
@@ -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>
@@ -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";
@@ -109,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))
@@ -321,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,
@@ -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;
}
+2 -39
View File
@@ -185,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
@@ -305,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
@@ -406,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
@@ -1789,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
@@ -2447,14 +2441,10 @@ checksums:
workspace/settings/feedback_record_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
workspace/settings/feedback_record_directories/no_access: cc3385cd01a11e3949003a2cc6fb5b31
workspace/settings/feedback_record_directories/no_connectors: b1becb4fe4e2ba7c5d277db149f092ff
workspace/settings/feedback_record_directories/pause_connectors_confirmation_description: a3c2c56daed9f2a9e6a853cb8b924bad
workspace/settings/feedback_record_directories/pause_connectors_confirmation_title: 09041363c55fb2686f8115df6fa2afc1
workspace/settings/feedback_record_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
workspace/settings/feedback_record_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
workspace/settings/feedback_record_directories/title: e3d425c27f80162f29ce094e31a3fd8f
workspace/settings/feedback_record_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
workspace/settings/feedback_record_directories/unarchive_workspace_conflict: 82f4b8ebaf41589cfb96e6398dafcc76
workspace/settings/feedback_record_directories/workspace_access: 32407b39cf878fb579559c1ed3660892
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
workspace/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
workspace/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
@@ -3463,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
@@ -3496,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
@@ -3511,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
@@ -3538,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
@@ -3566,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
@@ -3581,16 +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/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
@@ -3598,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",
})
);
});
+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;
};
+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"] },
-1
View File
@@ -12,7 +12,6 @@ const selectWorkspace = {
id: true,
createdAt: true,
updatedAt: true,
legacyEnvironmentId: true,
name: true,
organizationId: true,
languages: true,
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "Aktivität",
"add": "Hinzufügen",
"add_action": "Aktion hinzufügen",
"add_chart": "Diagramm hinzufügen",
"add_charts": "Diagramme hinzufügen",
"add_existing_chart_description": "Suche und wähle Diagramme aus, um sie zu diesem Dashboard hinzuzufügen.",
"add_filter": "Filter hinzufügen",
@@ -212,7 +213,6 @@
"delete_what": "{deleteWhat} löschen",
"description": "Beschreibung",
"disable": "Deaktivieren",
"disabled": "Deaktiviert",
"disallow": "Nicht erlauben",
"discard": "Verwerfen",
"dismissed": "Verworfen",
@@ -392,6 +392,7 @@
"report_survey": "Umfrage melden",
"request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen",
"resize": "Größe ändern",
"response": "Antwort",
"response_id": "Antwort-ID",
"responses": "Antworten",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "Bitte wähle ein Dashboard aus",
"predefined_measures": "Vordefinierte Kennzahlen",
"preset": "Vorlage",
"preview_chart": "Vorschaudiagramm",
"query_executed_successfully": "Abfrage erfolgreich ausgeführt",
"reset_to_ai_suggestion": "Auf KI-Vorschlag zurücksetzen",
"save_and_add_to_dashboard": "Speichern und zum Dashboard hinzufügen",
"save_chart": "Diagramm speichern",
"save_chart_dialog_title": "Diagramm speichern",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "Startdatum",
"time_dimension": "Zeitdimension",
"time_dimension_title": "Zeitbasierte Gruppierung hinzufügen",
"time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf."
"time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf.",
"update_chart": "Diagramm aktualisieren"
},
"dashboards": {
"add_count_charts": "{count} Diagramm(e) hinzufügen",
@@ -1809,6 +1813,7 @@
"create_dashboard": "Dashboard erstellen",
"create_dashboard_description": "Gib einen Namen für dein neues Dashboard ein.",
"create_failed": "Dashboard konnte nicht erstellt werden",
"create_new_chart": "Neues Diagramm erstellen",
"create_success": "Dashboard erfolgreich erstellt!",
"dashboard": "Dashboard",
"dashboard_delete_confirmation": "Bist du sicher, dass du dieses Dashboard löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "Dashboard konnte nicht dupliziert werden",
"duplicate_success": "Dashboard erfolgreich dupliziert!",
"failed_to_load_chart_data": "Diagrammdaten konnten nicht geladen werden",
"no_charts_available_description": "Es gibt keine Diagramme, die zu diesem Dashboard hinzugefügt werden können. Entweder existieren noch keine Diagramme oder alle vorhandenen Diagramme wurden bereits hinzugefügt. Gehe zur Diagramm-Seite, um neue Diagramme zu erstellen.",
"no_charts_to_add_message": "Keine Diagramme zum Hinzufügen zu diesem Dashboard vorhanden.",
"no_dashboards_found": "Keine Dashboards gefunden.",
"no_data_message": "Keine Daten. Es gibt derzeit keine Informationen zum Anzeigen. Füge Diagramme hinzu, um dein Dashboard zu erstellen.",
"please_enter_name": "Bitte gib einen Dashboard-Namen ein"
}
},
"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.",
"setup_feedback_source": "Richten Sie Feedbackquellen ein"
},
"api_keys": {
"add_api_key": "API-Key hinzufügen",
@@ -1861,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",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "Quellentyp kann nicht geändert werden",
"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",
+33 -30
View File
@@ -125,6 +125,7 @@
"activity": "Activity",
"add": "Add",
"add_action": "Add action",
"add_chart": "Add chart",
"add_charts": "Add charts",
"add_existing_chart_description": "Search and select charts to add to this dashboard.",
"add_filter": "Add filter",
@@ -212,7 +213,6 @@
"delete_what": "Delete {deleteWhat}",
"description": "Description",
"disable": "Disable",
"disabled": "Disabled",
"disallow": "Do not allow",
"discard": "Discard",
"dismissed": "Dismissed",
@@ -392,6 +392,7 @@
"report_survey": "Report Survey",
"request_trial_license": "Request trial license",
"reset_to_default": "Reset to default",
"resize": "Resize",
"response": "Response",
"response_id": "Response ID",
"responses": "Responses",
@@ -1784,9 +1785,11 @@
"please_select_dashboard": "Please select a dashboard",
"predefined_measures": "Predefined Measures",
"preset": "Preset",
"preview_chart": "Preview chart",
"query_executed_successfully": "Query executed successfully",
"reset_to_ai_suggestion": "Reset to AI suggestion",
"save_chart": "Save Chart",
"save_and_add_to_dashboard": "Save & add to dashboard",
"save_chart": "Save chart",
"save_chart_dialog_title": "Save Chart",
"select_data_source": "Select a data source",
"select_data_source_first": "Please select a data source first",
@@ -1798,7 +1801,8 @@
"start_date": "Start date",
"time_dimension": "Time Dimension",
"time_dimension_title": "Add time-based grouping",
"time_dimension_toggle_description": "Monitor trends over time."
"time_dimension_toggle_description": "Monitor trends over time.",
"update_chart": "Update chart"
},
"dashboards": {
"add_count_charts": "Add {count} chart(s)",
@@ -1809,6 +1813,7 @@
"create_dashboard": "Create dashboard",
"create_dashboard_description": "Enter a name for your new dashboard.",
"create_failed": "Failed to create dashboard",
"create_new_chart": "Create new chart",
"create_success": "Dashboard created successfully!",
"dashboard": "Dashboard",
"dashboard_delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "Failed to duplicate dashboard",
"duplicate_success": "Dashboard duplicated successfully!",
"failed_to_load_chart_data": "Failed to load chart data",
"no_charts_available_description": "There are no charts that can be added to this dashboard. Either no charts exist yet, or all existing charts have already been added. Go to the Charts page to create new charts.",
"no_charts_to_add_message": "No charts to add to this dashboard.",
"no_dashboards_found": "No dashboards found.",
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
"please_enter_name": "Please enter a dashboard name"
}
},
"no_feedback_records_message": "You don't have Feedback Records to report on. Setup Feedback Sources to feed data into the system.",
"setup_feedback_source": "Setup feedback sources"
},
"api_keys": {
"add_api_key": "Add API Key",
@@ -1843,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",
@@ -1861,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",
@@ -2534,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",
@@ -2554,13 +2556,13 @@
"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": {
@@ -3604,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",
@@ -3630,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",
@@ -3651,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.",
@@ -3682,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...",
@@ -3710,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",
@@ -3720,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",
@@ -3734,19 +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",
"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",
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "Actividad",
"add": "Añadir",
"add_action": "Añadir acción",
"add_chart": "Agregar gráfico",
"add_charts": "Añadir gráficos",
"add_existing_chart_description": "Busca y selecciona gráficos para añadir a este panel.",
"add_filter": "Añadir filtro",
@@ -212,7 +213,6 @@
"delete_what": "Eliminar {deleteWhat}",
"description": "Descripción",
"disable": "Desactivar",
"disabled": "Desactivado",
"disallow": "No permitir",
"discard": "Descartar",
"dismissed": "Descartado",
@@ -392,6 +392,7 @@
"report_survey": "Reportar encuesta",
"request_trial_license": "Solicitar licencia de prueba",
"reset_to_default": "Restablecer a valores predeterminados",
"resize": "Cambiar tamaño",
"response": "Respuesta",
"response_id": "ID de respuesta",
"responses": "Respuestas",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "Selecciona un panel de control",
"predefined_measures": "Medidas predefinidas",
"preset": "Preajuste",
"preview_chart": "Vista previa del gráfico",
"query_executed_successfully": "Consulta ejecutada correctamente",
"reset_to_ai_suggestion": "Restablecer a sugerencia de IA",
"save_and_add_to_dashboard": "Guardar y agregar al panel",
"save_chart": "Guardar gráfico",
"save_chart_dialog_title": "Guardar gráfico",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "Fecha de inicio",
"time_dimension": "Dimensión temporal",
"time_dimension_title": "Añadir agrupación temporal",
"time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo."
"time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo.",
"update_chart": "Cuadro de actualización"
},
"dashboards": {
"add_count_charts": "Añadir {count} gráfico(s)",
@@ -1809,6 +1813,7 @@
"create_dashboard": "Crear panel",
"create_dashboard_description": "Introduce un nombre para tu panel de control nuevo.",
"create_failed": "Error al crear el panel de control",
"create_new_chart": "Crear nuevo gráfico",
"create_success": "Panel de control creado correctamente",
"dashboard": "Panel",
"dashboard_delete_confirmation": "¿Estás seguro de que quieres eliminar este panel? Esta acción no se puede deshacer.",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "Error al duplicar el panel de control",
"duplicate_success": "Panel de control duplicado correctamente",
"failed_to_load_chart_data": "Error al cargar los datos del gráfico",
"no_charts_available_description": "No hay gráficos que se puedan añadir a este panel. O bien no existen gráficos todavía, o todos los gráficos existentes ya se han añadido. Ve a la página de Gráficos para crear nuevos gráficos.",
"no_charts_to_add_message": "No hay gráficos para añadir a este panel.",
"no_dashboards_found": "No se han encontrado paneles de control.",
"no_data_message": "Sin datos. Actualmente no hay información que mostrar. Añade gráficos para crear tu panel.",
"please_enter_name": "Por favor, introduce un nombre para el panel de control"
}
},
"no_feedback_records_message": "No tienes registros de comentarios sobre los que informar. Configure fuentes de comentarios para introducir datos en el sistema.",
"setup_feedback_source": "Configurar fuentes de comentarios"
},
"api_keys": {
"add_api_key": "Añadir clave API",
@@ -1861,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",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "El tipo de origen no se puede cambiar",
"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",
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "Activité",
"add": "Ajouter",
"add_action": "Ajouter une action",
"add_chart": "Ajouter un graphique",
"add_charts": "Ajouter des graphiques",
"add_existing_chart_description": "Recherchez et sélectionnez des graphiques à ajouter à ce tableau de bord.",
"add_filter": "Ajouter un filtre",
@@ -212,7 +213,6 @@
"delete_what": "Supprimer {deleteWhat}",
"description": "Description",
"disable": "Désactiver",
"disabled": "Désactivé",
"disallow": "Ne pas autoriser",
"discard": "Annuler",
"dismissed": "Rejeté",
@@ -392,6 +392,7 @@
"report_survey": "Rapport d'enquête",
"request_trial_license": "Demander une licence d'essai",
"reset_to_default": "Réinitialiser par défaut",
"resize": "Redimensionner",
"response": "Réponse",
"response_id": "ID de réponse",
"responses": "Réponses",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "Veuillez sélectionner un tableau de bord",
"predefined_measures": "Mesures prédéfinies",
"preset": "Préréglage",
"preview_chart": "Aperçu du graphique",
"query_executed_successfully": "Requête exécutée avec succès",
"reset_to_ai_suggestion": "Réinitialiser à la suggestion IA",
"save_and_add_to_dashboard": "Enregistrer et ajouter au tableau de bord",
"save_chart": "Enregistrer le graphique",
"save_chart_dialog_title": "Enregistrer le graphique",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "Date de début",
"time_dimension": "Dimension temporelle",
"time_dimension_title": "Ajouter un groupement temporel",
"time_dimension_toggle_description": "Surveille les tendances dans le temps."
"time_dimension_toggle_description": "Surveille les tendances dans le temps.",
"update_chart": "Mettre à jour le graphique"
},
"dashboards": {
"add_count_charts": "Ajouter {count} graphique(s)",
@@ -1809,6 +1813,7 @@
"create_dashboard": "Créer un tableau de bord",
"create_dashboard_description": "Saisissez un nom pour votre nouveau tableau de bord.",
"create_failed": "Échec de la création du tableau de bord",
"create_new_chart": "Créer un nouveau graphique",
"create_success": "Tableau de bord créé avec succès!",
"dashboard": "Tableau de bord",
"dashboard_delete_confirmation": "Es-tu sûr(e) de vouloir supprimer ce tableau de bord ? Cette action est irréversible.",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "Échec de la duplication du tableau de bord",
"duplicate_success": "Tableau de bord dupliqué avec succès!",
"failed_to_load_chart_data": "Échec du chargement des données du graphique",
"no_charts_available_description": "Il n'y a aucun graphique pouvant être ajouté à ce tableau de bord. Soit aucun graphique n'existe encore, soit tous les graphiques existants ont déjà été ajoutés. Rendez-vous sur la page Graphiques pour créer de nouveaux graphiques.",
"no_charts_to_add_message": "Aucun graphique à ajouter à ce tableau de bord.",
"no_dashboards_found": "Aucun tableau de bord trouvé.",
"no_data_message": "Aucune donnée. Il n'y a actuellement aucune information à afficher. Ajoute des graphiques pour construire ton tableau de bord.",
"please_enter_name": "Veuillez saisir un nom de tableau de bord"
}
},
"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.",
"setup_feedback_source": "Configurer les sources de commentaires"
},
"api_keys": {
"add_api_key": "Ajouter une clé API",
@@ -1861,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",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "Le type de source ne peut pas être modifié",
"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",
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "Tevékenység",
"add": "Hozzáadás",
"add_action": "Művelet hozzáadása",
"add_chart": "Diagram hozzáadása",
"add_charts": "Diagramok hozzáadása",
"add_existing_chart_description": "Keressen és válasszon diagramokat a műszerfalhoz való hozzáadáshoz.",
"add_filter": "Szűrő hozzáadása",
@@ -212,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",
@@ -392,6 +392,7 @@
"report_survey": "Kérdőív jelentése",
"request_trial_license": "Próbaidőszaki licenc kérése",
"reset_to_default": "Visszaállítás az alapértelmezettre",
"resize": "Átméretezés",
"response": "Válasz",
"response_id": "Válaszazonosító",
"responses": "Válaszok",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "Kérjük, válassz egy vezérlőpultot",
"predefined_measures": "Előre definiált mérőszámok",
"preset": "Előbeállítás",
"preview_chart": "Előnézet diagram",
"query_executed_successfully": "Lekérdezés sikeresen végrehajtva",
"reset_to_ai_suggestion": "Visszaállítás AI javaslatra",
"save_and_add_to_dashboard": "Mentés és hozzáadása az irányítópulthoz",
"save_chart": "Diagram mentése",
"save_chart_dialog_title": "Diagram mentése",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "Kezdési dátum",
"time_dimension": "Időbeli dimenzió",
"time_dimension_title": "Időalapú csoportosítás hozzáadása",
"time_dimension_toggle_description": "Trendek figyelemmel kísérése az idő függvényében."
"time_dimension_toggle_description": "Trendek figyelemmel kísérése az idő függvényében.",
"update_chart": "Frissítse a diagramot"
},
"dashboards": {
"add_count_charts": "{count} diagram hozzáadása",
@@ -1809,6 +1813,7 @@
"create_dashboard": "Műszerfal létrehozása",
"create_dashboard_description": "Adjon nevet az új vezérlőpultnak.",
"create_failed": "A vezérlőpult létrehozása sikertelen",
"create_new_chart": "Új diagram létrehozása",
"create_success": "A vezérlőpult sikeresen létrehozva!",
"dashboard": "Műszerfal",
"dashboard_delete_confirmation": "Biztos benne, hogy törölni kívánja ezt a műszerfalat? Ez a művelet nem vonható vissza.",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "A vezérlőpult másolása sikertelen",
"duplicate_success": "A vezérlőpult sikeresen lemásolva!",
"failed_to_load_chart_data": "A diagram adatainak betöltése sikertelen",
"no_charts_available_description": "Nincsenek diagramok, amelyek hozzáadhatók ehhez az irányítópulthoz. Vagy még nem léteznek diagramok, vagy az összes meglévő diagram már hozzá lett adva. Látogassa meg a Diagramok oldalt új diagramok létrehozásához.",
"no_charts_to_add_message": "Nincsenek hozzáadható diagramok ehhez az irányítópulthoz.",
"no_dashboards_found": "Nem található vezérlőpult.",
"no_data_message": "Nincsenek adatok. Jelenleg nincsenek megjeleníthető információk. Adjon hozzá diagramokat az irányítópult felépítéséhez.",
"please_enter_name": "Kérjük, adjon nevet a vezérlőpultnak"
}
},
"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.",
"setup_feedback_source": "Visszajelzési források beállítása"
},
"api_keys": {
"add_api_key": "API-kulcs hozzáadása",
@@ -1861,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",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "A forrástípus nem módosítható",
"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",
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "アクティビティ",
"add": "追加",
"add_action": "アクションを追加",
"add_chart": "チャートを追加",
"add_charts": "グラフを追加",
"add_existing_chart_description": "このダッシュボードに追加するグラフを検索して選択してください。",
"add_filter": "フィルターを追加",
@@ -212,7 +213,6 @@
"delete_what": "{deleteWhat}を削除",
"description": "説明",
"disable": "無効にする",
"disabled": "無効",
"disallow": "許可しない",
"discard": "破棄",
"dismissed": "非表示",
@@ -392,6 +392,7 @@
"report_survey": "フォームを報告",
"request_trial_license": "トライアルライセンスをリクエスト",
"reset_to_default": "デフォルトにリセット",
"resize": "サイズ変更",
"response": "回答",
"response_id": "回答ID",
"responses": "回答",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "ダッシュボードを選択してください",
"predefined_measures": "事前定義されたメジャー",
"preset": "プリセット",
"preview_chart": "グラフのプレビュー",
"query_executed_successfully": "クエリが正常に実行されました",
"reset_to_ai_suggestion": "AIの提案にリセット",
"save_and_add_to_dashboard": "保存してダッシュボードに追加",
"save_chart": "チャートを保存",
"save_chart_dialog_title": "チャートを保存",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "開始日",
"time_dimension": "時間ディメンション",
"time_dimension_title": "時間ベースのグループ化を追加",
"time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。"
"time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。",
"update_chart": "チャートを更新"
},
"dashboards": {
"add_count_charts": "{count}個のグラフを追加",
@@ -1809,6 +1813,7 @@
"create_dashboard": "ダッシュボードを作成",
"create_dashboard_description": "新しいダッシュボードの名前を入力してください。",
"create_failed": "ダッシュボードの作成に失敗しました",
"create_new_chart": "新しいチャートを作成する",
"create_success": "ダッシュボードを正常に作成しました!",
"dashboard": "ダッシュボード",
"dashboard_delete_confirmation": "このダッシュボードを削除してもよろしいですか?この操作は元に戻せません。",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "ダッシュボードの複製に失敗しました",
"duplicate_success": "ダッシュボードを正常に複製しました!",
"failed_to_load_chart_data": "チャートデータの読み込みに失敗しました",
"no_charts_available_description": "このダッシュボードに追加できるチャートがありません。チャートがまだ存在しないか、既存のチャートがすべて追加済みです。新しいチャートを作成するには、チャートページに移動してください。",
"no_charts_to_add_message": "このダッシュボードに追加するチャートがありません。",
"no_dashboards_found": "ダッシュボードが見つかりません。",
"no_data_message": "データがありません。現在表示する情報がありません。ダッシュボードを構築するにはチャートを追加してください。",
"please_enter_name": "ダッシュボード名を入力してください"
}
},
"no_feedback_records_message": "レポートするフィードバック レコードがありません。データをシステムにフィードするためのフィードバック ソースをセットアップします。",
"setup_feedback_source": "フィードバックソースのセットアップ"
},
"api_keys": {
"add_api_key": "APIキーを追加",
@@ -1861,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と接続してください",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "ソースタイプは変更できません",
"status_error": "エラー",
"status_live_sync": "リアルタイム同期",
"status_paused": "一時停止",
"status_ready": "準備完了",
"submission_id": "提出ID",
"survey_has_no_questions": "このアンケートには質問がありません",
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "Activiteit",
"add": "Toevoegen",
"add_action": "Actie toevoegen",
"add_chart": "Diagram toevoegen",
"add_charts": "Grafieken toevoegen",
"add_existing_chart_description": "Zoek en selecteer grafieken om toe te voegen aan dit dashboard.",
"add_filter": "Filter toevoegen",
@@ -212,7 +213,6 @@
"delete_what": "Verwijder {deleteWhat}",
"description": "Beschrijving",
"disable": "Uitzetten",
"disabled": "Uitgeschakeld",
"disallow": "Niet toestaan",
"discard": "Weggooien",
"dismissed": "Afgewezen",
@@ -392,6 +392,7 @@
"report_survey": "Enquête melden",
"request_trial_license": "Proeflicentie aanvragen",
"reset_to_default": "Resetten naar standaard",
"resize": "Formaat wijzigen",
"response": "Antwoord",
"response_id": "Antwoord-ID",
"responses": "Reacties",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "Selecteer een dashboard",
"predefined_measures": "Vooraf gedefinieerde metingen",
"preset": "Voorinstelling",
"preview_chart": "Voorbeeldgrafiek",
"query_executed_successfully": "Query succesvol uitgevoerd",
"reset_to_ai_suggestion": "Herstel naar AI-suggestie",
"save_and_add_to_dashboard": "Opslaan en toevoegen aan dashboard",
"save_chart": "Diagram opslaan",
"save_chart_dialog_title": "Diagram opslaan",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "Startdatum",
"time_dimension": "Tijdsdimensie",
"time_dimension_title": "Tijdgebaseerde groepering toevoegen",
"time_dimension_toggle_description": "Volg trends over tijd."
"time_dimension_toggle_description": "Volg trends over tijd.",
"update_chart": "Diagram bijwerken"
},
"dashboards": {
"add_count_charts": "{count} grafiek(en) toevoegen",
@@ -1809,6 +1813,7 @@
"create_dashboard": "Dashboard maken",
"create_dashboard_description": "Voer een naam in voor je nieuwe dashboard.",
"create_failed": "Dashboard creëren mislukt",
"create_new_chart": "Maak een nieuw diagram",
"create_success": "Dashboard succesvol aangemaakt!",
"dashboard": "Dashboard",
"dashboard_delete_confirmation": "Weet je zeker dat je dit dashboard wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "Dashboard dupliceren mislukt",
"duplicate_success": "Dashboard succesvol gedupliceerd!",
"failed_to_load_chart_data": "Grafiekgegevens laden mislukt",
"no_charts_available_description": "Er zijn geen grafieken die aan dit dashboard kunnen worden toegevoegd. Er bestaan nog geen grafieken, of alle bestaande grafieken zijn al toegevoegd. Ga naar de pagina Grafieken om nieuwe grafieken te maken.",
"no_charts_to_add_message": "Geen grafieken om toe te voegen aan dit dashboard.",
"no_dashboards_found": "Geen dashboards gevonden.",
"no_data_message": "Geen gegevens. Er is momenteel geen informatie om weer te geven. Voeg grafieken toe om je dashboard op te bouwen.",
"please_enter_name": "Voer een dashboardnaam in"
}
},
"no_feedback_records_message": "U heeft geen feedbackrecords om over te rapporteren. Stel feedbackbronnen in om gegevens in het systeem in te voeren.",
"setup_feedback_source": "Feedbackbronnen instellen"
},
"api_keys": {
"add_api_key": "API-sleutel toevoegen",
@@ -1861,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",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "Brontype kan niet worden gewijzigd",
"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",
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "Atividade",
"add": "Adicionar",
"add_action": "Adicionar ação",
"add_chart": "Adicionar gráfico",
"add_charts": "Adicionar gráficos",
"add_existing_chart_description": "Pesquise e selecione gráficos para adicionar a este painel.",
"add_filter": "Adicionar filtro",
@@ -212,7 +213,6 @@
"delete_what": "Excluir {deleteWhat}",
"description": "Descrição",
"disable": "desativar",
"disabled": "Desativado",
"disallow": "Não permita",
"discard": "Descartar",
"dismissed": "Dispensado",
@@ -392,6 +392,7 @@
"report_survey": "Relatório de Pesquisa",
"request_trial_license": "Pedir licença de teste",
"reset_to_default": "Restaurar para o padrão",
"resize": "Redimensionar",
"response": "Resposta",
"response_id": "ID da resposta",
"responses": "Respostas",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "Por favor, selecione um painel",
"predefined_measures": "Medidas predefinidas",
"preset": "Predefinição",
"preview_chart": "Visualizar gráfico",
"query_executed_successfully": "Consulta executada com sucesso",
"reset_to_ai_suggestion": "Redefinir para sugestão da IA",
"save_and_add_to_dashboard": "Salvar e adicionar ao painel",
"save_chart": "Salvar gráfico",
"save_chart_dialog_title": "Salvar gráfico",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "Data inicial",
"time_dimension": "Dimensão temporal",
"time_dimension_title": "Adicionar agrupamento por tempo",
"time_dimension_toggle_description": "Monitore tendências ao longo do tempo."
"time_dimension_toggle_description": "Monitore tendências ao longo do tempo.",
"update_chart": "Atualizar gráfico"
},
"dashboards": {
"add_count_charts": "Adicionar {count} gráfico(s)",
@@ -1809,6 +1813,7 @@
"create_dashboard": "Criar painel",
"create_dashboard_description": "Digite um nome para o seu novo painel.",
"create_failed": "Falha ao criar painel",
"create_new_chart": "Criar novo gráfico",
"create_success": "Painel criado com sucesso!",
"dashboard": "Painel",
"dashboard_delete_confirmation": "Tem certeza de que deseja excluir este painel? Esta ação não pode ser desfeita.",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "Falha ao duplicar painel",
"duplicate_success": "Painel duplicado com sucesso!",
"failed_to_load_chart_data": "Falha ao carregar os dados do gráfico",
"no_charts_available_description": "Não há gráficos que possam ser adicionados a este painel. Ou nenhum gráfico existe ainda, ou todos os gráficos existentes já foram adicionados. Vá para a página de Gráficos para criar novos gráficos.",
"no_charts_to_add_message": "Nenhum gráfico para adicionar a este painel.",
"no_dashboards_found": "Nenhum painel encontrado.",
"no_data_message": "Sem Dados. Não há informações para exibir no momento. Adicione gráficos para construir seu painel.",
"please_enter_name": "Por favor, digite um nome para o painel"
}
},
"no_feedback_records_message": "Você não tem registros de feedback para relatar. Configure fontes de feedback para alimentar dados no sistema.",
"setup_feedback_source": "Configurar fontes de feedback"
},
"api_keys": {
"add_api_key": "Adicionar chave de API",
@@ -1861,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",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "O tipo de origem não pode ser alterado",
"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",
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "Atividade",
"add": "Adicionar",
"add_action": "Adicionar ação",
"add_chart": "Adicionar gráfico",
"add_charts": "Adicionar gráficos",
"add_existing_chart_description": "Pesquisa e seleciona gráficos para adicionar a este painel.",
"add_filter": "Adicionar filtro",
@@ -212,7 +213,6 @@
"delete_what": "Eliminar {deleteWhat}",
"description": "Descrição",
"disable": "Desativar",
"disabled": "Desativado",
"disallow": "Não permitir",
"discard": "Descartar",
"dismissed": "Dispensado",
@@ -392,6 +392,7 @@
"report_survey": "Relatório de Inquérito",
"request_trial_license": "Solicitar licença de teste",
"reset_to_default": "Repor para o padrão",
"resize": "Redimensionar",
"response": "Resposta",
"response_id": "ID de resposta",
"responses": "Respostas",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "Por favor, seleciona um painel",
"predefined_measures": "Medidas predefinidas",
"preset": "Predefinição",
"preview_chart": "Visualizar gráfico",
"query_executed_successfully": "Consulta executada com sucesso",
"reset_to_ai_suggestion": "Repor sugestão da IA",
"save_and_add_to_dashboard": "Salvar e adicionar ao painel",
"save_chart": "Guardar gráfico",
"save_chart_dialog_title": "Guardar gráfico",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "Data de início",
"time_dimension": "Dimensão temporal",
"time_dimension_title": "Adicionar agrupamento temporal",
"time_dimension_toggle_description": "Monitoriza tendências ao longo do tempo."
"time_dimension_toggle_description": "Monitoriza tendências ao longo do tempo.",
"update_chart": "Atualizar gráfico"
},
"dashboards": {
"add_count_charts": "Adicionar {count} gráfico(s)",
@@ -1809,6 +1813,7 @@
"create_dashboard": "Criar painel",
"create_dashboard_description": "Introduza um nome para o seu novo painel.",
"create_failed": "Falha ao criar painel",
"create_new_chart": "Criar novo gráfico",
"create_success": "Painel criado com sucesso!",
"dashboard": "Painel",
"dashboard_delete_confirmation": "Tens a certeza de que queres eliminar este painel? Esta ação não pode ser revertida.",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "Falha ao duplicar painel",
"duplicate_success": "Painel duplicado com sucesso!",
"failed_to_load_chart_data": "Falha ao carregar os dados do gráfico",
"no_charts_available_description": "Não há gráficos que possam ser adicionados a este painel. Ou ainda não existem gráficos, ou todos os gráficos existentes já foram adicionados. Vai à página de Gráficos para criar novos gráficos.",
"no_charts_to_add_message": "Não há gráficos para adicionar a este painel.",
"no_dashboards_found": "Nenhum painel encontrado.",
"no_data_message": "Sem Dados. Atualmente não há informação para apresentar. Adiciona gráficos para construir o teu painel.",
"please_enter_name": "Por favor, introduza um nome para o painel"
}
},
"no_feedback_records_message": "Você não tem registros de feedback para relatar. Configure fontes de feedback para alimentar dados no sistema.",
"setup_feedback_source": "Configurar fontes de feedback"
},
"api_keys": {
"add_api_key": "Adicionar chave API",
@@ -1861,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",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "O tipo de fonte não pode ser alterado",
"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 -8
View File
@@ -125,6 +125,7 @@
"activity": "Activitate",
"add": "Adaugă",
"add_action": "Adăugați acțiune",
"add_chart": "Adăugați diagramă",
"add_charts": "Adaugă grafice",
"add_existing_chart_description": "Caută și selectează grafice pentru a le adăuga la acest panou de control.",
"add_filter": "Adăugați filtru",
@@ -212,7 +213,6 @@
"delete_what": "Șterge {deleteWhat}",
"description": "Descriere",
"disable": "Dezactivează",
"disabled": "Dezactivat",
"disallow": "Nu permite",
"discard": "Renunță",
"dismissed": "Respins",
@@ -392,6 +392,7 @@
"report_survey": "Raportează chestionarul",
"request_trial_license": "Solicitați o licență de încercare",
"reset_to_default": "Revino la implicit",
"resize": "Redimensionați",
"response": "Răspuns",
"response_id": "ID răspuns",
"responses": "Răspunsuri",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "Te rugăm să selectezi un tablou de bord",
"predefined_measures": "Măsurători predefinite",
"preset": "Presetare",
"preview_chart": "Previzualizare diagramă",
"query_executed_successfully": "Interogarea a fost executată cu succes",
"reset_to_ai_suggestion": "Resetează la sugestia AI",
"save_and_add_to_dashboard": "Salvați și adăugați în tabloul de bord",
"save_chart": "Salvează graficul",
"save_chart_dialog_title": "Salvează graficul",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "Data de început",
"time_dimension": "Dimensiune temporală",
"time_dimension_title": "Adaugă grupare pe bază de timp",
"time_dimension_toggle_description": "Monitorizează tendințele în timp."
"time_dimension_toggle_description": "Monitorizează tendințele în timp.",
"update_chart": "Actualizați diagrama"
},
"dashboards": {
"add_count_charts": "Adaugă {count} grafic(e)",
@@ -1809,6 +1813,7 @@
"create_dashboard": "Creează panou de control",
"create_dashboard_description": "Introdu un nume pentru noul tău tablou de bord.",
"create_failed": "Crearea tabloului de bord a eșuat",
"create_new_chart": "Creați o nouă diagramă",
"create_success": "Tablou de bord creat cu succes!",
"dashboard": "Panou de control",
"dashboard_delete_confirmation": "Ești sigur că vrei să ștergi acest panou de control? Această acțiune nu poate fi anulată.",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "Duplicarea tabloului de bord a eșuat",
"duplicate_success": "Tablou de bord duplicat cu succes!",
"failed_to_load_chart_data": "Încărcarea datelor graficului a eșuat",
"no_charts_available_description": "Nu există grafice care pot fi adăugate la acest tablou de bord. Fie nu există încă grafice, fie toate graficele existente au fost deja adăugate. Mergi la pagina Grafice pentru a crea grafice noi.",
"no_charts_to_add_message": "Nu există grafice de adăugat la acest tablou de bord.",
"no_dashboards_found": "Nu s-a găsit niciun tablou de bord.",
"no_data_message": "Fără date. În prezent nu există informații de afișat. Adaugă grafice pentru a-ți construi tabloul de bord.",
"please_enter_name": "Te rugăm să introduci un nume pentru tablou de bord"
}
},
"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.",
"setup_feedback_source": "Configurați sursele de feedback"
},
"api_keys": {
"add_api_key": "Adaugă cheie API",
@@ -1861,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",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "Tipul sursei nu poate fi schimbat",
"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",
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "Активность",
"add": "Добавить",
"add_action": "Добавить действие",
"add_chart": "Добавить диаграмму",
"add_charts": "Добавить графики",
"add_existing_chart_description": "Найдите и выберите графики для добавления на этот дашборд.",
"add_filter": "Добавить фильтр",
@@ -212,7 +213,6 @@
"delete_what": "Удалить {deleteWhat}",
"description": "Описание",
"disable": "Отключить",
"disabled": "Отключено",
"disallow": "Не разрешать",
"discard": "Отменить",
"dismissed": "Отклонено",
@@ -392,6 +392,7 @@
"report_survey": "Пожаловаться на опрос",
"request_trial_license": "Запросить пробную лицензию",
"reset_to_default": "Сбросить по умолчанию",
"resize": "Изменить размер",
"response": "Ответ",
"response_id": "ID ответа",
"responses": "Ответы",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "Пожалуйста, выбери панель управления",
"predefined_measures": "Предустановленные показатели",
"preset": "Пресет",
"preview_chart": "Предварительный просмотр диаграммы",
"query_executed_successfully": "Запрос успешно выполнен",
"reset_to_ai_suggestion": "Сбросить к предложению ИИ",
"save_and_add_to_dashboard": "Сохранить и добавить на панель управления",
"save_chart": "Сохранить график",
"save_chart_dialog_title": "Сохранить график",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "Дата начала",
"time_dimension": "Временное измерение",
"time_dimension_title": "Добавить группировку по времени",
"time_dimension_toggle_description": "Отслеживайте тренды с течением времени."
"time_dimension_toggle_description": "Отслеживайте тренды с течением времени.",
"update_chart": "Обновить диаграмму"
},
"dashboards": {
"add_count_charts": "Добавить {count} график(ов)",
@@ -1809,6 +1813,7 @@
"create_dashboard": "Создать дашборд",
"create_dashboard_description": "Введите название для новой панели управления.",
"create_failed": "Не удалось создать панель управления",
"create_new_chart": "Создать новую диаграмму",
"create_success": "Панель управления успешно создана!",
"dashboard": "Дашборд",
"dashboard_delete_confirmation": "Вы уверены, что хотите удалить этот дашборд? Это действие нельзя отменить.",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "Не удалось дублировать панель управления",
"duplicate_success": "Панель управления успешно продублирована!",
"failed_to_load_chart_data": "Не удалось загрузить данные графика",
"no_charts_available_description": "Нет графиков, которые можно добавить к этому дашборду. Либо графики ещё не созданы, либо все существующие графики уже добавлены. Перейдите на страницу «Графики», чтобы создать новые графики.",
"no_charts_to_add_message": "Нет графиков для добавления к этому дашборду.",
"no_dashboards_found": "Панели управления не найдены.",
"no_data_message": "Нет данных. В настоящее время нет информации для отображения. Добавьте графики, чтобы построить свой дашборд.",
"please_enter_name": "Пожалуйста, введите название панели управления"
}
},
"no_feedback_records_message": "У вас нет записей обратной связи, о которых можно сообщить. Настройте источники обратной связи для подачи данных в систему.",
"setup_feedback_source": "Настройка источников обратной связи"
},
"api_keys": {
"add_api_key": "Добавить API-ключ",
@@ -1861,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",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "Тип источника нельзя изменить",
"status_error": "Ошибка",
"status_live_sync": "Синхронизация в реальном времени",
"status_paused": "Приостановлен",
"status_ready": "Готово",
"submission_id": "Идентификатор отправки",
"survey_has_no_questions": "В этом опросе нет вопросов",
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "Aktivitet",
"add": "Lägg till",
"add_action": "Lägg till åtgärd",
"add_chart": "Lägg till diagram",
"add_charts": "Lägg till diagram",
"add_existing_chart_description": "Sök och välj diagram att lägga till i den här instrumentpanelen.",
"add_filter": "Lägg till filter",
@@ -212,7 +213,6 @@
"delete_what": "Ta bort {deleteWhat}",
"description": "Beskrivning",
"disable": "Inaktivera",
"disabled": "Inaktiverad",
"disallow": "Tillåt inte",
"discard": "Förkasta",
"dismissed": "Avvisad",
@@ -392,6 +392,7 @@
"report_survey": "Rapportera enkät",
"request_trial_license": "Begär provlicens",
"reset_to_default": "Återställ till standard",
"resize": "Ändra storlek",
"response": "Svar",
"response_id": "Svar-ID",
"responses": "Svar",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "Välj en instrumentpanel",
"predefined_measures": "Fördefinierade mått",
"preset": "Förinställning",
"preview_chart": "Förhandsgranska diagram",
"query_executed_successfully": "Frågan kördes utan problem",
"reset_to_ai_suggestion": "Återställ till AI-förslag",
"save_and_add_to_dashboard": "Spara och lägg till i instrumentpanelen",
"save_chart": "Spara diagram",
"save_chart_dialog_title": "Spara diagram",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "Startdatum",
"time_dimension": "Tidsdimension",
"time_dimension_title": "Lägg till tidsbaserad gruppering",
"time_dimension_toggle_description": "Övervaka trender över tid."
"time_dimension_toggle_description": "Övervaka trender över tid.",
"update_chart": "Uppdatera diagram"
},
"dashboards": {
"add_count_charts": "Lägg till {count} diagram",
@@ -1809,6 +1813,7 @@
"create_dashboard": "Skapa instrumentpanel",
"create_dashboard_description": "Ange ett namn för din nya instrumentpanel.",
"create_failed": "Det gick inte att skapa instrumentpanelen",
"create_new_chart": "Skapa nytt diagram",
"create_success": "Instrumentpanelen har skapats!",
"dashboard": "Instrumentpanel",
"dashboard_delete_confirmation": "Är du säker på att du vill ta bort den här instrumentpanelen? Den här åtgärden kan inte ångras.",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "Det gick inte att duplicera instrumentpanelen",
"duplicate_success": "Instrumentpanelen har duplicerats!",
"failed_to_load_chart_data": "Det gick inte att ladda diagramdata",
"no_charts_available_description": "Det finns inga diagram som kan läggas till på den här instrumentpanelen. Antingen finns inga diagram än, eller så har alla befintliga diagram redan lagts till. Gå till sidan Diagram för att skapa nya diagram.",
"no_charts_to_add_message": "Inga diagram att lägga till på den här instrumentpanelen.",
"no_dashboards_found": "Inga instrumentpaneler hittades.",
"no_data_message": "Ingen data. Det finns för närvarande ingen information att visa. Lägg till diagram för att bygga din instrumentpanel.",
"please_enter_name": "Ange ett namn på instrumentpanelen"
}
},
"no_feedback_records_message": "Du har inga feedbackposter att rapportera om. Ställ in återkopplingskällor för att mata in data i systemet.",
"setup_feedback_source": "Ställ in feedbackkällor"
},
"api_keys": {
"add_api_key": "Lägg till API-nyckel",
@@ -1861,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",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "Källtyp kan inte ändras",
"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",
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "Etkinlik",
"add": "Ekle",
"add_action": "Eylem ekle",
"add_chart": "Grafik ekle",
"add_charts": "Grafik ekle",
"add_existing_chart_description": "Bu panoya eklemek için grafikleri ara ve seç.",
"add_filter": "Filtre ekle",
@@ -212,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",
@@ -392,6 +392,7 @@
"report_survey": "Anketi Raporla",
"request_trial_license": "Deneme lisansı iste",
"reset_to_default": "Varsayılana sıfırla",
"resize": "Yeniden boyutlandır",
"response": "Yanıt",
"response_id": "Yanıt ID",
"responses": "Yanıtlar",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "Lütfen bir kontrol paneli seç",
"predefined_measures": "Önceden Tanımlanmış Ölçümler",
"preset": "Ön Ayar",
"preview_chart": "Grafiği önizleyin",
"query_executed_successfully": "Sorgu başarıyla çalıştırıldı",
"reset_to_ai_suggestion": "Yapay zeka önerisine sıfırla",
"save_and_add_to_dashboard": "Kaydet ve kontrol paneline ekle",
"save_chart": "Grafiği Kaydet",
"save_chart_dialog_title": "Grafiği Kaydet",
"select_data_source": "Bir veri kaynağı seç",
@@ -1798,7 +1801,8 @@
"start_date": "Başlangıç tarihi",
"time_dimension": "Zaman Boyutu",
"time_dimension_title": "Zaman tabanlı gruplama ekle",
"time_dimension_toggle_description": "Zaman içindeki eğilimleri izle."
"time_dimension_toggle_description": "Zaman içindeki eğilimleri izle.",
"update_chart": "Grafiği güncelle"
},
"dashboards": {
"add_count_charts": "{count} grafik ekle",
@@ -1809,6 +1813,7 @@
"create_dashboard": "Pano oluştur",
"create_dashboard_description": "Yeni panon için bir isim gir.",
"create_failed": "Pano oluşturulamadı",
"create_new_chart": "Yeni grafik oluştur",
"create_success": "Pano başarıyla oluşturuldu!",
"dashboard": "Pano",
"dashboard_delete_confirmation": "Bu gösterge panelini silmek istediğinden emin misin? Bu işlem geri alınamaz.",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "Gösterge paneli kopyalanamadı",
"duplicate_success": "Gösterge paneli başarıyla kopyalandı!",
"failed_to_load_chart_data": "Grafik verileri yüklenemedi",
"no_charts_available_description": "Bu gösterge paneline eklenebilecek grafik bulunmuyor. Ya henüz hiç grafik oluşturulmadı ya da mevcut tüm grafikler zaten eklendi. Yeni grafikler oluşturmak için Grafikler sayfasına git.",
"no_charts_to_add_message": "Bu gösterge paneline eklenecek grafik yok.",
"no_dashboards_found": "Gösterge paneli bulunamadı.",
"no_data_message": "Veri Yok. Şu anda görüntülenecek bilgi bulunmuyor. Gösterge panelini oluşturmak için grafik ekle.",
"please_enter_name": "Lütfen bir gösterge paneli adı gir"
}
},
"no_feedback_records_message": "Raporlayabileceğiniz Geri Bildirim Kayıtlarınız yok. Verileri sisteme beslemek için Geri Bildirim Kaynaklarını ayarlayın.",
"setup_feedback_source": "Geri bildirim kaynaklarını ayarlayın"
},
"api_keys": {
"add_api_key": "API Anahtarı Ekle",
@@ -1861,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",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "Kaynak türü değiştirilemez",
"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",
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "活动",
"add": "添加",
"add_action": "添加 操作",
"add_chart": "添加图表",
"add_charts": "添加图表",
"add_existing_chart_description": "搜索并选择要添加到此仪表板的图表。",
"add_filter": "添加 过滤器",
@@ -212,7 +213,6 @@
"delete_what": "删除{deleteWhat}",
"description": "描述",
"disable": "禁用",
"disabled": "已禁用",
"disallow": "不允许",
"discard": "丢弃",
"dismissed": "忽略",
@@ -392,6 +392,7 @@
"report_survey": "报告调查",
"request_trial_license": "申请试用许可证",
"reset_to_default": "重置为 默认",
"resize": "调整大小",
"response": "响应",
"response_id": "响应 ID",
"responses": "反馈",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "请选择一个 Dashboard",
"predefined_measures": "预设度量",
"preset": "预设",
"preview_chart": "预览图表",
"query_executed_successfully": "查询执行成功",
"reset_to_ai_suggestion": "重置为 AI 建议",
"save_and_add_to_dashboard": "保存并添加到仪表板",
"save_chart": "保存图表",
"save_chart_dialog_title": "保存图表",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "开始日期",
"time_dimension": "时间维度",
"time_dimension_title": "添加基于时间的分组",
"time_dimension_toggle_description": "监控随时间变化的趋势。"
"time_dimension_toggle_description": "监控随时间变化的趋势。",
"update_chart": "更新图表"
},
"dashboards": {
"add_count_charts": "添加 {count} 个图表",
@@ -1809,6 +1813,7 @@
"create_dashboard": "创建仪表板",
"create_dashboard_description": "请输入新 Dashboard 的名称。",
"create_failed": "创建 Dashboard 失败",
"create_new_chart": "创建新图表",
"create_success": "Dashboard 创建成功!",
"dashboard": "仪表板",
"dashboard_delete_confirmation": "你确定要删除此仪表板吗?此操作无法撤销。",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "复制 Dashboard 失败",
"duplicate_success": "Dashboard 复制成功!",
"failed_to_load_chart_data": "加载图表数据失败",
"no_charts_available_description": "没有可以添加到此仪表板的图表。要么还没有创建任何图表,要么所有现有图表都已添加。请前往图表页面创建新图表。",
"no_charts_to_add_message": "没有可添加到此仪表板的图表。",
"no_dashboards_found": "未找到 Dashboard。",
"no_data_message": "暂无数据。当前没有可显示的信息。请添加图表来构建你的仪表板。",
"please_enter_name": "请输入 Dashboard 名称"
}
},
"no_feedback_records_message": "您没有可供报告的反馈记录。设置反馈源以将数据输入系统。",
"setup_feedback_source": "设置反馈源"
},
"api_keys": {
"add_api_key": "添加 API 密钥",
@@ -1861,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 的连接。",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "来源类型无法更改",
"status_error": "错误",
"status_live_sync": "Live sync",
"status_paused": "已暂停",
"status_ready": "Ready",
"submission_id": "提交ID",
"survey_has_no_questions": "该调查没有任何问题",
+11 -8
View File
@@ -125,6 +125,7 @@
"activity": "活動",
"add": "新增",
"add_action": "新增操作",
"add_chart": "新增圖表",
"add_charts": "新增圖表",
"add_existing_chart_description": "搜尋並選擇要新增至此儀表板的圖表。",
"add_filter": "新增篩選器",
@@ -212,7 +213,6 @@
"delete_what": "刪除{deleteWhat}",
"description": "描述",
"disable": "停用",
"disabled": "已停用",
"disallow": "不允許",
"discard": "捨棄",
"dismissed": "已關閉",
@@ -392,6 +392,7 @@
"report_survey": "報告問卷",
"request_trial_license": "請求試用授權",
"reset_to_default": "重設為預設值",
"resize": "調整大小",
"response": "回應",
"response_id": "回應 ID",
"responses": "回應",
@@ -1784,8 +1785,10 @@
"please_select_dashboard": "請選擇一個儀表板",
"predefined_measures": "預設指標",
"preset": "預設",
"preview_chart": "預覽圖表",
"query_executed_successfully": "查詢執行成功",
"reset_to_ai_suggestion": "重設為 AI 建議",
"save_and_add_to_dashboard": "儲存並新增到儀表板",
"save_chart": "儲存圖表",
"save_chart_dialog_title": "儲存圖表",
"select_data_source": "Select a data source",
@@ -1798,7 +1801,8 @@
"start_date": "開始日期",
"time_dimension": "時間維度",
"time_dimension_title": "新增基於時間的分組",
"time_dimension_toggle_description": "監控隨時間變化的趨勢。"
"time_dimension_toggle_description": "監控隨時間變化的趨勢。",
"update_chart": "更新圖表"
},
"dashboards": {
"add_count_charts": "新增 {count} 個圖表",
@@ -1809,6 +1813,7 @@
"create_dashboard": "建立儀表板",
"create_dashboard_description": "請輸入新儀表板的名稱。",
"create_failed": "建立儀表板失敗",
"create_new_chart": "建立新圖表",
"create_success": "儀表板建立成功!",
"dashboard": "儀表板",
"dashboard_delete_confirmation": "確定要刪除此儀表板嗎?此操作無法復原。",
@@ -1823,12 +1828,12 @@
"duplicate_failed": "複製儀表板失敗",
"duplicate_success": "儀表板複製成功!",
"failed_to_load_chart_data": "載入圖表資料失敗",
"no_charts_available_description": "目前沒有可以新增到此儀表板的圖表。可能是尚未建立任何圖表,或所有現有圖表都已新增。請前往圖表頁面建立新的圖表。",
"no_charts_to_add_message": "沒有可新增到此儀表板的圖表。",
"no_dashboards_found": "找不到儀表板。",
"no_data_message": "無資料。目前沒有可顯示的資訊。請新增圖表來建立你的儀表板。",
"please_enter_name": "請輸入儀表板名稱"
}
},
"no_feedback_records_message": "您沒有可供報告的回饋記錄。設定回饋來源以將資料輸入系統。",
"setup_feedback_source": "設定反饋源"
},
"api_keys": {
"add_api_key": "新增 API 金鑰",
@@ -1861,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",
@@ -3747,6 +3749,7 @@
"source_type_cannot_be_changed": "來源類型無法變更",
"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;
@@ -31,6 +31,7 @@ interface AddToDashboardDialogProps {
onDashboardSelect: (id: string) => void;
onConfirm: () => void;
isSaving: boolean;
showChartNameField?: boolean;
}
export function AddToDashboardDialog({
@@ -43,6 +44,7 @@ export function AddToDashboardDialog({
onDashboardSelect,
onConfirm,
isSaving,
showChartNameField = true,
}: Readonly<AddToDashboardDialogProps>) {
const { t } = useTranslation();
@@ -57,17 +59,19 @@ export function AddToDashboardDialog({
</DialogHeader>
<DialogBody>
<div className="space-y-4">
<div>
<Label htmlFor="chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
<Input
id="chart-name"
className="mt-2"
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
maxLength={255}
/>
</div>
{showChartNameField && (
<div>
<Label htmlFor="chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
<Input
id="chart-name"
className="mt-2"
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
maxLength={255}
/>
</div>
)}
<div>
<Label htmlFor="dashboard-select">{t("workspace.analysis.charts.dashboard")}</Label>
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
@@ -103,7 +107,10 @@ export function AddToDashboardDialog({
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
{t("common.cancel")}
</Button>
<Button onClick={onConfirm} loading={isSaving} disabled={!selectedDashboardId || !chartName.trim()}>
<Button
onClick={onConfirm}
loading={isSaving}
disabled={!selectedDashboardId || (showChartNameField && !chartName.trim())}>
{t("workspace.analysis.charts.add_to_dashboard")}
</Button>
</DialogFooter>
@@ -31,6 +31,7 @@ interface AdvancedChartBuilderProps {
hidePreview?: boolean;
onChartGenerated?: (data: AnalyticsResponse) => void;
feedbackRecordDirectoryId: string | null;
runQueryCtaLabel?: string;
}
const ACTION = {
@@ -84,6 +85,7 @@ export function AdvancedChartBuilder({
hidePreview = false,
onChartGenerated,
feedbackRecordDirectoryId,
runQueryCtaLabel,
}: Readonly<AdvancedChartBuilderProps>) {
const { t } = useTranslation();
const parsedInitial = initialQuery ? parseQueryToState(initialQuery) : null;
@@ -151,11 +153,7 @@ export function AdvancedChartBuilder({
return (
<div className={hidePreview ? "space-y-2" : "grid gap-4 lg:grid-cols-2"}>
<div className="mx-1 space-y-2">
{!hidePreview && (
<>
<ChartTypeSelector selectedChartType={chartType} onChartTypeSelect={() => {}} />
</>
)}
{!hidePreview && <ChartTypeSelector selectedChartType={chartType} onChartTypeSelect={() => {}} />}
<div className="mt-4 flex w-full flex-col gap-3 overflow-hidden rounded-lg border bg-slate-50 p-4">
<MeasuresPanel
@@ -249,7 +247,11 @@ export function AdvancedChartBuilder({
<div className="flex justify-end">
<Button onClick={handleRunQuery} disabled={isLoading || !hasConfigChanged}>
{isLoading ? <LoadingSpinner /> : t("workspace.analysis.charts.create_chart")}
{isLoading ? (
<LoadingSpinner />
) : (
(runQueryCtaLabel ?? t("workspace.analysis.charts.create_chart"))
)}
</Button>
</div>
</div>
@@ -7,25 +7,31 @@ import { DialogFooter } from "@/modules/ui/components/dialog";
interface ChartDialogFooterProps {
onSaveClick: () => void;
onAddToDashboardClick: () => void;
onAddToDashboardClick?: () => void;
isSaving: boolean;
saveLabel?: string;
showAddToDashboard?: boolean;
}
export function ChartDialogFooter({
onSaveClick,
onAddToDashboardClick,
isSaving,
saveLabel,
showAddToDashboard = true,
}: Readonly<ChartDialogFooterProps>) {
const { t } = useTranslation();
return (
<DialogFooter>
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.charts.add_to_dashboard")}
</Button>
{showAddToDashboard && onAddToDashboardClick && (
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.charts.add_to_dashboard")}
</Button>
)}
<Button onClick={onSaveClick} disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.charts.save_chart")}
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
</Button>
</DialogFooter>
);
@@ -1,12 +1,14 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import { CopyIcon, MoreVertical, PlusIcon, SquarePenIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteChartAction, duplicateChartAction } from "@/modules/ee/analysis/charts/actions";
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -31,6 +33,36 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
const [isDeleting, setIsDeleting] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [isAddingToDashboard, setIsAddingToDashboard] = useState(false);
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>();
useEffect(() => {
let cancelled = false;
if (!isAddToDashboardDialogOpen) {
return () => {
cancelled = true;
};
}
void getDashboardsAction({ workspaceId }).then((result) => {
if (cancelled) {
return;
}
if (result?.data) {
setDashboards(result.data.map((dashboard) => ({ id: dashboard.id, name: dashboard.name })));
} else {
toast.error(getFormattedErrorMessage(result));
}
});
return () => {
cancelled = true;
};
}, [isAddToDashboardDialogOpen, workspaceId]);
const handleDeleteChart = async () => {
setIsDeleting(true);
@@ -70,6 +102,37 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
}
};
const handleAddChartToDashboard = async () => {
if (!selectedDashboardId) {
toast.error(t("workspace.analysis.charts.please_select_dashboard"));
return;
}
setIsAddingToDashboard(true);
try {
const result = await addChartToDashboardAction({
workspaceId,
chartId: chart.id,
dashboardId: selectedDashboardId,
});
if (!result?.data) {
toast.error(
getFormattedErrorMessage(result) || t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
);
return;
}
toast.success(t("workspace.analysis.charts.chart_added_to_dashboard"));
setIsAddToDashboardDialogOpen(false);
setSelectedDashboardId(undefined);
router.refresh();
} finally {
setIsAddingToDashboard(false);
}
};
return (
<div id={`chart-${chart.id}-actions`} data-testid="chart-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
@@ -102,6 +165,15 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
{t("common.duplicate")}
</DropdownMenuItem>
<DropdownMenuItem
icon={<PlusIcon className="size-4" />}
onClick={() => {
setIsDropDownOpen(false);
setIsAddToDashboardDialogOpen(true);
}}>
{t("workspace.analysis.charts.add_to_dashboard")}
</DropdownMenuItem>
<DropdownMenuItem
icon={<TrashIcon className="size-4" />}
onClick={() => {
@@ -123,6 +195,23 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
text={t("workspace.analysis.charts.delete_chart_confirmation")}
isDeleting={isDeleting}
/>
<AddToDashboardDialog
isOpen={isAddToDashboardDialogOpen}
onOpenChange={(open) => {
setIsAddToDashboardDialogOpen(open);
if (!open) {
setSelectedDashboardId(undefined);
}
}}
chartName={chart.name}
onChartNameChange={() => {}}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onConfirm={handleAddChartToDashboard}
isSaving={isAddingToDashboard}
showChartNameField={false}
/>
</div>
);
}
@@ -4,6 +4,8 @@ import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list"
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
import { hasFeedbackRecordsInDirectories } from "@/modules/ee/analysis/lib/feedback-records";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
@@ -35,22 +37,35 @@ interface ChartsListPageProps {
export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPageProps>) {
const t = await getTranslate();
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
const chartsPromise = getChartsWithCreator(workspaceId);
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
directories.map((directory) => directory.id)
);
const chartsPromise = hasFeedbackRecords ? getChartsWithCreator(workspaceId) : null;
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
workspaceId={workspaceId}
cta={
isReadOnly ? undefined : <CreateChartButton workspaceId={workspaceId} directories={directories} />
isReadOnly ? undefined : (
<CreateChartButton
workspaceId={workspaceId}
directories={directories}
buttonProps={{ disabled: !hasFeedbackRecords }}
/>
)
}>
<ChartsListContent
chartsPromise={chartsPromise}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
directories={directories}
/>
{hasFeedbackRecords && chartsPromise ? (
<ChartsListContent
chartsPromise={chartsPromise}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
directories={directories}
/>
) : (
<NoFeedbackRecordsState workspaceId={workspaceId} />
)}
</AnalysisPageLayout>
);
}
@@ -4,28 +4,43 @@ import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import { Button } from "@/modules/ui/components/button";
import { Button, type ButtonProps } from "@/modules/ui/components/button";
interface CreateChartButtonProps {
workspaceId: string;
directories: { id: string; name: string }[];
autoAddToDashboardId?: string;
label?: string;
onSuccess?: () => void;
showIcon?: boolean;
buttonProps?: Omit<ButtonProps, "onClick" | "children">;
}
export function CreateChartButton({ workspaceId, directories }: Readonly<CreateChartButtonProps>) {
export function CreateChartButton({
workspaceId,
directories,
autoAddToDashboardId,
label,
onSuccess,
showIcon = true,
buttonProps,
}: Readonly<CreateChartButtonProps>) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { t } = useTranslation();
return (
<>
<Button size="sm" onClick={() => setIsDialogOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.charts.create_chart")}
<Button size="sm" onClick={() => setIsDialogOpen(true)} {...buttonProps}>
{showIcon && <PlusIcon className="mr-2 h-4 w-4" />}
{label ?? t("workspace.analysis.charts.create_chart")}
</Button>
<CreateChartDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
workspaceId={workspaceId}
autoAddToDashboardId={autoAddToDashboardId}
directories={directories}
onSuccess={onSuccess}
/>
</>
);
@@ -1,7 +1,6 @@
"use client";
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
import { EditChartView } from "@/modules/ee/analysis/charts/components/edit-chart-view";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
export interface CreateChartDialogProps {
@@ -9,6 +8,7 @@ export interface CreateChartDialogProps {
onOpenChange: (open: boolean) => void;
workspaceId: string;
chartId?: string;
autoAddToDashboardId?: string;
initialChart?: TChartWithCreator;
onSuccess?: () => void;
directories: { id: string; name: string }[];
@@ -19,29 +19,19 @@ export function CreateChartDialog({
onOpenChange,
workspaceId,
chartId,
autoAddToDashboardId,
initialChart,
onSuccess,
directories,
}: Readonly<CreateChartDialogProps>) {
if (chartId) {
return (
<EditChartView
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
chartId={chartId}
initialChart={initialChart}
onSuccess={onSuccess}
directories={directories}
/>
);
}
return (
<CreateChartView
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
chartId={chartId}
initialChart={initialChart}
autoAddToDashboardId={autoAddToDashboardId}
onSuccess={onSuccess}
directories={directories}
/>
@@ -2,15 +2,17 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { SaveChartDialog } from "@/modules/ee/analysis/charts/components/save-chart-dialog";
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
import { FrdPicker } from "@/modules/ee/feedback-record-directory/components/frd-picker";
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Alert } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
@@ -19,11 +21,16 @@ import {
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
interface CreateChartViewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string;
chartId?: string;
initialChart?: TChartWithCreator;
autoAddToDashboardId?: string;
onSuccess?: () => void;
directories: { id: string; name: string }[];
}
@@ -32,32 +39,39 @@ export function CreateChartView({
open,
onOpenChange,
workspaceId,
chartId,
initialChart,
autoAddToDashboardId,
onSuccess,
directories,
}: Readonly<CreateChartViewProps>) {
const { t } = useTranslation();
const isEditing = !!chartId;
const {
chartData,
initialQuery,
isLoadingChart,
chartLoadError,
chartName,
setChartName,
selectedChartType,
handleChartTypeChange,
handleChartGenerated,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
handleAddToDashboard,
handleSaveChart,
isSaving,
isSaveDialogOpen,
setIsSaveDialogOpen,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
selectedDirectoryId,
setSelectedDirectoryId,
handleClose,
} = useChartDialog({ open, onOpenChange, workspaceId, onSuccess, directories });
} = useChartDialog({
open,
onOpenChange,
workspaceId,
chartId,
initialChart,
autoAddToDashboardId,
onSuccess,
directories,
});
const chartPreviewRef = useRef<HTMLDivElement>(null);
@@ -67,96 +81,139 @@ export function CreateChartView({
}
}, [chartData]);
if (isLoadingChart && isEditing && !initialChart) {
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
}
if (isEditing && !isLoadingChart && !chartData && !initialChart && chartLoadError) {
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent width="wide">
<DialogHeader>
<DialogTitle>{t("common.error")}</DialogTitle>
<DialogDescription />
</DialogHeader>
<DialogBody>
<div className="flex flex-col items-center justify-center gap-4 py-8">
<p className="text-sm text-red-600">{chartLoadError}</p>
<Button variant="outline" onClick={handleClose}>
{t("common.close")}
</Button>
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
}
const chartType = selectedChartType ?? (isEditing ? DEFAULT_CHART_TYPE : undefined);
const hasSelectedDirectory = !!selectedDirectoryId;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide" disableCloseOnOutsideClick>
<DialogContent
className="max-h-[90vh] overflow-y-auto"
width="wide"
disableCloseOnOutsideClick={!isEditing}>
<DialogHeader>
<DialogTitle>{t("workspace.analysis.charts.create_chart")}</DialogTitle>
<DialogDescription>{t("workspace.analysis.charts.create_chart_description")}</DialogDescription>
<DialogTitle>
{isEditing
? t("workspace.analysis.charts.edit_chart_title")
: t("workspace.analysis.charts.create_chart")}
</DialogTitle>
<DialogDescription>
{isEditing
? t("workspace.analysis.charts.edit_chart_description")
: t("workspace.analysis.charts.create_chart_description")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4">
<FrdPicker
directories={directories}
selectedDirectoryId={selectedDirectoryId}
onChange={setSelectedDirectoryId}
workspaceId={workspaceId}
/>
{hasSelectedDirectory && (
{hasSelectedDirectory ? (
<>
<AIQuerySection
workspaceId={workspaceId}
onChartGenerated={handleChartGenerated}
feedbackRecordDirectoryId={selectedDirectoryId}
/>
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-2 text-sm text-gray-500">
{t("workspace.analysis.charts.OR")}
</span>
</div>
<div className="space-y-2">
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
<Input
id="create-chart-name"
value={chartName}
onChange={(event) => setChartName(event.target.value)}
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
maxLength={255}
required
/>
</div>
<ManualChartBuilder
selectedChartType={selectedChartType}
onChartTypeSelect={handleChartTypeChange}
/>
{!isEditing && (
<>
<AIQuerySection
workspaceId={workspaceId}
onChartGenerated={handleChartGenerated}
feedbackRecordDirectoryId={selectedDirectoryId}
/>
{selectedChartType && (
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-2 text-sm text-gray-500">
{t("workspace.analysis.charts.OR")}
</span>
</div>
</div>
</>
)}
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
{chartType && (
<AdvancedChartBuilder
workspaceId={workspaceId}
chartType={selectedChartType}
initialQuery={chartData?.query}
chartType={chartType}
initialQuery={chartData?.query ?? initialQuery}
hidePreview={true}
onChartGenerated={handleChartGenerated}
feedbackRecordDirectoryId={selectedDirectoryId}
runQueryCtaLabel={
chartData
? t("workspace.analysis.charts.update_chart")
: t("workspace.analysis.charts.preview_chart")
}
/>
)}
{chartData && (
{(isEditing || chartData) && (
<div ref={chartPreviewRef}>
<ChartPreview chartData={chartData} />
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
</div>
)}
</>
) : (
<Alert variant="error" size="small">
<div>
<p>{t("workspace.analysis.charts.no_data_source_available")}</p>
<a
className="mt-1 inline-block font-medium underline"
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
{t("workspace.analysis.charts.go_to_feedback_record_directories")}
</a>
</div>
</Alert>
)}
</div>
</DialogBody>
{chartData && (
<>
<ChartDialogFooter
onSaveClick={() => setIsSaveDialogOpen(true)}
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
isSaving={isSaving}
/>
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
isOpen={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onConfirm={handleAddToDashboard}
isSaving={isSaving}
/>
</>
<ChartDialogFooter
onSaveClick={handleSaveChart}
isSaving={isSaving}
showAddToDashboard={false}
saveLabel={
autoAddToDashboardId
? t("workspace.analysis.charts.save_and_add_to_dashboard")
: t("workspace.analysis.charts.save_chart")
}
/>
)}
</DialogContent>
</Dialog>
@@ -1,158 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
interface EditChartViewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string;
chartId: string;
initialChart?: TChartWithCreator;
onSuccess?: () => void;
directories: { id: string; name: string }[];
}
export function EditChartView({
open,
onOpenChange,
workspaceId,
chartId,
initialChart,
onSuccess,
directories,
}: Readonly<EditChartViewProps>) {
const { t } = useTranslation();
const {
chartData,
initialQuery,
isLoadingChart,
chartLoadError,
chartName,
setChartName,
selectedChartType,
handleChartTypeChange,
handleChartGenerated,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
handleAddToDashboard,
handleSaveChart,
isSaving,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
selectedDirectoryId,
handleClose,
} = useChartDialog({ open, onOpenChange, workspaceId, chartId, initialChart, onSuccess, directories });
if (isLoadingChart && !initialChart) {
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
}
if (!isLoadingChart && !chartData && !initialChart && chartLoadError) {
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent width="wide">
<DialogHeader>
<DialogTitle>{t("common.error")}</DialogTitle>
<DialogDescription />
</DialogHeader>
<DialogBody>
<div className="flex flex-col items-center justify-center gap-4 py-8">
<p className="text-sm text-red-600">{chartLoadError}</p>
<Button variant="outline" onClick={handleClose}>
{t("common.close")}
</Button>
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
}
const chartType = selectedChartType ?? DEFAULT_CHART_TYPE;
const directoryName = directories.find((d) => d.id === selectedDirectoryId)?.name;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
<DialogHeader>
<DialogTitle>{t("workspace.analysis.charts.edit_chart_title")}</DialogTitle>
<DialogDescription>{t("workspace.analysis.charts.edit_chart_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4 px-1">
<div className="space-y-2">
<label htmlFor="edit-chart-name" className="text-sm">
{t("workspace.analysis.charts.chart_name")}
</label>
<Input
id="edit-chart-name"
value={chartName}
onChange={(e) => setChartName(e.target.value)}
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
className="w-full"
/>
</div>
{directoryName && (
<div className="space-y-2">
<Label>{t("workspace.analysis.charts.data_source")}</Label>
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
{directoryName}
</div>
</div>
)}
<div className="space-y-2">
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
</div>
<AdvancedChartBuilder
workspaceId={workspaceId}
chartType={chartType}
initialQuery={chartData?.query ?? initialQuery}
hidePreview={true}
onChartGenerated={handleChartGenerated}
feedbackRecordDirectoryId={selectedDirectoryId}
/>
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
</div>
</DialogBody>
<ChartDialogFooter
onSaveClick={handleSaveChart}
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
isSaving={isSaving}
/>
<AddToDashboardDialog
isOpen={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onConfirm={handleAddToDashboard}
isSaving={isSaving}
/>
</DialogContent>
</Dialog>
);
}
@@ -26,6 +26,7 @@ export interface UseChartDialogProps {
onOpenChange: (open: boolean) => void;
workspaceId: string;
chartId?: string;
autoAddToDashboardId?: string;
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
initialChart?: TChartWithCreator;
onSuccess?: () => void;
@@ -37,6 +38,7 @@ export function useChartDialog({
onOpenChange,
workspaceId,
chartId,
autoAddToDashboardId,
initialChart,
onSuccess,
directories,
@@ -45,7 +47,6 @@ export function useChartDialog({
const router = useRouter();
const [selectedChartType, setSelectedChartType] = useState<TChartType | undefined>();
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
@@ -54,9 +55,7 @@ export function useChartDialog({
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(
directories?.length === 1 ? directories[0].id : null
);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories?.[0]?.id ?? null);
useEffect(() => {
let cancelled = false;
@@ -85,7 +84,7 @@ export function useChartDialog({
setChartName("");
setSelectedChartType(undefined);
setCurrentChartId(undefined);
setSelectedDirectoryId(directories?.length === 1 ? directories[0].id : null);
setSelectedDirectoryId(directories?.[0]?.id ?? null);
return;
}
@@ -159,11 +158,6 @@ export function useChartDialog({
const handleChartGenerated = (data: AnalyticsResponse) => {
setChartData(data);
if (!currentChartId) {
setChartName(
data.chartType ? `${t("workspace.analysis.charts.chart")} ${new Date().toLocaleString()}` : ""
);
}
setSelectedChartType(data.chartType);
};
@@ -180,6 +174,8 @@ export function useChartDialog({
setIsSaving(true);
try {
let savedChartId = currentChartId;
if (currentChartId) {
const result = await updateChartAction({
workspaceId,
@@ -218,11 +214,32 @@ export function useChartDialog({
}
setCurrentChartId(result.data.id);
savedChartId = result.data.id;
toast.success(t("workspace.analysis.charts.chart_saved_successfully"));
}
setIsSaveDialogOpen(false);
if (autoAddToDashboardId && savedChartId) {
const addResult = await addChartToDashboardAction({
workspaceId,
chartId: savedChartId,
dashboardId: autoAddToDashboardId,
});
if (!addResult?.data) {
toast.error(
getFormattedErrorMessage(addResult) ||
t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
);
return;
}
toast.success(t("workspace.analysis.charts.chart_added_to_dashboard"));
}
onOpenChange(false);
if (autoAddToDashboardId) {
router.push(`/workspaces/${workspaceId}/dashboards/${autoAddToDashboardId}`);
}
router.refresh();
onSuccess?.();
} catch (error: unknown) {
@@ -328,7 +345,7 @@ export function useChartDialog({
setSelectedChartType(undefined);
setCurrentChartId(undefined);
setChartLoadError(null);
setSelectedDirectoryId(directories?.length === 1 ? directories[0].id : null);
setSelectedDirectoryId(directories?.[0]?.id ?? null);
onOpenChange(false);
}
};
@@ -349,8 +366,6 @@ export function useChartDialog({
setSelectedChartType,
currentChartId,
setCurrentChartId,
isSaveDialogOpen,
setIsSaveDialogOpen,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
dashboards,
@@ -0,0 +1,28 @@
import { MessageSquareDashedIcon } from "lucide-react";
import Link from "next/link";
import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button";
interface NoFeedbackRecordsStateProps {
workspaceId: string;
}
export const NoFeedbackRecordsState = async ({ workspaceId }: Readonly<NoFeedbackRecordsStateProps>) => {
const t = await getTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 text-center">
<MessageSquareDashedIcon className="h-8 w-8 text-slate-400" />
<p className="text-balance text-sm text-slate-600">
{t("workspace.analysis.no_feedback_records_message")}
</p>
<Button asChild size="sm">
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
{t("workspace.analysis.setup_feedback_source")}
</Link>
</Button>
</div>
</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;
@@ -1,13 +1,14 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getChartsAction } from "@/modules/ee/analysis/charts/actions";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import { addChartToDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -18,6 +19,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Label } from "@/modules/ui/components/label";
import { MultiSelect } from "@/modules/ui/components/multi-select";
interface AddExistingChartsDialogProps {
@@ -25,6 +27,7 @@ interface AddExistingChartsDialogProps {
onOpenChange: (open: boolean) => void;
workspaceId: string;
dashboardId: string;
directories: { id: string; name: string }[];
existingChartIds: string[];
onSuccess: () => void;
}
@@ -39,39 +42,40 @@ export function AddExistingChartsDialog({
onOpenChange,
workspaceId,
dashboardId,
directories,
existingChartIds,
onSuccess,
}: Readonly<AddExistingChartsDialogProps>) {
const { t } = useTranslation();
const router = useRouter();
const [chartOptions, setChartOptions] = useState<ChartOption[]>([]);
const [selectedChartIds, setSelectedChartIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const loadCharts = useCallback(async () => {
setIsLoading(true);
setSelectedChartIds([]);
try {
const result = await getChartsAction({ workspaceId });
if (result?.data) {
const availableCharts = result.data.filter((chart) => !existingChartIds.includes(chart.id));
setChartOptions(availableCharts.map((chart) => ({ value: chart.id, label: chart.name })));
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch {
toast.error(t("workspace.analysis.dashboards.charts_load_failed"));
} finally {
setIsLoading(false);
}
}, [workspaceId, existingChartIds, t]);
useEffect(() => {
if (!open) return;
const loadCharts = async () => {
setIsLoading(true);
setSelectedChartIds([]);
try {
const result = await getChartsAction({ workspaceId });
if (result?.data) {
const availableCharts = result.data.filter((chart) => !existingChartIds.includes(chart.id));
setChartOptions(availableCharts.map((chart) => ({ value: chart.id, label: chart.name })));
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch {
toast.error(t("workspace.analysis.dashboards.charts_load_failed"));
} finally {
setIsLoading(false);
}
};
loadCharts();
}, [open, workspaceId, existingChartIds, t]);
}, [open, loadCharts]);
const handleAdd = async () => {
if (selectedChartIds.length === 0) return;
@@ -127,15 +131,8 @@ export function AddExistingChartsDialog({
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
</div>
) : (
<>
{chartOptions.length === 0 && (
<Alert variant="info" className="mb-4">
<AlertTitle>{t("workspace.analysis.dashboards.no_charts_to_add_message")}</AlertTitle>
<AlertDescription>
{t("workspace.analysis.dashboards.no_charts_available_description")}
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label>{t("common.add_chart")}</Label>
<MultiSelect
options={chartOptions}
value={selectedChartIds}
@@ -143,18 +140,35 @@ export function AddExistingChartsDialog({
placeholder={t("common.search_charts")}
disabled={chartOptions.length === 0}
/>
</>
</div>
)}
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
{t("common.cancel")}
</Button>
<Button onClick={handleAdd} loading={isAdding} disabled={selectedChartIds.length === 0 || isAdding}>
{selectedChartIds.length > 0
? t("workspace.analysis.dashboards.add_count_charts", { count: selectedChartIds.length })
: t("common.add")}
</Button>
<DialogFooter className="sm:justify-between">
<CreateChartButton
workspaceId={workspaceId}
directories={directories}
autoAddToDashboardId={dashboardId}
label={t("workspace.analysis.dashboards.create_new_chart")}
onSuccess={() => {
onOpenChange(false);
router.refresh();
onSuccess();
}}
buttonProps={{ variant: "secondary", size: "default", disabled: isAdding }}
/>
<div className="flex flex-col-reverse gap-2 sm:flex-row">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
{t("common.cancel")}
</Button>
<Button
onClick={handleAdd}
loading={isAdding}
disabled={selectedChartIds.length === 0 || isAdding}>
{selectedChartIds.length > 0
? t("workspace.analysis.dashboards.add_count_charts", { count: selectedChartIds.length })
: t("common.add")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -12,9 +12,13 @@ import { Button } from "@/modules/ui/components/button";
interface CreateDashboardButtonProps {
workspaceId: string;
disabled?: boolean;
}
export const CreateDashboardButton = ({ workspaceId }: Readonly<CreateDashboardButtonProps>) => {
export const CreateDashboardButton = ({
workspaceId,
disabled = false,
}: Readonly<CreateDashboardButtonProps>) => {
const { t } = useTranslation();
const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
@@ -59,7 +63,7 @@ export const CreateDashboardButton = ({ workspaceId }: Readonly<CreateDashboardB
return (
<>
<Button size="sm" onClick={() => handleOpenChange(true)}>
<Button size="sm" onClick={() => handleOpenChange(true)} disabled={disabled}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.dashboards.create_dashboard")}
</Button>
@@ -8,12 +8,14 @@ import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { AddExistingChartsDialog } from "@/modules/ee/analysis/dashboards/components/add-existing-charts-dialog";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { IconBar } from "@/modules/ui/components/iconbar";
interface DashboardControlBarProps {
workspaceId: string;
dashboardId: string;
directories: { id: string; name: string }[];
existingChartIds: string[];
isEditing: boolean;
isSaving: boolean;
@@ -28,6 +30,7 @@ interface DashboardControlBarProps {
export const DashboardControlBar = ({
workspaceId,
dashboardId,
directories,
existingChartIds,
isEditing,
isSaving,
@@ -82,12 +85,6 @@ export const DashboardControlBar = ({
];
const viewModeActions = [
{
icon: PlusIcon,
tooltip: t("common.add_charts"),
onClick: () => setIsAddExistingDialogOpen(true),
isVisible: !isReadOnly,
},
{
icon: RefreshCwIcon,
tooltip: t("common.refresh"),
@@ -110,7 +107,19 @@ export const DashboardControlBar = ({
return (
<>
<IconBar actions={isEditing ? editModeActions : viewModeActions} />
{isEditing ? (
<IconBar actions={editModeActions} />
) : (
<div className="flex items-center gap-2">
{!isReadOnly && (
<Button onClick={() => setIsAddExistingDialogOpen(true)}>
<PlusIcon />
{t("common.add_charts")}
</Button>
)}
<IconBar actions={viewModeActions} />
</div>
)}
<DeleteDialog
deleteWhat={t("workspace.analysis.dashboards.dashboard")}
open={isDeleteDialogOpen}
@@ -124,6 +133,7 @@ export const DashboardControlBar = ({
onOpenChange={setIsAddExistingDialogOpen}
workspaceId={workspaceId}
dashboardId={dashboardId}
directories={directories}
existingChartIds={existingChartIds}
onSuccess={() => {
setIsAddExistingDialogOpen(false);
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
import "react-resizable/css/styles.css";
import type { TChartQuery } from "@formbricks/types/analysis";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import { DashboardControlBar } from "@/modules/ee/analysis/dashboards/components/dashboard-control-bar";
import { DashboardPageHeader } from "@/modules/ee/analysis/dashboards/components/dashboard-page-header";
import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/dashboard-widget";
@@ -27,6 +28,7 @@ interface DashboardDetailClientProps {
workspaceId: string;
dashboard: TDashboardDetail;
widgetDataPromises: Map<string, Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>>;
directories: { id: string; name: string }[];
isReadOnly: boolean;
}
@@ -114,17 +116,26 @@ const MemoizedWidgetItem = memo(function WidgetItem({
widget,
isEditing,
dataPromise,
onEdit,
onResize,
onRemove,
}: Readonly<{
widget: TDashboardWidget;
isEditing: boolean;
dataPromise?: Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>;
onEdit?: () => void;
onResize?: () => void;
onRemove?: () => void;
}>) {
const title = widget.chart.name;
return (
<DashboardWidget title={title} isEditing={isEditing} onRemove={onRemove}>
<DashboardWidget
title={title}
isEditing={isEditing}
onEdit={onEdit}
onResize={onResize}
onRemove={onRemove}>
<MemoizedWidgetContent widget={widget} dataPromise={dataPromise} />
</DashboardWidget>
);
@@ -134,6 +145,7 @@ export function DashboardDetailClient({
workspaceId,
dashboard,
widgetDataPromises,
directories,
isReadOnly,
}: Readonly<DashboardDetailClientProps>) {
const router = useRouter();
@@ -142,6 +154,7 @@ export function DashboardDetailClient({
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editingChartId, setEditingChartId] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [name, setName] = useState(dashboard.name);
@@ -171,6 +184,32 @@ export function DashboardDetailClient({
[dashboard.widgets]
);
const handleEnterEditMode = useCallback(() => {
if (isEditing) {
return;
}
setDraftWidgets((current) => current ?? dashboard.widgets);
setIsEditing(true);
}, [dashboard.widgets, isEditing]);
const handleEditChart = useCallback((chartId: string) => {
setEditingChartId(chartId);
}, []);
const handleRemoveWidgetFromMenu = useCallback(
(widgetId: string) => {
if (!isEditing) {
setDraftWidgets((current) => (current ?? dashboard.widgets).filter((w) => w.id !== widgetId));
setIsEditing(true);
return;
}
handleRemoveWidget(widgetId);
},
[dashboard.widgets, handleRemoveWidget, isEditing]
);
const handleCancel = useCallback(() => {
setName(dashboard.name);
setDraftWidgets(null);
@@ -248,6 +287,7 @@ export function DashboardDetailClient({
<DashboardControlBar
workspaceId={workspaceId}
dashboardId={dashboard.id}
directories={directories}
existingChartIds={widgets.map((w) => w.chartId)}
isEditing={isEditing}
isSaving={isSaving}
@@ -296,7 +336,9 @@ export function DashboardDetailClient({
widget={widget}
isEditing={isEditing}
dataPromise={widgetDataPromises.get(widget.id)}
onRemove={isEditing ? () => handleRemoveWidget(widget.id) : undefined}
onEdit={isReadOnly ? undefined : () => handleEditChart(widget.chartId)}
onResize={isReadOnly ? undefined : handleEnterEditMode}
onRemove={isReadOnly ? undefined : () => handleRemoveWidgetFromMenu(widget.id)}
/>
</div>
))}
@@ -305,6 +347,23 @@ export function DashboardDetailClient({
)}
</div>
</section>
{!isReadOnly && (
<CreateChartDialog
open={editingChartId !== null}
onOpenChange={(open) => {
if (!open) {
setEditingChartId(null);
}
}}
workspaceId={workspaceId}
chartId={editingChartId ?? undefined}
onSuccess={() => {
setEditingChartId(null);
router.refresh();
}}
directories={directories}
/>
)}
</PageContentWrapper>
);
}
@@ -1,6 +1,6 @@
"use client";
import { MoreVerticalIcon, TrashIcon } from "lucide-react";
import { Maximize2Icon, MoreVerticalIcon, SquarePenIcon, TrashIcon } from "lucide-react";
import { ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
@@ -15,12 +15,22 @@ interface DashboardWidgetProps {
title: string;
children: ReactNode;
isEditing?: boolean;
onEdit?: () => void;
onResize?: () => void;
onRemove?: () => void;
}
export function DashboardWidget({ title, children, isEditing, onRemove }: Readonly<DashboardWidgetProps>) {
export function DashboardWidget({
title,
children,
isEditing,
onEdit,
onResize,
onRemove,
}: Readonly<DashboardWidgetProps>) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const hasMenuActions = Boolean(onEdit || onResize || onRemove);
return (
<div
@@ -34,7 +44,7 @@ export function DashboardWidget({ title, children, isEditing, onRemove }: Readon
isEditing && "rgl-drag-handle cursor-grab active:cursor-grabbing"
)}>
<h3 className="flex-1 truncate text-sm font-semibold text-gray-800">{title}</h3>
{onRemove && (
{hasMenuActions && (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
@@ -47,15 +57,37 @@ export function DashboardWidget({ title, children, isEditing, onRemove }: Readon
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onSelect={() => {
setMenuOpen(false);
onRemove();
}}
className="text-red-600 focus:text-red-600">
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.remove")}
</DropdownMenuItem>
{onEdit && (
<DropdownMenuItem
onSelect={() => {
setMenuOpen(false);
onEdit();
}}>
<SquarePenIcon className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
)}
{onResize && (
<DropdownMenuItem
onSelect={() => {
setMenuOpen(false);
onResize();
}}>
<Maximize2Icon className="mr-2 h-4 w-4" />
{t("common.resize")}
</DropdownMenuItem>
)}
{onRemove && (
<DropdownMenuItem
onSelect={() => {
setMenuOpen(false);
onRemove();
}}
className="text-red-600 focus:text-red-600">
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.remove")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -4,6 +4,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
import { injectTenantFilter } from "@/modules/ee/analysis/charts/lib/chart-utils";
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { DashboardDetailClient } from "../components/dashboard-detail-client";
import { getDashboard } from "../lib/dashboards";
@@ -33,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 {
@@ -65,6 +67,7 @@ export async function DashboardDetailPage({
workspaceId={workspaceId}
dashboard={dashboard}
widgetDataPromises={widgetDataPromises}
directories={directories}
isReadOnly={isReadOnly}
/>
);
@@ -1,6 +1,8 @@
import { use } from "react";
import { getTranslate } from "@/lingodotdev/server";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
import { hasWorkspaceFeedbackRecords } from "@/modules/ee/analysis/lib/feedback-records";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { TDashboardWithCount } from "../../types/analysis";
import { CreateDashboardButton } from "../components/create-dashboard-button";
@@ -31,18 +33,27 @@ export const DashboardsListPage = async ({ workspaceId }: Readonly<DashboardsLis
const t = await getTranslate();
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
const dashboardsPromise = getDashboards(workspaceId);
const hasFeedbackRecords = await hasWorkspaceFeedbackRecords(workspaceId);
const dashboardsPromise = hasFeedbackRecords ? getDashboards(workspaceId) : null;
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
workspaceId={workspaceId}
cta={isReadOnly ? undefined : <CreateDashboardButton workspaceId={workspaceId} />}>
<DashboardsListContent
dashboardsPromise={dashboardsPromise}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
/>
cta={
isReadOnly ? undefined : (
<CreateDashboardButton workspaceId={workspaceId} disabled={!hasFeedbackRecords} />
)
}>
{hasFeedbackRecords && dashboardsPromise ? (
<DashboardsListContent
dashboardsPromise={dashboardsPromise}
workspaceId={workspaceId}
isReadOnly={isReadOnly}
/>
) : (
<NoFeedbackRecordsState workspaceId={workspaceId} />
)}
</AnalysisPageLayout>
);
};
@@ -0,0 +1,30 @@
"server-only";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { listFeedbackRecords } from "@/modules/hub/service";
export const hasFeedbackRecordsInDirectories = async (directoryIds: string[]): Promise<boolean> => {
if (directoryIds.length === 0) {
return false;
}
const results = await Promise.all(
directoryIds.map((directoryId) => listFeedbackRecords({ tenant_id: directoryId, limit: 1 }))
);
const hasRecords = results.some((result) => (result.data?.data?.length ?? 0) > 0);
if (hasRecords) {
return true;
}
const hasErrors = results.some((result) => Boolean(result.error));
// Do not lock creation flows when record availability is unknown.
return hasErrors;
};
export const hasWorkspaceFeedbackRecords = async (workspaceId: string): Promise<boolean> => {
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
return hasFeedbackRecordsInDirectories(directories.map((directory) => directory.id));
};
@@ -36,6 +36,8 @@ describe("updateWorkspaceBranding", () => {
styling: {
allowStyleOverwrite: true,
brandColor: { light: "#64748b" },
questionColor: { light: "#2b2524" },
inputColor: { light: "#ffffff" },
inputBorderColor: { light: "#cbd5e1" },
cardBackgroundColor: { light: "#ffffff" },
cardBorderColor: { light: "#f8fafc" },
@@ -455,10 +455,10 @@ function EmailTemplateWrapper({
const colors = {
"brand-color": styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
"card-bg-color": styling.cardBackgroundColor?.light ?? COLOR_DEFAULTS.cardBackgroundColor,
"input-color": styling.inputBgColor?.light ?? COLOR_DEFAULTS.inputBgColor,
"input-color": styling.inputColor?.light ?? COLOR_DEFAULTS.inputColor,
"input-border-color": styling.inputBorderColor?.light ?? COLOR_DEFAULTS.inputBorderColor,
"card-border-color": styling.cardBorderColor?.light ?? COLOR_DEFAULTS.cardBorderColor,
"question-color": styling.elementHeadlineColor?.light ?? COLOR_DEFAULTS.elementHeadlineColor,
"question-color": styling.questionColor?.light ?? COLOR_DEFAULTS.questionColor,
};
if (isLight(colors["question-color"])) {
+2 -5
View File
@@ -40,7 +40,7 @@ import {
import { getPublicDomain } from "@/lib/getPublicUrl";
import { createEmailChangeToken, createInviteToken, createToken, createTokenForLinkSurvey } from "@/lib/jwt";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import { TElementResponseMappingSurvey, getElementResponseMapping } from "@/lib/responses";
import { getElementResponseMapping } from "@/lib/responses";
import { getTranslate } from "@/lingodotdev/server";
import { buildVerificationLinks } from "@/modules/auth/lib/verification-links";
import { resolveStorageUrl } from "@/modules/storage/utils";
@@ -62,9 +62,6 @@ interface SendEmailDataProps {
html: string;
}
export type TResponseFinishedEmailSurvey = TElementResponseMappingSurvey &
Pick<TSurvey, "id" | "name" | "variables" | "hiddenFields">;
export const sendEmail = async (emailData: SendEmailDataProps): Promise<boolean> => {
if (!IS_SMTP_CONFIGURED) {
logger.info("SMTP is not configured, skipping email sending");
@@ -239,7 +236,7 @@ export const sendResponseFinishedEmail = async (
email: string,
locale: TUserLocale,
workspaceId: string,
survey: TResponseFinishedEmailSurvey,
survey: TSurvey,
response: TResponse,
responseCount: number
): Promise<void> => {
+1
View File
@@ -5,6 +5,7 @@ export {
listFeedbackRecords,
retrieveFeedbackRecord,
updateFeedbackRecord,
type CreateFeedbackRecordResult,
type HubFeedbackRecordResult,
type ListFeedbackRecordsResult,
} from "./service";
+1 -63
View File
@@ -1,11 +1,5 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
createFeedbackRecord,
createFeedbackRecordsBatch,
listFeedbackRecords,
retrieveFeedbackRecord,
updateFeedbackRecord,
} from "./service";
import { createFeedbackRecord, createFeedbackRecordsBatch, listFeedbackRecords } from "./service";
import type { FeedbackRecordCreateParams } from "./types";
vi.mock("@formbricks/logger", () => ({
@@ -127,62 +121,6 @@ describe("hub service", () => {
});
});
describe("retrieveFeedbackRecord", () => {
test("returns error when client is null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await retrieveFeedbackRecord("rec-1");
expect(result.data).toBeNull();
expect(result.error?.message).toContain("HUB_API_KEY");
});
test("returns data on success", async () => {
const record = { id: "rec-1", field_id: "f1" };
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { retrieve: vi.fn().mockResolvedValue(record) },
} as any);
const result = await retrieveFeedbackRecord("rec-1");
expect(result.data).toEqual(record);
expect(result.error).toBeNull();
});
test("returns error on throw", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { retrieve: vi.fn().mockRejectedValue(new Error("Not found")) },
} as any);
const result = await retrieveFeedbackRecord("rec-1");
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ message: "Not found" });
});
});
describe("updateFeedbackRecord", () => {
test("returns error when client is null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await updateFeedbackRecord("rec-1", { value_text: "new" });
expect(result.data).toBeNull();
expect(result.error?.message).toContain("HUB_API_KEY");
});
test("returns data on success", async () => {
const updated = { id: "rec-1", value_text: "new" };
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { update: vi.fn().mockResolvedValue(updated) },
} as any);
const result = await updateFeedbackRecord("rec-1", { value_text: "new" });
expect(result.data).toEqual(updated);
expect(result.error).toBeNull();
});
test("returns error on throw", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { update: vi.fn().mockRejectedValue(new Error("Forbidden")) },
} as any);
const result = await updateFeedbackRecord("rec-1", { value_text: "new" });
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ message: "Forbidden" });
});
});
describe("createFeedbackRecordsBatch", () => {
test("returns all errors when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
+1
View File
@@ -16,6 +16,7 @@ export type HubFeedbackRecordResult = {
data: FeedbackRecordData | null;
error: HubError | null;
};
export type CreateFeedbackRecordResult = HubFeedbackRecordResult;
const NO_CONFIG_ERROR = {
status: 0,
@@ -172,65 +172,8 @@ export const EditAPIKeys = ({
return (
<div className="space-y-4">
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-10 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">{t("workspace.api_keys.api_key")}</div>
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
<div></div>
</div>
<div className="grid-cols-9">
{apiKeysLocal?.length === 0 ? (
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400">
{t("workspace.api_keys.no_api_keys_yet")}
</div>
) : (
apiKeysLocal?.map((apiKey) => (
<div
role="button"
className="grid h-12 w-full grid-cols-10 content-center items-center rounded-lg px-6 text-left text-sm text-slate-900 hover:bg-slate-50 focus:bg-slate-50 focus:outline-none"
onClick={() => {
setActiveKey(apiKey);
setViewPermissionsOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveKey(apiKey);
setViewPermissionsOpen(true);
}
}}
tabIndex={0}
data-testid="api-key-row"
key={apiKey.id}>
<div className="col-span-4 font-semibold sm:col-span-2">{apiKey.label}</div>
<div className="col-span-4 hidden pr-4 sm:col-span-5 sm:block">
<ApiKeyDisplay apiKey={apiKey.actualKey ?? ""} />
</div>
<div className="col-span-4 sm:col-span-2">
{timeSince(apiKey.createdAt.toString(), locale)}
</div>
{!isReadOnly && (
<div className="col-span-1 text-center">
<Button
size="icon"
variant="ghost"
onClick={(e) => {
handleOpenDeleteKeyModal(e, apiKey);
e.stopPropagation();
}}>
<TrashIcon />
</Button>
</div>
)}
</div>
))
)}
</div>
</div>
{!isReadOnly && (
<div>
<div className="absolute right-4 top-4">
<Button
size="sm"
onClick={() => {
@@ -240,6 +183,65 @@ export const EditAPIKeys = ({
</Button>
</div>
)}
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-10 content-center border-b border-slate-200 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">{t("workspace.api_keys.api_key")}</div>
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
<div></div>
</div>
<div>
{apiKeysLocal?.length === 0 ? (
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm text-slate-400">
{t("workspace.api_keys.no_api_keys_yet")}
</div>
) : (
<div className="divide-y divide-slate-100">
{apiKeysLocal?.map((apiKey) => (
<div
role="button"
className="grid h-12 w-full grid-cols-10 content-center items-center px-6 text-left text-sm text-slate-900 transition-colors hover:bg-slate-50 focus:bg-slate-50 focus:outline-none"
onClick={() => {
setActiveKey(apiKey);
setViewPermissionsOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveKey(apiKey);
setViewPermissionsOpen(true);
}
}}
tabIndex={0}
data-testid="api-key-row"
key={apiKey.id}>
<div className="col-span-4 font-semibold sm:col-span-2">{apiKey.label}</div>
<div className="col-span-4 hidden pr-4 sm:col-span-5 sm:block">
<ApiKeyDisplay apiKey={apiKey.actualKey ?? ""} />
</div>
<div className="col-span-4 sm:col-span-2">
{timeSince(apiKey.createdAt.toString(), locale)}
</div>
{!isReadOnly && (
<div className="col-span-1 text-center">
<Button
size="icon"
variant="ghost"
onClick={(e) => {
handleOpenDeleteKeyModal(e, apiKey);
e.stopPropagation();
}}>
<TrashIcon />
</Button>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
<AddApiKeyModal
open={isAddAPIKeyModalOpen}
setOpen={setIsAddAPIKeyModalOpen}
@@ -26,7 +26,6 @@ type TIntegrationPipelineData = {
response: Pick<TResponse, "createdAt" | "data" | "meta" | "variables">;
surveyId: string;
};
type TPipelineIntegrationSurvey = Pick<TSurvey, "blocks" | "hiddenFields" | "variables" | "name">;
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
@@ -68,7 +67,7 @@ const toIntegrationFieldSelection = (config: {
const processDataForIntegration = async (
integrationType: TIntegrationType,
data: TIntegrationPipelineData,
survey: TPipelineIntegrationSurvey,
survey: TSurvey,
selection: TIntegrationFieldSelection
): Promise<{
responses: string[];
@@ -109,7 +108,7 @@ const processDataForIntegration = async (
export const handleIntegrations = async (
integrations: TIntegration[],
data: TIntegrationPipelineData,
survey: TPipelineIntegrationSurvey
survey: TSurvey
) => {
for (const integration of integrations) {
switch (integration.type) {
@@ -156,7 +155,7 @@ export const handleIntegrations = async (
const handleAirtableIntegration = async (
integration: TIntegrationAirtable,
data: TIntegrationPipelineData,
survey: TPipelineIntegrationSurvey
survey: TSurvey
): Promise<Result<void, Error>> => {
try {
if (integration.config.data.length > 0) {
@@ -188,7 +187,7 @@ const handleAirtableIntegration = async (
const handleGoogleSheetsIntegration = async (
integration: TIntegrationGoogleSheets,
data: TIntegrationPipelineData,
survey: TPipelineIntegrationSurvey
survey: TSurvey
): Promise<Result<void, Error>> => {
try {
if (integration.config.data.length > 0) {
@@ -225,7 +224,7 @@ const handleGoogleSheetsIntegration = async (
const handleSlackIntegration = async (
integration: TIntegrationSlack,
data: TIntegrationPipelineData,
survey: TPipelineIntegrationSurvey
survey: TSurvey
): Promise<Result<void, Error>> => {
try {
if (integration.config.data.length > 0) {
@@ -301,7 +300,7 @@ const extractResponses = async (
integrationType: TIntegrationType,
pipelineData: TIntegrationPipelineData,
elementIds: string[],
survey: TPipelineIntegrationSurvey
survey: TSurvey
): Promise<{
responses: string[];
elements: string[];
@@ -346,7 +345,7 @@ const extractResponses = async (
const handleNotionIntegration = async (
integration: TIntegrationNotion,
data: TIntegrationPipelineData,
surveyData: TPipelineIntegrationSurvey
surveyData: TSurvey
): Promise<Result<void, Error>> => {
try {
if (integration.config.data.length > 0) {
@@ -373,7 +372,7 @@ const handleNotionIntegration = async (
const buildNotionPayloadProperties = (
mapping: TIntegrationNotionConfigData["mapping"],
data: TIntegrationPipelineData,
surveyData: TPipelineIntegrationSurvey
surveyData: TSurvey
) => {
const properties: any = {};
const normalizedResponses = { ...data.response.data };
@@ -1,3 +1,4 @@
import { UnrecoverableError } from "bullmq";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { TResponsePipelineJobData } from "@formbricks/jobs";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
@@ -5,15 +6,12 @@ import { processResponsePipelineJob } from "./process-response-pipeline-job";
const {
mockFetch,
mockCaptureSurveyResponsePostHogEvent,
mockGetIntegrations,
mockGetOrganizationByWorkspaceId,
mockGetResponseCountBySurveyId,
mockGetSurvey,
mockHandleIntegrations,
mockLoggerError,
mockLoggerWarn,
mockPrismaOrganizationFindFirst,
mockPrismaSurveyFindUnique,
mockPrismaSurveyUpdate,
mockPrismaUserFindMany,
mockPrismaWebhookFindMany,
mockQueueAuditEventWithoutRequest,
@@ -21,41 +19,29 @@ const {
mockSendFollowUpsForResponse,
mockSendResponseFinishedEmail,
mockSendTelemetryEvents,
mockUpdateSurvey,
mockValidateWebhookUrl,
} = vi.hoisted(() => {
process.env.HUB_API_URL ??= "https://hub.test";
return {
mockFetch: vi.fn(),
mockCaptureSurveyResponsePostHogEvent: vi.fn(),
mockGetIntegrations: vi.fn(),
mockGetResponseCountBySurveyId: vi.fn(),
mockHandleIntegrations: vi.fn(),
mockLoggerError: vi.fn(),
mockLoggerWarn: vi.fn(),
mockPrismaOrganizationFindFirst: vi.fn(),
mockPrismaSurveyFindUnique: vi.fn(),
mockPrismaSurveyUpdate: vi.fn(),
mockPrismaUserFindMany: vi.fn(),
mockPrismaWebhookFindMany: vi.fn(),
mockQueueAuditEventWithoutRequest: vi.fn(),
mockRecordResponseCreatedMeterEvent: vi.fn(),
mockSendFollowUpsForResponse: vi.fn(),
mockSendResponseFinishedEmail: vi.fn(),
mockSendTelemetryEvents: vi.fn(),
mockValidateWebhookUrl: vi.fn(),
};
});
} = vi.hoisted(() => ({
mockFetch: vi.fn(),
mockGetIntegrations: vi.fn(),
mockGetOrganizationByWorkspaceId: vi.fn(),
mockGetResponseCountBySurveyId: vi.fn(),
mockGetSurvey: vi.fn(),
mockHandleIntegrations: vi.fn(),
mockLoggerError: vi.fn(),
mockPrismaUserFindMany: vi.fn(),
mockPrismaWebhookFindMany: vi.fn(),
mockQueueAuditEventWithoutRequest: vi.fn(),
mockRecordResponseCreatedMeterEvent: vi.fn(),
mockSendFollowUpsForResponse: vi.fn(),
mockSendResponseFinishedEmail: vi.fn(),
mockSendTelemetryEvents: vi.fn(),
mockUpdateSurvey: vi.fn(),
mockValidateWebhookUrl: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findFirst: mockPrismaOrganizationFindFirst,
},
survey: {
findUnique: mockPrismaSurveyFindUnique,
update: mockPrismaSurveyUpdate,
},
webhook: {
findMany: mockPrismaWebhookFindMany,
},
@@ -65,24 +51,6 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@formbricks/jobs", () => ({
UnrecoverableError: class UnrecoverableError extends Error {
constructor(message: string) {
super(message);
this.name = "UnrecoverableError";
}
},
}));
vi.mock(import("@/lib/constants"), async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
POSTHOG_KEY: undefined,
};
});
vi.mock("./handle-integrations", () => ({
handleIntegrations: mockHandleIntegrations,
}));
@@ -91,6 +59,10 @@ vi.mock("./telemetry", () => ({
sendTelemetryEvents: mockSendTelemetryEvents,
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByWorkspaceId: mockGetOrganizationByWorkspaceId,
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrations: mockGetIntegrations,
}));
@@ -99,8 +71,9 @@ vi.mock("@/lib/response/service", () => ({
getResponseCountBySurveyId: mockGetResponseCountBySurveyId,
}));
vi.mock("./posthog", () => ({
captureSurveyResponsePostHogEvent: mockCaptureSurveyResponsePostHogEvent,
vi.mock("@/lib/survey/service", () => ({
getSurvey: mockGetSurvey,
updateSurvey: mockUpdateSurvey,
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
@@ -128,12 +101,12 @@ vi.mock("@formbricks/logger", () => ({
debug: vi.fn(),
error: mockLoggerError,
info: vi.fn(),
warn: mockLoggerWarn,
warn: vi.fn(),
},
}));
const baseData: TResponsePipelineJobData = {
workspaceId: "workspace_123",
workspaceId: "ws_123",
event: "responseCreated",
response: {
contact: null,
@@ -176,20 +149,14 @@ const organization = {
};
const survey = {
blocks: [],
autoComplete: null,
createdAt: new Date("2026-04-01T10:00:00.000Z"),
followUps: [],
hiddenFields: {
fieldIds: [],
},
id: "survey_123",
languages: [],
name: "Test survey",
status: "inProgress",
type: "app",
updatedAt: new Date("2026-04-01T10:00:00.000Z"),
variables: [],
workspaceId: "workspace_123",
};
@@ -198,8 +165,8 @@ const originalFetch = global.fetch;
describe("processResponsePipelineJob", () => {
beforeEach(() => {
vi.clearAllMocks();
mockPrismaOrganizationFindFirst.mockResolvedValue(organization);
mockPrismaSurveyFindUnique.mockResolvedValue(survey);
mockGetOrganizationByWorkspaceId.mockResolvedValue(organization);
mockGetSurvey.mockResolvedValue(survey);
mockGetIntegrations.mockResolvedValue([]);
mockPrismaWebhookFindMany.mockResolvedValue([]);
mockPrismaUserFindMany.mockResolvedValue([]);
@@ -211,7 +178,7 @@ describe("processResponsePipelineJob", () => {
mockSendResponseFinishedEmail.mockResolvedValue(undefined);
mockSendFollowUpsForResponse.mockResolvedValue({ ok: true, data: [] });
mockSendTelemetryEvents.mockResolvedValue(undefined);
mockPrismaSurveyUpdate.mockResolvedValue(undefined);
mockUpdateSurvey.mockResolvedValue(undefined);
mockFetch.mockResolvedValue({
ok: true,
status: 200,
@@ -285,7 +252,7 @@ describe("processResponsePipelineJob", () => {
test("processes responseFinished jobs and preserves legacy side effects", async () => {
mockGetIntegrations.mockResolvedValue([{ id: "integration_123", type: "slack" }]);
mockPrismaSurveyFindUnique.mockResolvedValue({
mockGetSurvey.mockResolvedValue({
...survey,
autoComplete: 1,
followUps: [{ id: "followup_123" }],
@@ -323,59 +290,6 @@ describe("processResponsePipelineJob", () => {
followUps: [{ id: "followup_123" }],
}
);
expect(mockPrismaUserFindMany).toHaveBeenCalledWith({
select: { email: true, locale: true },
where: {
memberships: {
some: {
organization: {
workspaces: {
some: {
id: "workspace_123",
},
},
},
},
},
notificationSettings: {
equals: true,
path: ["alert", "survey_123"],
},
OR: [
{
memberships: {
some: {
role: {
in: ["owner", "manager"],
},
organization: {
workspaces: {
some: {
id: "workspace_123",
},
},
},
},
},
},
{
teamUsers: {
some: {
team: {
workspaceTeams: {
some: {
workspace: {
id: "workspace_123",
},
},
},
},
},
},
},
],
},
});
expect(mockSendFollowUpsForResponse).toHaveBeenCalledWith("response_123");
expect(mockSendResponseFinishedEmail).toHaveBeenCalledWith(
"owner@example.com",
@@ -385,14 +299,12 @@ describe("processResponsePipelineJob", () => {
baseData.response,
1
);
expect(mockPrismaSurveyUpdate).toHaveBeenCalledWith({
data: {
status: "completed",
},
where: {
expect(mockUpdateSurvey).toHaveBeenCalledWith(
expect.objectContaining({
id: "survey_123",
},
});
status: "completed",
})
);
expect(mockQueueAuditEventWithoutRequest).toHaveBeenCalledWith(
expect.objectContaining({
action: "updated",
@@ -421,8 +333,8 @@ describe("processResponsePipelineJob", () => {
message: "not allowed",
},
});
mockPrismaSurveyUpdate.mockRejectedValue(new Error("update failed"));
mockPrismaSurveyFindUnique.mockResolvedValue({
mockUpdateSurvey.mockRejectedValue(new Error("update failed"));
mockGetSurvey.mockResolvedValue({
...survey,
autoComplete: 1,
followUps: [{ id: "followup_123" }],
@@ -463,7 +375,7 @@ describe("processResponsePipelineJob", () => {
test("fails the job before the final attempt when webhook delivery fails", async () => {
const webhookError = new Error("invalid webhook");
mockGetIntegrations.mockResolvedValue([{ id: "integration_123", type: "slack" }]);
mockPrismaSurveyFindUnique.mockResolvedValue({
mockGetSurvey.mockResolvedValue({
...survey,
autoComplete: 1,
followUps: [{ id: "followup_123" }],
@@ -496,7 +408,7 @@ describe("processResponsePipelineJob", () => {
expect(mockHandleIntegrations).not.toHaveBeenCalled();
expect(mockSendFollowUpsForResponse).not.toHaveBeenCalled();
expect(mockSendResponseFinishedEmail).not.toHaveBeenCalled();
expect(mockPrismaSurveyUpdate).not.toHaveBeenCalled();
expect(mockUpdateSurvey).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
expect.objectContaining({
err: webhookError,
@@ -509,7 +421,7 @@ describe("processResponsePipelineJob", () => {
test("continues responseFinished side effects on the final webhook attempt", async () => {
const webhookError = new Error("invalid webhook");
mockGetIntegrations.mockResolvedValue([{ id: "integration_123", type: "slack" }]);
mockPrismaSurveyFindUnique.mockResolvedValue({
mockGetSurvey.mockResolvedValue({
...survey,
autoComplete: 1,
followUps: [{ id: "followup_123" }],
@@ -542,7 +454,7 @@ describe("processResponsePipelineJob", () => {
expect(mockHandleIntegrations).toHaveBeenCalledTimes(1);
expect(mockSendFollowUpsForResponse).toHaveBeenCalledWith("response_123");
expect(mockSendResponseFinishedEmail).toHaveBeenCalledTimes(1);
expect(mockPrismaSurveyUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdateSurvey).toHaveBeenCalledTimes(1);
expect(mockLoggerError).toHaveBeenCalledWith(
expect.objectContaining({
attempt: 3,
@@ -580,7 +492,7 @@ describe("processResponsePipelineJob", () => {
test("does not retry a successful webhook when later responseFinished side effects fail", async () => {
const auditError = new Error("audit offline");
mockPrismaSurveyFindUnique.mockResolvedValue({
mockGetSurvey.mockResolvedValue({
...survey,
autoComplete: 1,
});
@@ -743,16 +655,16 @@ describe("processResponsePipelineJob", () => {
});
test("fails fast when the workspace organization cannot be found", async () => {
mockPrismaOrganizationFindFirst.mockResolvedValue(null);
mockGetOrganizationByWorkspaceId.mockResolvedValue(null);
await expect(processResponsePipelineJob(baseData, baseContext)).rejects.toThrow(
"Organization not found for workspace workspace_123"
new UnrecoverableError("Organization not found for workspace workspace_123")
);
expect(mockLoggerError).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "workspace_123",
err: expect.any(Error),
workspaceId: "ws_123",
err: expect.any(UnrecoverableError),
jobId: "job_123",
responseId: "response_123",
surveyId: "survey_123",
@@ -760,47 +672,4 @@ describe("processResponsePipelineJob", () => {
"Response pipeline job failed"
);
});
test("fails fast when the survey cannot be found", async () => {
mockPrismaSurveyFindUnique.mockResolvedValue(null);
await expect(processResponsePipelineJob(baseData, baseContext)).rejects.toThrow(
"Survey survey_123 not found"
);
expect(mockLoggerError).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "workspace_123",
err: expect.any(Error),
jobId: "job_123",
responseId: "response_123",
surveyId: "survey_123",
}),
"Response pipeline job failed"
);
});
test("classifies database pool exhaustion as retryable and logs a warning", async () => {
const poolExhaustionError = new Error("Timed out fetching a new connection from the connection pool");
mockPrismaSurveyFindUnique.mockRejectedValue(poolExhaustionError);
await expect(processResponsePipelineJob(baseData, baseContext)).rejects.toThrow(poolExhaustionError);
expect(mockLoggerWarn).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "workspace_123",
err: poolExhaustionError,
jobId: "job_123",
responseId: "response_123",
surveyId: "survey_123",
}),
"Response pipeline job hit database pool exhaustion and will be retried"
);
expect(mockLoggerError).not.toHaveBeenCalledWith(
expect.objectContaining({
err: poolExhaustionError,
}),
"Response pipeline job failed"
);
});
});
@@ -1,21 +1,21 @@
import "server-only";
import { PipelineTriggers, Prisma, type Webhook } from "@prisma/client";
import { PipelineTriggers, type Webhook } from "@prisma/client";
import { UnrecoverableError } from "bullmq";
import { createHash } from "node:crypto";
import { prisma } from "@formbricks/database";
import { type JobHandler, type TResponsePipelineJobData, UnrecoverableError } from "@formbricks/jobs";
import type { JobHandler, TResponsePipelineJobData } from "@formbricks/jobs";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { type TUserLocale, ZUserLocale } from "@formbricks/types/user";
import { POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler";
import { type 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 { captureSurveyResponsePostHogEvent } from "@/modules/response-pipeline/lib/posthog";
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";
@@ -25,69 +25,6 @@ import { sendTelemetryEvents } from "./telemetry";
const WEBHOOK_TIMEOUT_MS = 5_000;
const DEFAULT_NOTIFICATION_LOCALE: TUserLocale = "en-US";
const pipelineOrganizationSelect = {
id: true,
billing: {
select: {
stripeCustomerId: true,
},
},
} satisfies Prisma.OrganizationSelect;
const pipelineSurveySelect = {
id: true,
workspaceId: true,
name: true,
type: true,
status: true,
createdAt: true,
updatedAt: true,
blocks: true,
hiddenFields: true,
variables: true,
followUps: true,
autoComplete: true,
languages: {
select: {
default: true,
enabled: true,
language: {
select: {
id: true,
code: true,
alias: true,
createdAt: true,
updatedAt: true,
workspaceId: true,
},
},
},
},
} satisfies Prisma.SurveySelect;
type TPipelineOrganization = Prisma.OrganizationGetPayload<{ select: typeof pipelineOrganizationSelect }>;
type TPipelineSurvey = Prisma.SurveyGetPayload<{ select: typeof pipelineSurveySelect }>;
const getOrganizationForPipeline = async (workspaceId: string): Promise<TPipelineOrganization | null> =>
prisma.organization.findFirst({
where: {
workspaces: {
some: {
id: workspaceId,
},
},
},
select: pipelineOrganizationSelect,
});
const getSurveyForPipeline = async (surveyId: string): Promise<TPipelineSurvey | null> =>
prisma.survey.findUnique({
where: {
id: surveyId,
},
select: pipelineSurveySelect,
});
const getPipelineLogContext = (
data: TResponsePipelineJobData,
context: Parameters<JobHandler<TResponsePipelineJobData>>[1]
@@ -111,20 +48,6 @@ const toUserLocale = (locale: string): TUserLocale => {
return parsedLocale.success ? parsedLocale.data : DEFAULT_NOTIFICATION_LOCALE;
};
export const isPipelinePoolExhaustionError = (error: unknown): boolean => {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2024") {
return true;
}
if (error instanceof DatabaseError || error instanceof Error) {
return /Timed out fetching a new connection from the connection pool|connection pool timeout/i.test(
error.message
);
}
return false;
};
const createWebhookMessageId = ({
event,
jobId,
@@ -180,7 +103,7 @@ const createWebhookDeliveryTask = async ({
}: {
webhook: Webhook;
data: TResponsePipelineJobData;
survey: TPipelineSurvey;
survey: Awaited<ReturnType<typeof getSurvey>>;
logContext: ReturnType<typeof getPipelineLogContext>;
}): Promise<void> => {
try {
@@ -253,7 +176,7 @@ const deliverWebhooks = async ({
}: {
data: TResponsePipelineJobData;
logContext: ReturnType<typeof getPipelineLogContext>;
survey: TPipelineSurvey;
survey: NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
webhooks: Webhook[];
}): Promise<void> => {
const results = await Promise.allSettled(
@@ -354,17 +277,10 @@ const getUsersWithNotifications = async ({
OR: [
{
memberships: {
some: {
every: {
role: {
in: ["owner", "manager"],
},
organization: {
workspaces: {
some: {
id: workspaceId,
},
},
},
},
},
},
@@ -416,7 +332,7 @@ const handleFollowUpsSafely = async ({
}: {
data: TResponsePipelineJobData;
logContext: ReturnType<typeof getPipelineLogContext>;
survey: TPipelineSurvey;
survey: NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
}): Promise<void> => {
if (!survey.followUps?.length) {
return;
@@ -455,7 +371,7 @@ const sendNotificationEmailsSafely = async ({
data: TResponsePipelineJobData;
logContext: ReturnType<typeof getPipelineLogContext>;
responseCount: number | null;
survey: TPipelineSurvey;
survey: NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
usersWithNotifications: Array<{ email: string; locale: TUserLocale }>;
workspaceId: string;
}): Promise<void> => {
@@ -507,7 +423,7 @@ const handleSurveyAutoCompleteSafely = async ({
logContext: ReturnType<typeof getPipelineLogContext>;
organizationId: string;
responseCount: number | null;
survey: TPipelineSurvey;
survey: NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
}): Promise<void> => {
if (responseCount === null) {
if (survey.autoComplete) {
@@ -530,13 +446,9 @@ const handleSurveyAutoCompleteSafely = async ({
let logStatus: TAuditStatus = "success";
try {
await prisma.survey.update({
where: {
id: survey.id,
},
data: {
status: "completed",
},
await updateSurvey({
...survey,
status: "completed",
});
} catch (error) {
logStatus = "failure";
@@ -588,21 +500,14 @@ const runResponseFinishedSideEffects = async ({
data: TResponsePipelineJobData;
logContext: ReturnType<typeof getPipelineLogContext>;
organizationId: string;
survey: TPipelineSurvey;
survey: NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
workspaceId: string;
}) => {
const [{ integrations, responseCount }, usersWithNotifications] = await Promise.all([
loadResponseFinishedContext({
data,
logContext,
workspaceId,
}),
getUsersWithNotifications({
data,
logContext,
workspaceId,
}),
]);
const { integrations, responseCount } = await loadResponseFinishedContext({
data,
logContext,
workspaceId,
});
if (integrations.length > 0) {
try {
@@ -618,6 +523,12 @@ const runResponseFinishedSideEffects = async ({
}
}
const usersWithNotifications = await getUsersWithNotifications({
data,
logContext,
workspaceId,
});
await handleFollowUpsSafely({
data,
logContext,
@@ -644,14 +555,10 @@ const runResponseFinishedSideEffects = async ({
const runResponseCreatedSideEffects = async ({
data,
logContext,
organizationId,
survey,
stripeCustomerId,
}: {
data: TResponsePipelineJobData;
logContext: ReturnType<typeof getPipelineLogContext>;
organizationId: string;
survey: TPipelineSurvey;
stripeCustomerId: string | null | undefined;
}) => {
try {
@@ -670,27 +577,6 @@ const runResponseCreatedSideEffects = async ({
);
}
if (POSTHOG_KEY) {
try {
const responseCount = await getResponseCountBySurveyId(data.surveyId);
captureSurveyResponsePostHogEvent({
organizationId,
surveyId: data.surveyId,
surveyType: survey.type,
workspaceId: data.workspaceId,
responseCount,
});
} catch (error) {
logger.error(
{
...logContext,
err: error,
},
"Response pipeline PostHog capture failed"
);
}
}
try {
await sendTelemetryEvents();
} catch (error) {
@@ -708,26 +594,20 @@ export const processResponsePipelineJob: JobHandler<TResponsePipelineJobData> =
const logContext = getPipelineLogContext(data, context);
try {
const [organization, survey, webhooks] = await Promise.all([
getOrganizationForPipeline(data.workspaceId),
getSurveyForPipeline(data.surveyId),
getWebhooksForPipeline(data.workspaceId, data.event as PipelineTriggers, data.surveyId),
]);
const survey = await getSurvey(data.surveyId);
if (!survey) {
throw new UnrecoverableError(`Survey ${data.surveyId} not found`);
}
const workspaceId = survey.workspaceId;
const organization = await getOrganizationByWorkspaceId(workspaceId);
if (!organization) {
throw new UnrecoverableError(`Organization not found for workspace ${data.workspaceId}`);
}
if (survey.workspaceId !== data.workspaceId) {
throw new UnrecoverableError(
`Survey ${data.surveyId} does not belong to workspace ${data.workspaceId}`
);
throw new UnrecoverableError(`Organization not found for workspace ${workspaceId}`);
}
const event = data.event as PipelineTriggers;
const webhooks = await getWebhooksForPipeline(workspaceId, event, data.surveyId);
await deliverWebhooks({
data,
logContext,
@@ -741,7 +621,7 @@ export const processResponsePipelineJob: JobHandler<TResponsePipelineJobData> =
logContext,
organizationId: organization.id,
survey,
workspaceId: data.workspaceId,
workspaceId,
});
}
@@ -749,23 +629,10 @@ export const processResponsePipelineJob: JobHandler<TResponsePipelineJobData> =
await runResponseCreatedSideEffects({
data,
logContext,
organizationId: organization.id,
survey,
stripeCustomerId: organization.billing?.stripeCustomerId,
stripeCustomerId: organization.billing.stripeCustomerId,
});
}
} catch (error) {
if (isPipelinePoolExhaustionError(error)) {
logger.warn(
{
...logContext,
err: error,
},
"Response pipeline job hit database pool exhaustion and will be retried"
);
throw error;
}
logger.error(
{
...logContext,
@@ -181,7 +181,7 @@ export const FormStylingSettings = ({
<div className="grid grid-cols-2 gap-4">
<ColorField
form={form}
name="inputBgColor.light"
name="inputColor.light"
label={t("workspace.surveys.edit.input_color")}
description={t("workspace.surveys.edit.input_color_description")}
/>
@@ -9,7 +9,12 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TWorkspaceStyling } from "@formbricks/types/workspace";
import { COLOR_DEFAULTS, STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
import {
COLOR_DEFAULTS,
STYLE_DEFAULTS,
deriveNewFieldsFromLegacy,
getSuggestedColors,
} from "@/lib/styling/constants";
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
import { LogoSettingsCard } from "@/modules/survey/editor/components/logo-settings-card";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
@@ -68,10 +73,15 @@ export const StylingView = ({
? Object.fromEntries(Object.entries(localSurvey.styling).filter(([, v]) => v != null))
: {};
const workspaceLegacyFills = deriveNewFieldsFromLegacy(cleanWorkspace);
const surveyLegacyFills = deriveNewFieldsFromLegacy(cleanSurvey);
const form = useForm<TSurveyStyling>({
defaultValues: {
...STYLE_DEFAULTS,
...workspaceLegacyFills,
...cleanWorkspace,
...surveyLegacyFills,
...cleanSurvey,
},
});
@@ -18,10 +18,6 @@ import { ActionSettingsCard } from "../components/action-settings-card";
export const AppConnectionPage = async ({ params }: { params: Promise<{ workspaceId: string }> }) => {
const t = await getTranslate();
const { workspaceId } = await params;
const frameworkGuidesUrl =
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides";
const workspaceIdMigrationUrl =
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/workspace-id-migration";
const { isReadOnly, session, workspace } = await getWorkspaceAuth(workspaceId);
@@ -41,26 +37,7 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ workspac
description={t("workspace.app-connection.sdk_connection_details_description")}>
<div className="space-y-3">
<IdBadge id={workspace.id} label={t("workspace.app-connection.workspace_id")} />
{workspace.legacyEnvironmentId && (
<IdBadge
id={workspace.legacyEnvironmentId}
label={t("workspace.app-connection.environment_id_legacy")}
copyDisabled
/>
)}
<IdBadge id={WEBAPP_URL} label={t("workspace.app-connection.webapp_url")} />
{workspace.legacyEnvironmentId && (
<Alert variant="info" size="small">
<AlertDescription>
<p>
{t("workspace.app-connection.environment_id_legacy_alert")}{" "}
<Link href={workspaceIdMigrationUrl} target="_blank" rel="noopener noreferrer">
{t("workspace.app-connection.environment_id_legacy_alert_link")}
</Link>
</p>
</AlertDescription>
</Alert>
)}
</div>
</SettingsCard>
<SettingsCard
@@ -69,23 +46,26 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ workspac
{workspace && (
<div className="space-y-4">
<WidgetStatusIndicator workspace={workspace} />
{workspace.appSetupCompleted ? (
{!workspace.appSetupCompleted ? (
<Alert variant="info">
<AlertTitle>{t("workspace.app-connection.setup_alert_title")}</AlertTitle>
<AlertDescription>{t("workspace.app-connection.setup_alert_description")}</AlertDescription>
<AlertButton asChild>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides"
target="_blank"
rel="noopener noreferrer">
{t("common.learn_more")}
</Link>
</AlertButton>
</Alert>
) : (
<Alert variant="warning">
<AlertTitle>{t("workspace.app-connection.cache_update_delay_title")}</AlertTitle>
<AlertDescription>
{t("workspace.app-connection.cache_update_delay_description")}
</AlertDescription>
</Alert>
) : (
<Alert variant="info">
<AlertTitle>{t("workspace.app-connection.setup_alert_title")}</AlertTitle>
<AlertDescription>{t("workspace.app-connection.setup_alert_description")}</AlertDescription>
<AlertButton asChild>
<Link href={frameworkGuidesUrl} target="_blank" rel="noopener noreferrer">
{t("common.learn_more")}
</Link>
</AlertButton>
</Alert>
)}
</div>
)}
@@ -11,7 +11,12 @@ import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
import { TWorkspace } from "@formbricks/types/workspace";
import { TWorkspaceStyling, ZWorkspaceStyling } from "@formbricks/types/workspace";
import { previewSurvey } from "@/app/lib/templates";
import { COLOR_DEFAULTS, STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
import {
COLOR_DEFAULTS,
STYLE_DEFAULTS,
deriveNewFieldsFromLegacy,
getSuggestedColors,
} from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
@@ -62,8 +67,10 @@ export const ThemeStyling = ({
? Object.fromEntries(Object.entries(savedStyling).filter(([, v]) => v != null))
: {};
const legacyFills = deriveNewFieldsFromLegacy(cleanSaved);
const form = useForm<TWorkspaceStyling>({
defaultValues: { ...STYLE_DEFAULTS, ...cleanSaved },
defaultValues: { ...STYLE_DEFAULTS, ...legacyFills, ...cleanSaved },
resolver: zodResolver(ZWorkspaceStyling),
});
@@ -7,9 +7,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export const WorkspaceFeedbackSourcesPage = async (
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
) => {
export const WorkspaceSourcesPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const t = await getTranslate();
const params = await props.params;
+4 -4
View File
@@ -31,12 +31,12 @@ cube(`FeedbackRecords`, {
npsScore: {
type: `number`,
sql: `
CASE
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
(
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
/ COUNT(*)::numeric
) * 100,
2
@@ -131,7 +131,7 @@ cube(`FeedbackRecords`, {
cube(`TopicsUnnested`, {
sql: `
SELECT
SELECT
fr.id as feedback_record_id,
topic_elem.topic
FROM feedback_records fr
+5 -4
View File
@@ -89,10 +89,11 @@ services:
- 4001:4001 # Cube Playground UI (dev only)
environment:
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: ${CUBEJS_DB_HOST:-formbricks_hub_postgres}
CUBEJS_DB_NAME: ${CUBEJS_DB_NAME:-hub}
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-formbricks}
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-formbricks_dev}
# Default to the local postgres service used by this compose stack.
CUBEJS_DB_HOST: ${CUBEJS_DB_HOST:-postgres}
CUBEJS_DB_NAME: ${CUBEJS_DB_NAME:-postgres}
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-postgres}
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres}
CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432}
CUBEJS_DEV_MODE: "true"
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET}
+30
View File
@@ -5566,6 +5566,36 @@ components:
pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$
required:
- light
questionColor:
type:
- object
- "null"
properties:
light:
type: string
pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$
dark:
type:
- string
- "null"
pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$
required:
- light
inputColor:
type:
- object
- "null"
properties:
light:
type: string
pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$
dark:
type:
- string
- "null"
pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$
required:
- light
inputBorderColor:
type:
- object
@@ -0,0 +1,119 @@
# Question Bank - Linear Ticket Package (3 Scopes)
## Problem statement
Teams currently recreate the same survey questions across workspaces, which slows authors down, creates inconsistent wording, and makes reporting harder to standardize. We need a shared question bank at the organization level so authenticated creators can reuse high-quality questions quickly without breaking existing surveys.
## Success metrics
- Time to add a reusable question into a survey decreases by at least 50%.
- At least 40% of newly created surveys use one or more question bank entries within 60 days.
- Duplicate-question creation rate declines over time (measured by title/similarity heuristics).
- Permission failures are explicit and non-destructive (0 ambiguous auth errors in tracked flows).
## Locked auth model for all scopes
- Sharing boundary: all workspaces in the same organization.
- Publish eligibility: authenticated users with `readWrite` or `manage` in at least one workspace.
- Edit/delete: creator plus org `Owner/Manager`.
- Unpublish: org `Owner/Manager`.
- API keys: keep existing explicit workspace/org scopes (no owner-role inheritance change).
- Deprovisioned creators: ownership transfers to org admin group.
- Audit baseline: `createdBy`, `updatedBy`, timestamps.
---
## Ticket 1 - Scope 1 Core MVP
### Goal
Launch a usable organization-level question bank that supports the full core lifecycle and detached insertion into surveys.
### In scope
- Browse and insert global questions from survey creation/editing flow.
- Create and publish global questions with locked auth model.
- Edit/delete with creator + org admin override model.
- Unpublish with org admin permissions.
- Insert behavior is copy-detached (future question bank edits do not affect already inserted survey questions).
- Basic metadata in UI: title, category, creator, updated time.
- Empty state, unauthorized state, and no-results state messaging.
### Explicitly out of scope
- Approval workflows.
- Certified/locked question sets.
- Sensitive-category permission differences.
- Version history, diff, restore.
- Live-linked question sync into existing surveys.
### Acceptance criteria
- A user with `readWrite/manage` in at least one workspace can create and publish.
- A user without required workspace permission cannot publish and gets clear guidance.
- Creator can edit/delete own global questions.
- Org `Owner/Manager` can edit/delete/unpublish any global question.
- Inserting a question creates an independent survey copy that does not change on later bank edits.
- Unpublishing hides the question from new insertions but does not alter surveys that already copied it.
- All successful mutations capture attribution metadata (`createdBy`/`updatedBy` and timestamps).
### Scope 1 edge-case handling
- Creator loses workspace write access after publishing: question remains available; creator can no longer mutate unless still authorized by current rules.
- Creator is removed/deactivated: ownership is transferred to org admin group without content loss.
- Admin edits creator-owned content: latest updater attribution is visible.
- Cross-workspace discoverability remains consistent inside the same org.
---
## Ticket 2 - Scope 2 Adoption and Operational Quality
### Goal
Increase discoverability and operational reliability so the bank is easy to use at scale for both UI users and scoped API clients.
### In scope
- Search, category filtering, and sorting.
- API-key read/write support under current explicit workspace/org scope model.
- Ownership transfer flow for deprovisioned creators (admin stewardship).
- Basic moderation operations for org admins focused on unpublish/recoverability.
- Audit visibility in product UX (created by, last updated by, published status timestamps).
### Acceptance criteria
- Users can find bank questions by keyword, category, and sort order with predictable results.
- API key mutations respect explicit scope boundaries; out-of-scope writes are rejected clearly.
- Admins can view and complete ownership transfer for deprovisioned creators.
- Admin can recover previously unpublished items using a clear operational flow.
- Audit fields are visible and understandable in bank listing/detail surfaces.
### Scope 2 edge-case handling
- API key with partial workspace grants attempts org-wide mutation: request is denied with explicit reason.
- Large volume of near-duplicate questions: list remains navigable via filters and sort defaults.
- Simultaneous edits by creator and admin: user-facing result is deterministic and auditable.
---
## Ticket 3 - Scope 3 Governance and Enterprise Depth (Optional/Future)
### Goal
Introduce governance controls for organizations that need stronger quality gates and policy enforcement.
### In scope (optional controls)
- Approval gate before global publication.
- Curator workflow for elevated stewardship.
- Certified/locked question sets (or semi-locked variants).
- Sensitive-category restrictions (if compliance needs require).
- Extended versioning: history, fork, restore.
- Org policy controls for publishing standards and lifecycle rules.
### Acceptance criteria
- Governance controls are configurable per organization and can be rolled out progressively.
- Approval workflow supports clear pending/approved/rejected states.
- Certified or locked items are visibly distinct and enforce expected edit restrictions.
- Version history supports safe recovery without impacting existing detached survey copies.
- Policy changes are auditable and reversible.
### Scope 3 edge-case handling
- Approval backlog delays publication: pending state remains visible and actionable.
- Policy changes after content is already published: no retroactive corruption of survey copies.
- Governance disabled for some orgs: core flows continue without governance regressions.
---
## Prioritization rationale (Qualtrics-informed)
- Start with the minimum reusable-library primitives users expect: browse, search, insert, categorize.
- Keep insertion detached by default to prevent retroactive survey changes.
- Defer certified and strict governance controls until adoption data justifies added complexity.
Reference: [Qualtrics pre-made library questions](https://www.qualtrics.com/support/survey-platform/survey-module/editing-questions/question-types-guide/pre-made-qualtrics-library-questions/)
-1
View File
@@ -114,7 +114,6 @@
"icon": "mobile",
"pages": [
"xm-and-surveys/surveys/website-app-surveys/quickstart",
"xm-and-surveys/surveys/website-app-surveys/workspace-id-migration",
"xm-and-surveys/surveys/website-app-surveys/framework-guides",
"xm-and-surveys/surveys/website-app-surveys/google-tag-manager",
{
-14
View File
@@ -6,8 +6,6 @@ icon: "arrow-right"
## v5
**Rate Limit**
Formbricks v5 changes how rate limiting is enforced:
- several public and API-key routes are no longer rate-limited inside the application server
@@ -23,18 +21,6 @@ Formbricks v5 changes how rate limiting is enforced:
See the [rate-limiting guide](/self-hosting/advanced/rate-limiting) for the exact covered route groups, thresholds,
and the remaining app-enforced limits.
**Workspaces and Environment IDs**
Environment IDs were deprecated to simplify SDK setup around a single Workspace ID while keeping existing
integrations backward compatible during migration.
**Website & App Surveys SDK: Migrate to Workspace ID**
<Info>
Formbricks v5 also includes the Environment ID to Workspace ID transition for SDK setup. If you still rely on a
legacy Environment ID, read the [Migrate to Workspace ID guide](/xm-and-surveys/surveys/website-app-surveys/workspace-id-migration).
</Info>
## v4.7
Formbricks v4.7 introduces **typed contact attributes** with native `number` and `date` data types. This enables comparison-based segment filters (e.g. "signup date before 2025-01-01") that were previously not possible with string-only attribute values.
@@ -8,8 +8,6 @@ icon: "code"
These variables are present inside your machine's docker-compose file. Restart the docker containers if you change any variables for them to take effect.
For `AI_PROVIDER=google`, use a Gemini model ID such as `gemini-2.5-flash` together with Google Cloud credentials. Formbricks uses Google Cloud naming here, even though the underlying SDK still talks to Vertex AI endpoints for Gemini model access.
| Variable | Description | Required | Default |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| WEBAPP_URL | Base URL of the site. | required | http://localhost:3000 |
@@ -55,12 +53,12 @@ For `AI_PROVIDER=google`, use a Gemini model ID such as `gemini-2.5-flash` toget
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| AI_PROVIDER | Instance-level AI provider used in the background. Supported values: `aws`, `google`, `azure`. | optional (required if AI is enabled) | |
| AI_PROVIDER | Instance-level AI provider used in the background. Supported values: `aws`, `gcp`, `azure`. | optional (required if AI is enabled) | |
| AI_MODEL | Instance-level AI model or deployment name used by the active provider. | optional (required if `AI_PROVIDER` is set) | |
| AI_GOOGLE_CLOUD_PROJECT | Google Cloud project ID for the `google` AI provider. | optional (required if `AI_PROVIDER=google`) | |
| AI_GOOGLE_CLOUD_LOCATION | Google Cloud location for `google` AI requests. | optional (required if `AI_PROVIDER=google`) | |
| AI_GOOGLE_CLOUD_CREDENTIALS_JSON | Service account credentials JSON for the `google` AI provider. | optional (one of this or `AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS` required if `AI_PROVIDER=google`) | |
| AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS | Path to Google Application Default Credentials used by the `google` AI provider. | optional (one of this or `AI_GOOGLE_CLOUD_CREDENTIALS_JSON` required if `AI_PROVIDER=google`) | |
| AI_GCP_PROJECT | Google Cloud project ID for Vertex AI. | optional (required if `AI_PROVIDER=gcp`) | |
| AI_GCP_LOCATION | Google Cloud location for Vertex AI requests. | optional (required if `AI_PROVIDER=gcp`) | |
| AI_GCP_CREDENTIALS_JSON | Service account credentials JSON for Vertex AI. | optional (one of this or `AI_GCP_APPLICATION_CREDENTIALS` required if `AI_PROVIDER=gcp`) | |
| AI_GCP_APPLICATION_CREDENTIALS | Path to Google Application Default Credentials used for Vertex AI. | optional (one of this or `AI_GCP_CREDENTIALS_JSON` required if `AI_PROVIDER=gcp`) | |
| AI_AWS_REGION | AWS region for Amazon Bedrock. | optional (required if `AI_PROVIDER=aws`) | |
| AI_AWS_ACCESS_KEY_ID | AWS access key ID for Amazon Bedrock. | optional (required if `AI_PROVIDER=aws`) | |
| AI_AWS_SECRET_ACCESS_KEY | AWS secret access key for Amazon Bedrock. | optional (required if `AI_PROVIDER=aws`) | |
@@ -1,73 +0,0 @@
---
title: "Migrate to Workspace ID"
description: "Learn why Environment IDs were deprecated, what still works today, and how to migrate your SDK setup to Workspace IDs."
icon: "book"
---
Formbricks deprecated the legacy Environment ID for SDK setup and now uses **Workspace ID** as the primary identifier.
<Info>
Existing SDK integrations that still use `environmentId` continue to work for now. New and updated integrations
should use `workspaceId`.
</Info>
## Why This Changed
Formbricks moved from an environment-centric model to a workspace-centric model to simplify setup, reduce confusion,
and align product terminology across the app, SDKs, and API surfaces.
## Terms To Know
- **Workspace ID**: The canonical identifier for SDK setup and current product workflows.
- **Environment ID (legacy)**: A backward-compatible identifier that may still appear in older integrations.
## What Still Works
- SDK setups using `workspaceId` are the recommended and future-proof option.
- SDK setups using `environmentId` are still accepted during the migration period.
- If your app is already connected and sending data with a legacy Environment ID, no immediate outage is expected.
## Recommended Migration
<Steps>
<Step title="Open Website & App Connection">
In Formbricks, go to **Configuration → Website & App Connection**.
</Step>
<Step title="Copy Your Workspace ID">
Use the Workspace ID field as the canonical ID for all new SDK setup changes.
</Step>
<Step title="Update SDK Initialization">
Replace legacy `environmentId` config usage with `workspaceId` in your integration snippets.
</Step>
<Step title="Keep appUrl Unchanged">
Keep your existing `appUrl` value unless your hosting/domain setup changed.
</Step>
</Steps>
## SDK Example
```javascript
import formbricks from "@formbricks/js";
formbricks.setup({
workspaceId: "<your-workspace-id>",
appUrl: "<your-app-url>",
});
```
## FAQ
### Do I Need To Rotate IDs Immediately?
No. Legacy setups continue to work during the migration period. However, new implementations should always use
`workspaceId`.
### Do Test And Production Workflows Change?
No. Your development/testing and production workflows remain the same. This migration only changes the canonical
identifier you should use in SDK setup.
### Where Can I Find Detailed Setup Guides?
Use the [Framework Guides](https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides)
for framework-specific SDK instructions.
+31 -49
View File
@@ -45,9 +45,9 @@ describe("packages/ai provider helpers", () => {
test("resolves the active provider from the environment", () => {
expect(
getActiveAiProvider({
AI_PROVIDER: " google ",
AI_PROVIDER: " gcp ",
})
).toBe("google");
).toBe("gcp");
});
test("resolves the active model from the environment", () => {
@@ -58,23 +58,23 @@ describe("packages/ai provider helpers", () => {
).toBe("gemini-2.5-flash");
});
test("reports a fully configured Google Cloud instance when the active provider credentials and model are valid", () => {
test("reports a fully configured GCP instance when the active provider credentials and model are valid", () => {
expect(
getAiConfigurationStatus({
AI_PROVIDER: "google",
AI_PROVIDER: "gcp",
AI_MODEL: "gemini-2.5-flash",
AI_GOOGLE_CLOUD_PROJECT: "test-project",
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: JSON.stringify({ client_email: "google@example.com" }),
AI_GCP_PROJECT: "test-project",
AI_GCP_LOCATION: "us-central1",
AI_GCP_CREDENTIALS_JSON: JSON.stringify({ client_email: "vertex@example.com" }),
})
).toEqual({
provider: "google",
provider: "gcp",
model: "gemini-2.5-flash",
isConfigured: true,
missingFields: [],
invalidFields: [],
providerStatus: {
provider: "google",
provider: "gcp",
model: "gemini-2.5-flash",
isConfigured: true,
missingFields: [],
@@ -87,9 +87,9 @@ describe("packages/ai provider helpers", () => {
expect(
isAiConfigured({
AI_MODEL: "gemini-2.5-flash",
AI_GOOGLE_CLOUD_PROJECT: "test-project",
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: "/tmp/google-cloud.json",
AI_GCP_PROJECT: "test-project",
AI_GCP_LOCATION: "us-central1",
AI_GCP_APPLICATION_CREDENTIALS: "/tmp/vertex.json",
})
).toBe(false);
});
@@ -141,65 +141,47 @@ describe("packages/ai provider helpers", () => {
});
});
test("treats the instance as not configured when Google Cloud credentials JSON is invalid", () => {
test("treats the instance as not configured when GCP credentials JSON is invalid", () => {
expect(
getAiConfigurationStatus({
AI_PROVIDER: "google",
AI_PROVIDER: "gcp",
AI_MODEL: "gemini-2.5-flash",
AI_GOOGLE_CLOUD_PROJECT: "test-project",
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: "{not-json}",
AI_GCP_PROJECT: "test-project",
AI_GCP_LOCATION: "us-central1",
AI_GCP_CREDENTIALS_JSON: "{not-json}",
})
).toMatchObject({
provider: "google",
provider: "gcp",
model: "gemini-2.5-flash",
isConfigured: false,
invalidFields: ["AI_GOOGLE_CLOUD_CREDENTIALS_JSON"],
invalidFields: ["AI_GCP_CREDENTIALS_JSON"],
errorCode: "providerNotConfigured",
});
});
test("treats the instance as not configured when Google Cloud credentials JSON is not an object", () => {
expect(
getAiConfigurationStatus({
AI_PROVIDER: "google",
AI_MODEL: "gemini-2.5-flash",
AI_GOOGLE_CLOUD_PROJECT: "test-project",
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: "[]",
})
).toMatchObject({
provider: "google",
model: "gemini-2.5-flash",
isConfigured: false,
invalidFields: ["AI_GOOGLE_CLOUD_CREDENTIALS_JSON"],
errorCode: "providerNotConfigured",
});
});
test("creates and caches a Google Cloud model with parsed JSON credentials", () => {
const vertexProvider = createMockProvider("google");
test("creates and caches a GCP model with parsed JSON credentials", () => {
const vertexProvider = createMockProvider("gcp");
mocks.createVertex.mockReturnValue(vertexProvider);
const environment: AIEnvironment = {
AI_PROVIDER: "google",
AI_PROVIDER: "gcp",
AI_MODEL: "gemini-2.5-flash",
AI_GOOGLE_CLOUD_PROJECT: "test-project",
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: JSON.stringify({ client_email: "google@example.com" }),
AI_GCP_PROJECT: "test-project",
AI_GCP_LOCATION: "us-central1",
AI_GCP_CREDENTIALS_JSON: JSON.stringify({ client_email: "vertex@example.com" }),
};
const firstModel = getAiModel(environment);
const secondModel = getAiModel(environment);
expect(firstModel).toEqual({ providerName: "google", modelName: "gemini-2.5-flash" });
expect(firstModel).toEqual({ providerName: "gcp", modelName: "gemini-2.5-flash" });
expect(secondModel).toBe(firstModel);
expect(mocks.createVertex).toHaveBeenCalledWith({
project: "test-project",
location: "us-central1",
googleAuthOptions: {
credentials: {
client_email: "google@example.com",
client_email: "vertex@example.com",
},
},
});
@@ -252,10 +234,10 @@ describe("packages/ai provider helpers", () => {
test("throws a helpful error when the active model is missing", () => {
const getModel = (): ReturnType<typeof getAiModel> =>
getAiModel({
AI_PROVIDER: "google",
AI_GOOGLE_CLOUD_PROJECT: "test-project",
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: "/tmp/google-cloud.json",
AI_PROVIDER: "gcp",
AI_GCP_PROJECT: "test-project",
AI_GCP_LOCATION: "us-central1",
AI_GCP_APPLICATION_CREDENTIALS: "/tmp/vertex.json",
});
expect(getModel).toThrowError(AIConfigurationError);
+111
View File
@@ -0,0 +1,111 @@
import { createVertex } from "@ai-sdk/google-vertex";
import { AIConfigurationError } from "../errors";
import type { AIProviderAdapter } from "../registry";
import { normalizeValue } from "../shared";
import type { AIEnvironment } from "../types";
type VertexProviderSettings = NonNullable<Parameters<typeof createVertex>[0]>;
const parseVertexCredentialsJson = (value?: string | null): Record<string, unknown> | undefined => {
const normalizedValue = normalizeValue(value);
if (!normalizedValue) {
return undefined;
}
return JSON.parse(normalizedValue) as Record<string, unknown>;
};
export const gcpProviderAdapter: AIProviderAdapter = {
validate: (environment: AIEnvironment) => {
const missingFields: string[] = [];
const invalidFields: string[] = [];
if (!normalizeValue(environment.AI_GCP_PROJECT)) {
missingFields.push("AI_GCP_PROJECT");
}
if (!normalizeValue(environment.AI_GCP_LOCATION)) {
missingFields.push("AI_GCP_LOCATION");
}
const credentialsJson = normalizeValue(environment.AI_GCP_CREDENTIALS_JSON);
const applicationCredentials = normalizeValue(environment.AI_GCP_APPLICATION_CREDENTIALS);
if (!credentialsJson && !applicationCredentials) {
missingFields.push("AI_GCP_CREDENTIALS_JSON or AI_GCP_APPLICATION_CREDENTIALS");
}
if (credentialsJson) {
try {
parseVertexCredentialsJson(credentialsJson);
} catch {
invalidFields.push("AI_GCP_CREDENTIALS_JSON");
}
}
return {
missingFields,
invalidFields,
};
},
buildCacheKey: (model: string, environment: AIEnvironment) =>
JSON.stringify({
provider: "gcp",
model,
project: normalizeValue(environment.AI_GCP_PROJECT),
location: normalizeValue(environment.AI_GCP_LOCATION),
hasCredentialsJson: Boolean(normalizeValue(environment.AI_GCP_CREDENTIALS_JSON)),
hasApplicationCredentials: Boolean(normalizeValue(environment.AI_GCP_APPLICATION_CREDENTIALS)),
}),
createModel: (model: string, environment: AIEnvironment) => {
const project = normalizeValue(environment.AI_GCP_PROJECT);
const location = normalizeValue(environment.AI_GCP_LOCATION);
const credentialsJson = normalizeValue(environment.AI_GCP_CREDENTIALS_JSON);
const applicationCredentials = normalizeValue(environment.AI_GCP_APPLICATION_CREDENTIALS);
if (!project || !location || (!credentialsJson && !applicationCredentials)) {
throw new AIConfigurationError("providerNotConfigured", "GCP Vertex AI credentials are incomplete", {
provider: "gcp",
missingFields: [
...(!project ? ["AI_GCP_PROJECT"] : []),
...(!location ? ["AI_GCP_LOCATION"] : []),
...(!credentialsJson && !applicationCredentials
? ["AI_GCP_CREDENTIALS_JSON or AI_GCP_APPLICATION_CREDENTIALS"]
: []),
],
});
}
let googleAuthOptions: VertexProviderSettings["googleAuthOptions"] | undefined;
if (credentialsJson) {
try {
googleAuthOptions = {
credentials: parseVertexCredentialsJson(credentialsJson),
} as VertexProviderSettings["googleAuthOptions"];
} catch {
throw new AIConfigurationError(
"providerNotConfigured",
"AI_GCP_CREDENTIALS_JSON must be valid JSON",
{
provider: "gcp",
invalidFields: ["AI_GCP_CREDENTIALS_JSON"],
}
);
}
} else if (applicationCredentials) {
googleAuthOptions = {
keyFilename: applicationCredentials,
} as VertexProviderSettings["googleAuthOptions"];
}
const vertex = createVertex({
project,
location,
...(googleAuthOptions ? { googleAuthOptions } : {}),
});
return vertex(model);
},
};

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