mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 165c427b9f | |||
| 3869ebff51 | |||
| 628c558757 | |||
| e82e0c87a4 | |||
| f2f2defd10 | |||
| 128e94e4cf | |||
| ee0ea7caa6 | |||
| 52dc64ffd2 | |||
| bec3fa2dbd | |||
| 18a3c4f0f7 | |||
| 6c61afec2f |
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: csv-mapping-ui
|
||||
overview: "Simplify the unreleased CSV mapping flow by matching the Formbricks Survey connector’s 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
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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");
|
||||
}
|
||||
|
||||
+205
-31
@@ -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) {
|
||||
+1
-1
@@ -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 {
|
||||
+31
-3
@@ -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]));
|
||||
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+16
-12
@@ -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,
|
||||
|
||||
+24
-20
@@ -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>;
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -12,7 +12,6 @@ const selectWorkspace = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
legacyEnvironmentId: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
languages: true,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "このアンケートには質問がありません",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "В этом опросе нет вопросов",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "该调查没有任何问题",
|
||||
|
||||
@@ -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"])) {
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
listFeedbackRecords,
|
||||
retrieveFeedbackRecord,
|
||||
updateFeedbackRecord,
|
||||
type CreateFeedbackRecordResult,
|
||||
type HubFeedbackRecordResult,
|
||||
type ListFeedbackRecordsResult,
|
||||
} from "./service";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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/)
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user