Compare commits

..

1 Commits

Author SHA1 Message Date
Johannes
ea62125067 feat: enhance segment activity summary and settings in segment modal
- Added activity summary to the segment table and edit segment modal.
- Refactored segment settings tab for better readability and functionality.
- Updated segment activity tab to utilize the new activity summary structure.
- Improved data handling for active and inactive surveys in segment table data row container.
2026-03-20 16:53:05 +01:00
19 changed files with 1532 additions and 512 deletions

View File

@@ -6,11 +6,11 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
operationId: "uploadBulkContacts",
summary: "Upload Bulk Contacts",
description:
"Uploads contacts in bulk. This endpoint expects the bulk request shape: `contacts` must be an array, and each contact item must contain an `attributes` array of `{ attributeKey, value }` objects. Unlike `POST /management/contacts`, this endpoint does not accept a top-level `attributes` object. Each contact must include an `email` attribute in its `attributes` array, and that email must be valid.",
"Uploads contacts in bulk. Each contact in the payload must have an 'email' attribute present in their attributes array. The email attribute is mandatory and must be a valid email format. Without a valid email, the contact will be skipped during processing.",
requestBody: {
required: true,
description:
"The contacts to upload. Use the full nested bulk body shown in the example or cURL snippet: `{ environmentId, contacts: [{ attributes: [{ attributeKey: { key, name }, value }] }] }`. Each contact must include an `email` attribute inside its `attributes` array.",
"The contacts to upload. Each contact must include an 'email' attribute in their attributes array. The email is used as the unique identifier for the contact.",
content: {
"application/json": {
schema: ZContactBulkUploadRequest,

View File

@@ -6,13 +6,13 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
operationId: "createContact",
summary: "Create a contact",
description:
"Creates a single contact in the database. This endpoint expects a top-level `attributes` object. For bulk uploads, use `PUT /management/contacts/bulk`, which expects `contacts[].attributes[]` instead. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
"Creates a contact in the database. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
tags: ["Management API - Contacts"],
requestBody: {
required: true,
description:
"The single contact to create. Must include a top-level `attributes` object with an email attribute, and all attribute keys must already exist in the environment.",
"The contact to create. Must include an email attribute and all attribute keys must already exist in the environment.",
content: {
"application/json": {
schema: ZContactCreateRequest,

View File

@@ -15,23 +15,60 @@ import {
DialogTitle,
} from "@/modules/ui/components/dialog";
import { SegmentActivityTab } from "./segment-activity-tab";
import { TSegmentActivitySummary } from "./segment-activity-utils";
interface EditSegmentModalProps {
environmentId: string;
open: boolean;
setOpen: (open: boolean) => void;
currentSegment: TSegmentWithSurveyNames;
activitySummary: TSegmentActivitySummary;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
}
const SegmentSettingsTab = ({
contactAttributeKeys,
currentSegment,
environmentId,
isContactsEnabled,
isReadOnly,
segments,
setOpen,
}: Pick<
EditSegmentModalProps,
| "contactAttributeKeys"
| "currentSegment"
| "environmentId"
| "isContactsEnabled"
| "isReadOnly"
| "segments"
| "setOpen"
>) => {
if (!isContactsEnabled) {
return null;
}
return (
<SegmentSettings
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
initialSegment={currentSegment}
segments={segments}
setOpen={setOpen}
isReadOnly={isReadOnly}
/>
);
};
export const EditSegmentModal = ({
environmentId,
open,
setOpen,
currentSegment,
activitySummary,
contactAttributeKeys,
segments,
isContactsEnabled,
@@ -40,31 +77,24 @@ export const EditSegmentModal = ({
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(0);
const SettingsTab = () => {
if (isContactsEnabled) {
return (
<SegmentSettings
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
initialSegment={currentSegment}
segments={segments}
setOpen={setOpen}
isReadOnly={isReadOnly}
/>
);
}
return null;
};
const tabs = [
{
title: t("common.activity"),
children: <SegmentActivityTab currentSegment={currentSegment} />,
children: <SegmentActivityTab currentSegment={currentSegment} activitySummary={activitySummary} />,
},
{
title: t("common.settings"),
children: <SettingsTab />,
children: (
<SegmentSettingsTab
contactAttributeKeys={contactAttributeKeys}
currentSegment={currentSegment}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
segments={segments}
setOpen={setOpen}
/>
),
},
];

View File

@@ -5,15 +5,16 @@ import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { convertDateTimeStringShort } from "@/lib/time";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
import { TSegmentActivitySummary } from "./segment-activity-utils";
interface SegmentActivityTabProps {
currentSegment: TSegmentWithSurveyNames;
activitySummary: TSegmentActivitySummary;
}
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
export const SegmentActivityTab = ({ currentSegment, activitySummary }: SegmentActivityTabProps) => {
const { t } = useTranslation();
const { activeSurveys, inactiveSurveys } = currentSegment;
const { activeSurveys, inactiveSurveys } = activitySummary;
return (
<div className="grid grid-cols-3 pb-2">
@@ -22,20 +23,20 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
<Label className="text-slate-500">{t("common.active_surveys")}</Label>
{!activeSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{activeSurveys?.map((survey, index) => (
<p className="text-sm text-slate-900" key={index + survey}>
{survey}
</p>
{activeSurveys?.map((surveyName) => (
<div className="py-0.5" key={surveyName}>
<p className="text-sm text-slate-900">{surveyName}</p>
</div>
))}
</div>
<div>
<Label className="text-slate-500">{t("common.inactive_surveys")}</Label>
{!inactiveSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{inactiveSurveys?.map((survey, index) => (
<p className="text-sm text-slate-900" key={index + survey}>
{survey}
</p>
{inactiveSurveys?.map((surveyName) => (
<div className="py-0.5" key={surveyName}>
<p className="text-sm text-slate-900">{surveyName}</p>
</div>
))}
</div>
</div>

View File

@@ -0,0 +1,340 @@
import { describe, expect, test } from "vitest";
import { TBaseFilters, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
buildSegmentActivitySummary,
buildSegmentActivitySummaryFromSegments,
doesSegmentReferenceSegment,
getReferencingSegments,
} from "./segment-activity-utils";
const createSurvey = (overrides: Partial<TSurvey>): TSurvey =>
({
id: "survey_1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: "env_1",
status: "inProgress",
welcomeCard: {
enabled: false,
headline: {},
html: {},
fileUrl: "",
buttonLabel: {},
timeToFinish: false,
},
questions: [],
hiddenFields: { enabled: false, fieldIds: [] },
endings: [],
autoClose: null,
displayOption: "displayOnce",
displayPercentage: null,
recontactDays: null,
displayLimit: null,
delay: 0,
autoComplete: null,
triggers: [],
styling: null,
surveyClosedMessage: null,
segment: null,
segmentId: null,
projectOverwrites: null,
singleUse: null,
pin: null,
redirectUrl: null,
displayStatus: null,
displayCount: null,
languages: [],
showLanguageSwitch: false,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
recaptcha: null,
variables: [],
blocks: undefined,
followUps: [],
verifyEmailTemplateId: null,
...overrides,
}) as TSurvey;
const createSegment = (overrides: Partial<TSegment>): TSegment =>
({
id: "segment_1",
title: "Segment 1",
description: null,
isPrivate: false,
environmentId: "env_1",
createdAt: new Date(),
updatedAt: new Date(),
surveys: [],
filters: [],
...overrides,
}) as TSegment;
const createSegmentWithSurveyNames = (overrides: Partial<TSegmentWithSurveyNames>): TSegmentWithSurveyNames =>
({
...createSegment(overrides),
activeSurveys: [],
inactiveSurveys: [],
...overrides,
}) as TSegmentWithSurveyNames;
describe("segment activity utils", () => {
test("doesSegmentReferenceSegment returns true for nested segment filters", () => {
const filters: TBaseFilters = [
{
id: "group_1",
connector: null,
resource: [
{
id: "filter_1",
connector: null,
resource: {
id: "segment_filter_1",
root: {
type: "segment",
segmentId: "segment_target",
},
value: "segment_target",
qualifier: {
operator: "userIsNotIn",
},
},
},
],
},
];
expect(doesSegmentReferenceSegment(filters, "segment_target")).toBe(true);
expect(doesSegmentReferenceSegment(filters, "segment_other")).toBe(false);
});
test("getReferencingSegments excludes the current segment and returns only matching segments", () => {
const segments = [
createSegment({ id: "segment_target" }),
createSegment({
id: "segment_ref",
filters: [
{
id: "filter_1",
connector: null,
resource: {
id: "segment_filter_1",
root: {
type: "segment",
segmentId: "segment_target",
},
value: "segment_target",
qualifier: {
operator: "userIsIn",
},
},
},
],
}),
createSegment({
id: "segment_other",
filters: [
{
id: "filter_2",
connector: null,
resource: {
id: "attribute_filter_1",
root: {
type: "attribute",
contactAttributeKey: "plan",
},
value: "enterprise",
qualifier: {
operator: "equals",
},
},
},
],
}),
];
expect(getReferencingSegments(segments, "segment_target").map((segment) => segment.id)).toEqual([
"segment_ref",
]);
});
test("buildSegmentActivitySummary returns direct surveys grouped by status", () => {
const directSurveys = [
createSurvey({
id: "survey_direct",
name: "Direct Survey",
status: "inProgress",
}),
createSurvey({
id: "survey_draft",
name: "Draft Survey",
status: "draft",
}),
];
expect(buildSegmentActivitySummary(directSurveys, [])).toEqual({
activeSurveys: ["Direct Survey"],
inactiveSurveys: ["Draft Survey"],
});
});
test("buildSegmentActivitySummary includes indirect surveys when there is no direct match", () => {
const indirectSurveyGroups = [
{
segmentId: "segment_ref",
segmentTitle: "Referenced Segment",
surveys: [
createSurvey({
id: "survey_draft",
name: "Draft Survey",
status: "draft",
}),
],
},
];
expect(buildSegmentActivitySummary([], indirectSurveyGroups)).toEqual({
activeSurveys: [],
inactiveSurveys: ["Draft Survey"],
});
});
test("buildSegmentActivitySummary prefers direct surveys over indirect duplicates", () => {
const directSurveys = [
createSurvey({
id: "survey_shared",
name: "Shared Survey",
status: "inProgress",
}),
];
const indirectSurveyGroups = [
{
segmentId: "segment_ref",
segmentTitle: "Referenced Segment",
surveys: [
createSurvey({
id: "survey_shared",
name: "Shared Survey",
status: "inProgress",
}),
],
},
];
expect(buildSegmentActivitySummary(directSurveys, indirectSurveyGroups)).toEqual({
activeSurveys: ["Shared Survey"],
inactiveSurveys: [],
});
});
test("buildSegmentActivitySummary deduplicates indirect surveys referenced by multiple segments", () => {
const indirectSurveyGroups = [
{
segmentId: "segment_ref_1",
segmentTitle: "Referenced Segment 1",
surveys: [
createSurvey({
id: "survey_indirect",
name: "Indirect Survey",
status: "paused",
}),
],
},
{
segmentId: "segment_ref_2",
segmentTitle: "Referenced Segment 2",
surveys: [
createSurvey({
id: "survey_indirect",
name: "Indirect Survey",
status: "paused",
}),
],
},
];
expect(buildSegmentActivitySummary([], indirectSurveyGroups)).toEqual({
activeSurveys: [],
inactiveSurveys: ["Indirect Survey"],
});
});
test("buildSegmentActivitySummaryFromSegments merges direct and indirect surveys from segment table data", () => {
const currentSegment = createSegmentWithSurveyNames({
id: "segment_target",
activeSurveys: ["Direct Survey"],
inactiveSurveys: ["Paused Survey"],
});
const segments = [
currentSegment,
createSegmentWithSurveyNames({
id: "segment_ref",
title: "Referenced Segment",
activeSurveys: ["Indirect Survey"],
inactiveSurveys: ["Paused Survey"],
filters: [
{
id: "filter_1",
connector: null,
resource: {
id: "segment_filter_1",
root: {
type: "segment",
segmentId: "segment_target",
},
value: "segment_target",
qualifier: {
operator: "userIsIn",
},
},
},
],
}),
];
expect(buildSegmentActivitySummaryFromSegments(currentSegment, segments)).toEqual({
activeSurveys: ["Direct Survey", "Indirect Survey"],
inactiveSurveys: ["Paused Survey"],
});
});
test("buildSegmentActivitySummaryFromSegments includes indirect usage from private survey segments", () => {
const currentSegment = createSegmentWithSurveyNames({
id: "segment_target",
});
const privateReferencingSegment = createSegmentWithSurveyNames({
id: "segment_private_ref",
title: "Private Survey Segment",
isPrivate: true,
activeSurveys: ["Indirect Private Survey"],
filters: [
{
id: "filter_1",
connector: null,
resource: {
id: "segment_filter_1",
root: {
type: "segment",
segmentId: "segment_target",
},
value: "segment_target",
qualifier: {
operator: "userIsNotIn",
},
},
},
],
});
expect(
buildSegmentActivitySummaryFromSegments(currentSegment, [currentSegment, privateReferencingSegment])
).toEqual({
activeSurveys: ["Indirect Private Survey"],
inactiveSurveys: [],
});
});
});

View File

@@ -0,0 +1,99 @@
import { TBaseFilters, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
type TSurveySummary = Pick<TSurvey, "id" | "name" | "status">;
type TReferencingSegmentSurveyGroup = {
segmentId: string;
segmentTitle: string;
surveys: TSurveySummary[];
};
export type TSegmentActivitySummary = {
activeSurveys: string[];
inactiveSurveys: string[];
};
export const doesSegmentReferenceSegment = (filters: TBaseFilters, targetSegmentId: string): boolean => {
for (const filter of filters) {
const { resource } = filter;
if (Array.isArray(resource)) {
if (doesSegmentReferenceSegment(resource, targetSegmentId)) {
return true;
}
continue;
}
if (resource.root.type === "segment" && resource.root.segmentId === targetSegmentId) {
return true;
}
}
return false;
};
export const getReferencingSegments = (segments: TSegment[], targetSegmentId: string): TSegment[] =>
segments.filter(
(segment) =>
segment.id !== targetSegmentId && doesSegmentReferenceSegment(segment.filters, targetSegmentId)
);
export const buildSegmentActivitySummary = (
directSurveys: TSurveySummary[],
indirectSurveyGroups: TReferencingSegmentSurveyGroup[]
): TSegmentActivitySummary => {
const surveyMap = new Map<string, TSurveySummary>();
for (const survey of directSurveys) {
surveyMap.set(survey.id, survey);
}
for (const segment of indirectSurveyGroups) {
for (const survey of segment.surveys) {
if (!surveyMap.has(survey.id)) {
surveyMap.set(survey.id, survey);
}
}
}
const surveys = Array.from(surveyMap.values());
return {
activeSurveys: surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name),
inactiveSurveys: surveys
.filter((survey) => survey.status === "draft" || survey.status === "paused")
.map((survey) => survey.name),
};
};
export const buildSegmentActivitySummaryFromSegments = (
currentSegment: TSegmentWithSurveyNames,
segments: TSegmentWithSurveyNames[]
): TSegmentActivitySummary => {
const activeSurveys = new Set(currentSegment.activeSurveys);
const inactiveSurveys = new Set(currentSegment.inactiveSurveys);
const directSurveyNames = new Set([...activeSurveys, ...inactiveSurveys]);
const referencingSegments = getReferencingSegments(
segments,
currentSegment.id
) as TSegmentWithSurveyNames[];
for (const segment of referencingSegments) {
for (const surveyName of segment.activeSurveys) {
if (!directSurveyNames.has(surveyName)) {
activeSurveys.add(surveyName);
}
}
for (const surveyName of segment.inactiveSurveys) {
if (!directSurveyNames.has(surveyName)) {
inactiveSurveys.add(surveyName);
}
}
}
return {
activeSurveys: Array.from(activeSurveys),
inactiveSurveys: Array.from(inactiveSurveys),
};
};

View File

@@ -1,6 +1,7 @@
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment } from "@formbricks/types/segment";
import { getSurveysBySegmentId } from "@/lib/survey/service";
import { buildSegmentActivitySummary, getReferencingSegments } from "./segment-activity-utils";
import { SegmentTableDataRow } from "./segment-table-data-row";
type TSegmentTableDataRowProps = {
@@ -18,17 +19,28 @@ export const SegmentTableDataRowContainer = async ({
isContactsEnabled,
isReadOnly,
}: TSegmentTableDataRowProps) => {
const surveys = await getSurveysBySegmentId(currentSegment.id);
const directSurveys = await getSurveysBySegmentId(currentSegment.id);
const activeSurveys = surveys?.length
? surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name)
const activeSurveys = directSurveys?.length
? directSurveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name)
: [];
const inactiveSurveys = surveys?.length
? surveys.filter((survey) => ["draft", "paused"].includes(survey.status)).map((survey) => survey.name)
const inactiveSurveys = directSurveys?.length
? directSurveys
.filter((survey) => ["draft", "paused"].includes(survey.status))
.map((survey) => survey.name)
: [];
const filteredSegments = segments.filter((segment) => segment.id !== currentSegment.id);
const referencingSegments = getReferencingSegments(filteredSegments, currentSegment.id);
const indirectSurveyGroups = await Promise.all(
referencingSegments.map(async (segment) => ({
segmentId: segment.id,
segmentTitle: segment.title,
surveys: await getSurveysBySegmentId(segment.id),
}))
);
const activitySummary = buildSegmentActivitySummary(directSurveys, indirectSurveyGroups);
return (
<SegmentTableDataRow
@@ -37,6 +49,7 @@ export const SegmentTableDataRowContainer = async ({
activeSurveys,
inactiveSurveys,
}}
activitySummary={activitySummary}
segments={filteredSegments}
contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled}

View File

@@ -6,9 +6,11 @@ import { useState } from "react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { EditSegmentModal } from "./edit-segment-modal";
import { TSegmentActivitySummary } from "./segment-activity-utils";
type TSegmentTableDataRowProps = {
currentSegment: TSegmentWithSurveyNames;
activitySummary: TSegmentActivitySummary;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
@@ -17,6 +19,7 @@ type TSegmentTableDataRowProps = {
export const SegmentTableDataRow = ({
currentSegment,
activitySummary,
contactAttributeKeys,
segments,
isContactsEnabled,
@@ -62,6 +65,7 @@ export const SegmentTableDataRow = ({
open={isEditSegmentModalOpen}
setOpen={setIsEditSegmentModalOpen}
currentSegment={currentSegment}
activitySummary={activitySummary}
contactAttributeKeys={contactAttributeKeys}
segments={segments}
isContactsEnabled={isContactsEnabled}

View File

@@ -7,10 +7,12 @@ import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { EditSegmentModal } from "./edit-segment-modal";
import { buildSegmentActivitySummaryFromSegments } from "./segment-activity-utils";
import { generateSegmentTableColumns } from "./segment-table-columns";
interface SegmentTableUpdatedProps {
segments: TSegmentWithSurveyNames[];
allSegments: TSegmentWithSurveyNames[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
@@ -18,16 +20,17 @@ interface SegmentTableUpdatedProps {
export function SegmentTable({
segments,
allSegments,
contactAttributeKeys,
isContactsEnabled,
isReadOnly,
}: SegmentTableUpdatedProps) {
}: Readonly<SegmentTableUpdatedProps>) {
const { t } = useTranslation();
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
const columns = useMemo(() => {
return generateSegmentTableColumns(t);
}, []);
}, [t]);
const table = useReactTable({
data: segments,
@@ -136,6 +139,7 @@ export function SegmentTable({
open={!!editingSegment}
setOpen={(open) => !open && setEditingSegment(null)}
currentSegment={editingSegment}
activitySummary={buildSegmentActivitySummaryFromSegments(editingSegment, allSegments)}
contactAttributeKeys={contactAttributeKeys}
segments={segments}
isContactsEnabled={isContactsEnabled}

View File

@@ -47,6 +47,7 @@ export const SegmentsPage = async ({
upgradePromptTitle={t("environments.segments.unlock_segments_title")}
upgradePromptDescription={t("environments.segments.unlock_segments_description")}>
<SegmentTable
allSegments={segments}
segments={filteredSegments}
contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled}

View File

@@ -1386,22 +1386,17 @@ paths:
put:
operationId: uploadBulkContacts
summary: Upload Bulk Contacts
description: >-
Uploads contacts in bulk. This endpoint expects the bulk request
shape: `contacts` must be an array, and each contact item must contain
an `attributes` array of `{ attributeKey, value }` objects. Unlike `POST
/management/contacts`, this endpoint does not accept a top-level `attributes`
object. Each contact must include an `email` attribute in its `attributes`
array, and that email must be valid.
description: Uploads contacts in bulk. Each contact in the payload must have an
'email' attribute present in their attributes array. The email attribute
is mandatory and must be a valid email format. Without a valid email,
the contact will be skipped during processing.
tags:
- Management API - Contacts
requestBody:
required: true
description: >-
The contacts to upload. Use the full nested bulk body shown
in the example or cURL snippet: `{ environmentId, contacts: [{ attributes:
[{ attributeKey: { key, name }, value }] }] }`. Each contact must include
an `email` attribute inside its `attributes` array.
description: The contacts to upload. Each contact must include an 'email'
attribute in their attributes array. The email is used as the unique
identifier for the contact.
content:
application/json:
schema:
@@ -1525,19 +1520,16 @@ paths:
post:
operationId: createContact
summary: Create a contact
description: Creates a single contact in the database. This endpoint expects
a top-level `attributes` object. For bulk uploads, use `PUT /management/contacts/bulk`,
which expects `contacts[].attributes[]` instead. Each contact must have
a valid email address in the attributes. All attribute keys must already
exist in the environment. The email is used as the unique identifier along
description: Creates a contact in the database. Each contact must have a valid
email address in the attributes. All attribute keys must already exist
in the environment. The email is used as the unique identifier along
with the environment.
tags:
- Management API - Contacts
requestBody:
required: true
description: The single contact to create. Must include a top-level `attributes`
object with an email attribute, and all attribute keys must already exist
in the environment.
description: The contact to create. Must include an email attribute and all
attribute keys must already exist in the environment.
content:
application/json:
schema:

View File

@@ -0,0 +1,7 @@
---
title: "Multi-language Surveys"
description: "Survey respondents in multiple-languages."
icon: "language"
---
If you'd like to survey users in multiple languages while keeping all results in the same survey, you can make use of [Multi-language Surveys](/xm-and-surveys/surveys/general-features/multi-language-surveys#multi-language-surveys)

View File

@@ -77,7 +77,6 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
| Pin-protected surveys | ✅ | ✅ |
| Webhooks | ✅ | ✅ |
| Email follow-ups | ✅ | ✅ |
| Multi-language surveys | ✅ | ✅ |
| Multi-language UI | ✅ | ✅ |
| All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ |
| Domain Split Configuration | ✅ | ✅ |
@@ -86,6 +85,7 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
| Whitelabel email follow-ups | ❌ | ✅ |
| Teams & access roles | ❌ | ✅ |
| Contact management & segments | ❌ | ✅ |
| Multi-language surveys | ❌ | ✅ |
| Quota Management | ❌ | ✅ |
| Audit Logs | ❌ | ✅ |
| OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |

View File

@@ -4,6 +4,10 @@ description: "Create surveys that support multiple languages using translations.
icon: "language"
---
<Note>
Multi-language surveys are part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license)
</Note>
How to deliver a specific language depends on the survey type (app or link survey):
- App & Website survey: Set a `language` attribute for the user. [Read this guide for App Surveys](#app-surveys-configuration)

View File

@@ -73,6 +73,12 @@ Use the `setLanguage` function to set the user's preferred language for surveys.
formbricks.setLanguage("de"); // ISO identifier or Alias set when creating language
```
<Note>
If a user has a language assigned, a survey has multi-language activated and it is missing a translation in
the language of the user, the survey will not be displayed. Learn more about [Multi-language Surveys](/docs/xm-and-surveys/surveys/general-features/multi-language-surveys).
</Note>
### Logging Out Users
When a user logs out of your webpage, also log them out of Formbricks to prevent activity from being linked to the wrong user. Use the logout function:

View File

@@ -42,8 +42,7 @@
"generate-translations": "pnpm i18n:web:generate && pnpm i18n:surveys:generate",
"scan-translations": "pnpm --filter @formbricks/i18n-utils scan-translations",
"i18n": "pnpm generate-translations && pnpm scan-translations",
"i18n:validate": "pnpm scan-translations",
"dev:setup": "node scripts/setup-dev-env.mjs"
"i18n:validate": "pnpm scan-translations"
},
"dependencies": {
"react": "19.2.4",
@@ -83,22 +82,15 @@
},
"pnpm": {
"overrides": {
"@hono/node-server": "1.19.10",
"axios": "1.13.5",
"flatted": "3.4.2",
"hono": "4.12.4",
"@microsoft/api-extractor>minimatch": "10.2.4",
"axios": ">=1.12.2",
"node-forge": ">=1.3.2",
"rollup": "4.59.0",
"socket.io-parser": "4.2.6",
"tar": ">=7.5.11",
"typeorm": ">=0.3.26",
"undici": "7.24.0",
"fast-xml-parser": "5.5.7",
"fast-xml-parser": "5.4.2",
"diff": ">=8.0.3"
},
"comments": {
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316) - awaiting Prisma update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | fast-xml-parser (CVE-2026-25896/26278) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
},
"patchedDependencies": {
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"

View File

@@ -13,14 +13,14 @@
"clean": "rimraf .turbo node_modules dist"
},
"dependencies": {
"@react-email/components": "1.0.10",
"react-email": "5.2.10"
"@react-email/components": "1.0.9",
"react-email": "5.2.9"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@formbricks/types": "workspace:*",
"@react-email/preview-server": "5.2.10",
"@react-email/preview-server": "5.2.9",
"autoprefixer": "10.4.27",
"clsx": "2.1.1",
"postcss": "8.5.8",

1251
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,146 +0,0 @@
#!/usr/bin/env node
import { randomBytes } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..");
const examplePath = path.join(repoRoot, ".env.example");
const envPath = path.join(repoRoot, ".env");
const generatedSecretKeys = ["ENCRYPTION_KEY", "NEXTAUTH_SECRET", "CRON_SECRET"];
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const parseEnvValue = (rawValue) => {
const trimmedStartValue = rawValue.trimStart();
let normalizedValue = "";
let inSingleQuotes = false;
let inDoubleQuotes = false;
for (const char of trimmedStartValue) {
if (char === "'" && !inDoubleQuotes) {
inSingleQuotes = !inSingleQuotes;
} else if (char === '"' && !inSingleQuotes) {
inDoubleQuotes = !inDoubleQuotes;
} else if (char === "#" && !inSingleQuotes && !inDoubleQuotes) {
break;
}
normalizedValue += char;
}
const trimmedValue = normalizedValue.trim();
if (
(trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) ||
(trimmedValue.startsWith("'") && trimmedValue.endsWith("'"))
) {
return trimmedValue.slice(1, -1);
}
return trimmedValue;
};
const parseEnv = (content) => {
const entries = new Map();
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const separatorIndex = line.indexOf("=");
if (separatorIndex === -1) {
continue;
}
const key = line
.slice(0, separatorIndex)
.trim()
.replace(/^export\s+/, "");
const value = parseEnvValue(line.slice(separatorIndex + 1));
if (!key) {
continue;
}
entries.set(key, value);
}
return entries;
};
const replaceOrAppendEnvValue = (content, key, value) => {
const linePattern = new RegExp(`^(?:export\\s+)?${escapeRegExp(key)}\\s*=.*$`, "m");
const nextLine = `${key}=${value}`;
if (linePattern.test(content)) {
return content.replace(linePattern, nextLine);
}
const normalizedContent = content.endsWith("\n") ? content : `${content}\n`;
return `${normalizedContent}${nextLine}\n`;
};
const isValidEncryptionKey = (value) => value.length === 32 || /^[0-9a-fA-F]{64}$/.test(value);
const isMissingSecretValue = (key, value) => {
if (!value) {
return true;
}
if (key === "ENCRYPTION_KEY" && !isValidEncryptionKey(value)) {
return true;
}
return false;
};
if (!existsSync(examplePath)) {
console.error(`❌ Could not find template file at ${path.relative(repoRoot, examplePath)}.`);
process.exit(1);
}
const exampleContent = readFileSync(examplePath, "utf8");
let envContent = existsSync(envPath) ? readFileSync(envPath, "utf8") : exampleContent;
const initialEnvExists = existsSync(envPath);
const parsedEnv = parseEnv(envContent);
const changedKeys = [];
for (const key of generatedSecretKeys) {
const currentValue = parsedEnv.get(key);
if (isMissingSecretValue(key, currentValue)) {
const generatedValue = randomBytes(32).toString("hex");
envContent = replaceOrAppendEnvValue(envContent, key, generatedValue);
parsedEnv.set(key, generatedValue);
changedKeys.push(key);
}
}
if (!initialEnvExists || changedKeys.length > 0) {
writeFileSync(envPath, envContent, "utf8");
}
const relativeEnvPath = path.relative(repoRoot, envPath);
if (!initialEnvExists) {
console.log(`✅ Created ${relativeEnvPath} from .env.example.`);
} else {
console.log(` Using existing ${relativeEnvPath}.`);
}
if (changedKeys.length > 0) {
console.log(`🔐 Updated ${relativeEnvPath}: ${changedKeys.join(", ")}.`);
} else {
console.log(`${relativeEnvPath} already has all required generated secrets.`);
}
console.log("🚀 Development environment file is ready.");