Compare commits

..

32 Commits

Author SHA1 Message Date
pandeymangg 2dae889df6 feedback 2026-05-12 17:59:19 +05:30
pandeymangg 384c916ca0 fixes feedback 2026-05-12 15:00:07 +05:30
pandeymangg 0a43c7e91e Merge branch 'epic/v5' of https://github.com/formbricks/formbricks into fix/hub-feedback-object-error 2026-05-12 14:25:51 +05:30
pandeymangg 97b8f33d18 fixes the [Object, Object] value error and adds the proper way to store matrix and ranking questions feedback records 2026-05-12 14:23:14 +05:30
Dhruwang Jariwala 037b005d48 fix(charts): pie tooltip spacing + scroll to chart-name on empty save (ENG-914, ENG-916) (#7970) 2026-05-12 13:22:00 +05:30
Dhruwang Jariwala 6777b284b3 fix(a11y): large selects are not scrollable (#7963) 2026-05-12 12:08:38 +05:30
Anshuman Pandey c6282632e0 fix: fixes read only issues in feedback sources UI (#7974) 2026-05-12 10:20:30 +04:00
Dhruwang Jariwala f84c409bc4 feat: add beta badge to unify feedback navigation section (#7968) 2026-05-12 11:38:09 +05:30
Dhruwang Jariwala 98b475a2a4 fix(charts): time-range crash + sentiment/emotion/response-id cube column mismatches (ENG-907, ENG-906, ENG-915) (#7973) 2026-05-12 10:02:50 +04:00
Dhruwang c48474b943 chore(charts): drop sonar S6478 JSDoc on PieTooltipRow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:31:38 +05:30
Anshuman Pandey 3c0d1e3fd7 fix: fixes connector modal UI errors (#7971) 2026-05-11 16:40:35 +04:00
Bhagya Amarasinghe 1f7a496967 fix(helm): stop forcing TEI float16 dtype (#7967) 2026-05-11 17:24:57 +05:30
Dhruwang 99e378ae2e chore: drop verbose doc comments on ChartDialogFooter props
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:44:15 +05:30
Dhruwang c6e39c3103 refactor(charts): hoist pie tooltip formatter to module scope (sonar S6478)
Same pattern as CartesianChart's ChartTooltipRow — a module-level
component that calls useTranslation internally, plus a thin formatter
function passed to ChartTooltipContent. Resolves the nested-component
warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:41:18 +05:30
Dhruwang 19472ca9d3 chore: inline form onSubmit to avoid deprecated FormEvent type alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:37:20 +05:30
Dhruwang 43feff0009 fix(charts): inline error styling for empty chart-name on submit
Switch from the native browser tooltip to the in-app inline error pattern:
red Label, isInvalid Input border, and helper text below the field. The
onInvalid handler suppresses the default tooltip and scrolls + focuses the
field via event.currentTarget — no ref needed. Typing clears the error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:36:45 +05:30
Dhruwang 507cec6958 feat(charts): show translated message on empty chart-name validation
Browsers default to "Please fill in this field"; use the existing
workspace.analysis.charts.please_enter_chart_name key so the message
matches the toast we surface elsewhere. Clear custom validity on change
so the form unblocks once the user types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:27:52 +05:30
Dhruwang d874b65be9 refactor(charts): use native form validation for chart-name field (ENG-916)
Replace the imperative scrollIntoView + focus on Save click with a tiny
<form> wrapping the required Input. The Save button becomes type=submit
with form="create-chart-form", so the browser handles scroll, focus, and
the "Please fill in this field" tooltip natively when the name is empty.

ChartDialogFooter now accepts either onSaveClick (button mode) or formId
(submit-button mode). All sibling buttons in the footer are explicitly
type="button" so they don't accidentally submit the form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:25:17 +05:30
Dhruwang Jariwala c159af1a26 fix: format toast (ENG-893) (#7966) 2026-05-11 16:22:24 +05:30
Dhruwang 3ce2ef6cf4 fix(charts): scroll/focus chart-name input when save is blocked by empty name (ENG-916)
Clicking Save Chart with the name field empty fired a toast but left the
modal scrolled wherever the user was (e.g. on the chart preview at the
bottom), so they couldn't see what was wrong. Scroll the name field into
view and focus it before delegating to the existing save handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:20:57 +05:30
Dhruwang Jariwala baae6335c9 fix: validate workspace assignment on feedback directory create (ENG-900) (#7965)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-11 14:50:05 +04:00
Dhruwang 8e443db1f6 revert: drop YAxis integer-tick handling
The fractional-tick bar/line/area scenario isn't the bug we're shipping in
this PR. Keep only the pie tooltip spacing/formatting change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:13:40 +05:30
Dhruwang 18cd9afc2c fix(charts): space between value and name in pie tooltip
Returning [value, name] from the tooltip formatter rendered as two adjacent text nodes ("3Nps"). Return a fragment with an explicit space so it reads "3 Nps".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:08:22 +05:30
Dhruwang 680e1e1593 fix(charts): show integer y-axis ticks for COUNT measures and format pie tooltips
ENG-914

When a chart's measures are all whole numbers (e.g. COUNT grouped by a
dimension), Recharts was auto-generating fractional y-axis ticks like
0.5, 1.5, 2 — nonsensical for a discrete count. The pie tooltip also
rendered values via raw String() with no thousand separators.

- Add allValuesAreIntegers() + formatYAxisTick() to chart-utils.
- CartesianChart now passes allowDecimals={false} and a number-aware
  tickFormatter to YAxis when every measure value is an integer.
- ChartRenderer's pie tooltip routes the value through formatCellValue,
  matching the Cartesian tooltip behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:49:57 +05:30
Dhruwang 04bfdeef69 removed stale comment 2026-05-11 15:09:51 +05:30
Dhruwang 8a1db7b6aa reverted errorcode transform change 2026-05-11 15:08:58 +05:30
Dhruwang 1d22fe2da6 fix: surface translated inline form errors and prefer .at() over slice
- FormError ignored its children when an error was set, rendering the raw
  zod message instead of the translated text passed in. Prefer explicit
  children, fall back to the field error message.
- Use .at(-1) in getTranslatedFeedbackDirectoryError per SonarQube S7755.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:03:00 +05:30
Dhruwang d8a119712c revert: drop zero-workspace requirement on feedback directory create
Per Johannes on ENG-893: zero-workspace creation is acceptable — admins
move workspaces between directories and forcing at least one is an
arbitrary limitation. The unformatted-error fix
(getTranslatedFeedbackDirectoryError prefix stripping) is retained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:54:54 +05:30
Cursor Agent 50089eeca4 feat: add beta badge to unify feedback section
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-11 08:20:06 +00:00
Bhagya Amarasinghe d8fe832f5f fix(helm): stop forcing TEI float16 dtype 2026-05-11 13:24:49 +05:30
Dhruwang 1c904e2243 fix: require at least one workspace when creating a feedback directory
ENG-893

Why: A feedback directory with zero workspaces is useless — it groups
nothing. The create path allowed it, and any validation error that did
fire surfaced as a raw "field: CODE" string in the toast.

How:
- createFeedbackDirectory now rejects empty/missing workspaceIds with
  DIRECTORY_WORKSPACES_REQUIRED before touching the database.
- getTranslatedFeedbackDirectoryError strips the "<field>: " prefix that
  next-safe-action prepends to validation errors, so machine codes always
  map to a translated message.
- Add the new translation key across all 15 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:04:47 +05:30
Javi Aguilar 4dbecc2d58 fix/a11y-select-scroll 2026-05-11 05:52:55 +02:00
39 changed files with 963 additions and 168 deletions
@@ -43,6 +43,7 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -151,7 +152,17 @@ export const MainNavigation = ({
},
{
id: "unify-feedback",
name: t("workspace.unify.unify_feedback"),
name: (
<span className="inline-flex items-center gap-2">
<span>{t("workspace.unify.unify_feedback")}</span>
<Badge
text="Beta"
type="gray"
size="tiny"
className="normal-case text-[10px] font-semibold tracking-normal"
/>
</span>
),
items: [
{
name: t("workspace.unify.feedback_records"),
+7
View File
@@ -3529,6 +3529,12 @@ checksums:
workspace/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
workspace/unify/enter_value: 4f068bb59617975c1e546218373122cd
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
workspace/unify/error_connector_field_mapping_duplicate: 4e507ae4a99f53dfa75d849d32566bf2
workspace/unify/error_connector_formbricks_mapping_duplicate: 48524e22fa33bd0b2829f7aea49c711b
workspace/unify/error_connector_name_duplicate: 56a1a05212e87dff21261d1db90fdb11
workspace/unify/error_connector_name_required: b2d5f79f6126e23128d7bef0c1736ff2
workspace/unify/error_connector_questions_required: d7d8e388959ab83a9195ba63bebf6516
workspace/unify/error_connector_survey_required: 1f49086dfb874307aae1136e88c3d514
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
workspace/unify/feedback_directory: 156fa9957e1ee5eadf1f44226a2365e4
@@ -3576,6 +3582,7 @@ checksums:
workspace/unify/no_feedback_directory_linked_member_description: e799b17da03899cabd125c3cf672c10d
workspace/unify/no_feedback_directory_linked_title: b01a53d508aeeb1c8ad080ee07efcd04
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
workspace/unify/no_formbricks_surveys_available_description: 2218334806910168bdfb6f283f31b963
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
+81 -2
View File
@@ -386,11 +386,12 @@ describe("createConnectorWithMappings", () => {
);
});
test("throws InvalidInputError on unique constraint violation", async () => {
test("throws CONNECTOR_NAME_DUPLICATE on Connector name unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "name"] },
})
);
@@ -400,7 +401,43 @@ describe("createConnectorWithMappings", () => {
type: "formbricks_survey",
feedbackDirectoryId: FRD_ID,
})
).rejects.toThrow(InvalidInputError);
).rejects.toThrow(new InvalidInputError("CONNECTOR_NAME_DUPLICATE"));
});
test("throws CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE on mapping unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "connectorId", "surveyId", "elementId"] },
})
);
await expect(
createConnectorWithMappings(ENV_ID, {
name: "Dup mapping",
type: "formbricks_survey",
feedbackDirectoryId: FRD_ID,
})
).rejects.toThrow(new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE"));
});
test("throws CONNECTOR_FIELD_MAPPING_DUPLICATE on field mapping unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "connectorId", "sourceFieldId", "targetFieldId"] },
})
);
await expect(
createConnectorWithMappings(ENV_ID, {
name: "Dup field mapping",
type: "csv",
feedbackDirectoryId: FRD_ID,
})
).rejects.toThrow(new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE"));
});
test("throws DatabaseError on generic Prisma error", async () => {
@@ -526,6 +563,48 @@ describe("updateConnectorWithMappings", () => {
);
});
test("throws CONNECTOR_NAME_DUPLICATE on Connector name unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "name"] },
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "Dup" })).rejects.toThrow(
new InvalidInputError("CONNECTOR_NAME_DUPLICATE")
);
});
test("throws CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE on formbricks mapping unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "connectorId", "surveyId", "elementId"] },
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE")
);
});
test("throws CONNECTOR_FIELD_MAPPING_DUPLICATE on field mapping unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "connectorId", "sourceFieldId", "targetFieldId"] },
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE")
);
});
test("throws DatabaseError on generic Prisma error", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
+16 -1
View File
@@ -212,6 +212,18 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
const target = error.meta?.target;
const targetFields = Array.isArray(target) ? (target as string[]) : [];
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
return new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE");
}
if (targetFields.includes("sourceFieldId") || targetFields.includes("targetFieldId")) {
return new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE");
}
return new InvalidInputError("CONNECTOR_NAME_DUPLICATE");
};
export type TFormbricksMappingsInput = {
type: "formbricks_survey";
mappings: TConnectorFormbricksMappingCreateInput[];
@@ -284,7 +296,7 @@ export const createConnectorWithMappings = async (
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError(`Connector with name ${data.name} already exists`);
throw mapUniqueConstraintError(error);
}
throw new DatabaseError(error.message);
}
@@ -359,6 +371,9 @@ export const updateConnectorWithMappings = async (
return mapConnectorWithMappings(result);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw mapUniqueConstraintError(error);
}
if (error.code === PrismaErrorType.RecordDoesNotExist) {
throw new ResourceNotFoundError("Connector", connectorId);
}
+291 -32
View File
@@ -40,6 +40,8 @@ const mockSurvey = {
],
} as unknown as TSurvey;
const mockTenantId = "cmp2f6428000504la7iyh87h1";
const mockResponse = {
id: "resp-1",
createdAt: NOW,
@@ -84,13 +86,18 @@ describe("transformResponseToFeedbackRecords", () => {
test("returns empty array when response has no data", () => {
const emptyResponse = { ...mockResponse, data: null } as unknown as TResponse;
const result = transformResponseToFeedbackRecords(emptyResponse, mockSurvey, allMappings);
const result = transformResponseToFeedbackRecords(emptyResponse, mockSurvey, allMappings, mockTenantId);
expect(result).toEqual([]);
});
test("returns empty array when no mappings match the survey", () => {
const otherSurveyMappings = allMappings.map((m) => ({ ...m, surveyId: "other-survey" }));
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, otherSurveyMappings);
const result = transformResponseToFeedbackRecords(
mockResponse,
mockSurvey,
otherSurveyMappings,
mockTenantId
);
expect(result).toEqual([]);
});
@@ -100,7 +107,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-text": "" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result).toEqual([]);
});
@@ -113,14 +120,14 @@ describe("transformResponseToFeedbackRecords", () => {
createMapping({ elementId: "el-text", hubFieldType: "text" }),
createMapping({ elementId: "el-nps", hubFieldType: "nps" }),
];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].field_id).toBe("el-nps");
});
test("transforms text field correctly", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
source_type: "formbricks_survey",
@@ -137,7 +144,7 @@ describe("transformResponseToFeedbackRecords", () => {
test("transforms nps field correctly", () => {
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].value_number).toBe(9);
expect(result[0].field_type).toBe("nps");
@@ -145,28 +152,28 @@ describe("transformResponseToFeedbackRecords", () => {
test("transforms rating field correctly", () => {
const mappings = [createMapping({ elementId: "el-rating", hubFieldType: "rating" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].value_number).toBe(4);
});
test("transforms date field to ISO string", () => {
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].value_date).toBe(new Date("2026-01-15").toISOString());
});
test("transforms boolean field correctly", () => {
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].value_boolean).toBe(true);
});
test("transforms categorical (multi-select) field to comma-separated text", () => {
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].value_text).toBe("feat-a, feat-b");
});
@@ -175,13 +182,13 @@ describe("transformResponseToFeedbackRecords", () => {
const mappings = [
createMapping({ elementId: "el-text", hubFieldType: "text", customFieldLabel: "Custom Label" }),
];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result[0].field_label).toBe("Custom Label");
});
test("sets collected_at from response createdAt", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
expect(result[0].collected_at).toBe(NOW.toISOString());
});
@@ -189,7 +196,7 @@ describe("transformResponseToFeedbackRecords", () => {
const updatedAt = new Date("2026-02-25T10:00:00.000Z");
const response = { ...mockResponse, createdAt: undefined, updatedAt } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].collected_at).toBe(updatedAt.toISOString());
});
@@ -199,7 +206,7 @@ describe("transformResponseToFeedbackRecords", () => {
createdAt: "2026-02-26T10:00:00.000Z",
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].collected_at).toBe("2026-02-26T10:00:00.000Z");
});
@@ -209,28 +216,22 @@ describe("transformResponseToFeedbackRecords", () => {
expect(result[0].tenant_id).toBe("tenant-abc");
});
test("omits tenant_id when not provided", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
expect(result[0].tenant_id).toBeUndefined();
});
test("omits language when response language is 'default'", () => {
const response = { ...mockResponse, language: "default" } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].language).toBeUndefined();
});
test("omits user_id when contact has no userId", () => {
const response = { ...mockResponse, contact: null } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].user_id).toBeUndefined();
});
test("transforms all mappings in a single call", () => {
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings);
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings, mockTenantId);
expect(result).toHaveLength(6);
const fieldIds = result.map((r) => r.field_id);
expect(fieldIds).toEqual(["el-text", "el-nps", "el-rating", "el-date", "el-bool", "el-multi"]);
@@ -246,7 +247,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-bare": "some text" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-bare", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, survey, mappings);
const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId);
expect(result[0].field_label).toBe("Untitled");
});
@@ -257,7 +258,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-nps": "7" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_number).toBe(7);
});
@@ -267,7 +268,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-nps": "not-a-number" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_number).toBeUndefined();
});
@@ -277,7 +278,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-text": { nested: "value" } },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_text).toBe(JSON.stringify({ nested: "value" }));
});
@@ -287,7 +288,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-date": "not-a-date" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_date).toBeUndefined();
});
@@ -297,7 +298,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-bool": "1" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_boolean).toBe(true);
});
@@ -307,7 +308,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-bool": "false" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_boolean).toBe(false);
});
@@ -317,7 +318,7 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-text": ["a", "b", "c"] },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_text).toBe("a, b, c");
});
@@ -327,8 +328,266 @@ describe("transformResponseToFeedbackRecords", () => {
data: { "el-multi": "single-choice" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_text).toBe("single-choice");
});
test("JSON-stringifies object value for categorical field (matrix/ranking responses)", () => {
const response = {
...mockResponse,
data: { "el-multi": { row1: "col1", row2: "col2" } },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_text).toBe(JSON.stringify({ row1: "col1", row2: "col2" }));
expect(result[0].value_text).not.toBe("[object Object]");
});
test("creates a record for a ranking response (string array)", () => {
const response = {
...mockResponse,
data: { "el-multi": ["LabelA", "LabelB", "LabelC"] },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].field_id).toBe("el-multi");
expect(result[0].value_text).toBe("LabelA, LabelB, LabelC");
});
test("creates a record for an empty ranking response (empty array)", () => {
const response = {
...mockResponse,
data: { "el-multi": [] },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].value_text).toBe("");
});
test("JSON-stringifies object value for unknown hubFieldType (default branch)", () => {
const response = {
...mockResponse,
data: { "el-multi": { row1: "col1" } },
} as unknown as TResponse;
const mappings = [
createMapping({
elementId: "el-multi",
hubFieldType: "unknown-type" as TConnectorFormbricksMapping["hubFieldType"],
}),
];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
expect(result[0].value_text).toBe(JSON.stringify({ row1: "col1" }));
expect(result[0].value_text).not.toBe("[object Object]");
});
});
describe("matrix expansion", () => {
const matrixSurvey = {
id: "survey-1",
name: "Matrix Survey",
blocks: [
{
elements: [
{
id: "el-matrix",
type: "matrix",
headline: { default: "Rate each feature" },
rows: [
{ id: "row-1", label: { default: "Speed" } },
{ id: "row-2", label: { default: "Quality" } },
],
columns: [
{ id: "col-1", label: { default: "Good" } },
{ id: "col-2", label: { default: "Bad" } },
],
},
],
},
],
} as unknown as TSurvey;
test("emits one record per answered row with shared field_group_id", () => {
const response = {
id: "resp-matrix",
createdAt: NOW,
data: { "el-matrix": { Speed: "Good", Quality: "Bad" } },
language: "default",
contact: { userId: "user-42" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
expect(result).toHaveLength(2);
expect(result.every((r) => r.field_group_id === "el-matrix")).toBe(true);
expect(result.every((r) => r.field_group_label === "Rate each feature")).toBe(true);
expect(result.every((r) => r.submission_id === "resp-matrix")).toBe(true);
expect(result.every((r) => r.metadata?.question_type === "matrix")).toBe(true);
expect(result[0]).toMatchObject({
field_id: "el-matrix__row-1",
field_label: "Speed",
field_type: "categorical",
value_text: "Good",
});
expect(result[1]).toMatchObject({
field_id: "el-matrix__row-2",
field_label: "Quality",
value_text: "Bad",
});
});
test("skips matrix rows with empty cell value", () => {
const response = {
id: "resp-matrix-partial",
createdAt: NOW,
data: { "el-matrix": { Speed: "Good", Quality: "" } },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].field_id).toBe("el-matrix__row-1");
});
test("skips matrix rows whose label does not match any row choice", () => {
const response = {
id: "resp-matrix-stale",
createdAt: NOW,
data: { "el-matrix": { "Old Row Label": "Good", Quality: "Bad" } },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].field_id).toBe("el-matrix__row-2");
});
test("JSON-stringifies non-string matrix cell value (regression for ENG-891)", () => {
const cellObject = { a: 1 };
const response = {
id: "resp-matrix-obj",
createdAt: NOW,
data: { "el-matrix": { Speed: cellObject } },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
field_id: "el-matrix__row-1",
field_label: "Speed",
field_group_id: "el-matrix",
field_group_label: "Rate each feature",
metadata: { question_type: "matrix" },
value_text: JSON.stringify(cellObject),
});
expect(result[0].value_text).not.toBe("[object Object]");
});
test("emits no records for empty matrix response", () => {
const response = {
id: "resp-empty",
createdAt: NOW,
data: { "el-matrix": {} },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
expect(result).toEqual([]);
});
});
describe("ranking expansion", () => {
const rankingSurvey = {
id: "survey-1",
name: "Ranking Survey",
blocks: [
{
elements: [
{
id: "el-ranking",
type: "ranking",
headline: { default: "Rank these features" },
choices: [
{ id: "ch-1", label: { default: "Reports" } },
{ id: "ch-2", label: { default: "Dashboards" } },
{ id: "ch-3", label: { default: "Alerts" } },
],
},
],
},
],
} as unknown as TSurvey;
test("emits one record per ranked item with rank as value_number", () => {
const response = {
id: "resp-ranking",
createdAt: NOW,
data: { "el-ranking": ["Dashboards", "Reports", "Alerts"] },
language: "default",
contact: { userId: "user-42" },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, rankingSurvey, mappings, mockTenantId);
expect(result).toHaveLength(3);
expect(result.every((r) => r.field_group_id === "el-ranking")).toBe(true);
expect(result.every((r) => r.field_group_label === "Rank these features")).toBe(true);
expect(result.every((r) => r.field_type === "number")).toBe(true);
expect(result.every((r) => r.metadata?.question_type === "ranking")).toBe(true);
expect(result.every((r) => r.metadata?.total_items === 3)).toBe(true);
expect(result[0]).toMatchObject({
field_id: "el-ranking__ch-2",
field_label: "Dashboards",
value_number: 1,
});
expect(result[1]).toMatchObject({
field_id: "el-ranking__ch-1",
field_label: "Reports",
value_number: 2,
});
expect(result[2]).toMatchObject({
field_id: "el-ranking__ch-3",
field_label: "Alerts",
value_number: 3,
});
});
test("emits no records for empty ranking response", () => {
const response = {
id: "resp-empty",
createdAt: NOW,
data: { "el-ranking": [] },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, rankingSurvey, mappings, mockTenantId);
expect(result).toEqual([]);
});
test("skips ranking items whose label does not match any choice", () => {
const response = {
id: "resp-ranking-stale",
createdAt: NOW,
data: { "el-ranking": ["Reports", "Removed Option"] },
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })];
const result = transformResponseToFeedbackRecords(response, rankingSurvey, mappings, mockTenantId);
expect(result).toHaveLength(1);
expect(result[0].field_id).toBe("el-ranking__ch-1");
expect(result[0].value_number).toBe(1);
});
});
});
+137 -16
View File
@@ -1,8 +1,15 @@
import "server-only";
import { TConnectorFormbricksMapping, THubFieldType } from "@formbricks/types/connector";
import { TResponse, TResponseData, TResponseDataValue } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import type {
TSurveyElement,
TSurveyElementChoice,
TSurveyMatrixElement,
TSurveyMatrixElementChoice,
TSurveyRankingElement,
} from "@formbricks/types/surveys/elements";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
@@ -14,6 +21,18 @@ const getHeadlineFromElement = (element?: TSurveyElement): string => {
return getTextContent(raw) || "Untitled";
};
const getChoiceLabel = (choice: { label: TSurveyElementChoice["label"] }, language: string): string => {
return getTextContent(getLocalizedValue(choice.label, language));
};
const findChoiceByLabel = <T extends { id: string; label: TSurveyElementChoice["label"] }>(
choices: T[],
label: string,
language: string
): T | undefined => {
return choices.find((choice) => getChoiceLabel(choice, language) === label);
};
const toIsoTimestamp = (value: unknown): string | null => {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
@@ -83,16 +102,114 @@ const convertValueToHubFields = (
case "categorical":
if (typeof value === "string") return { value_text: value };
if (Array.isArray(value)) return { value_text: value.join(", ") };
if (typeof value === "object") return { value_text: JSON.stringify(value) };
return { value_text: String(value) };
default:
return { value_text: typeof value === "string" ? value : String(value) };
if (typeof value === "string") return { value_text: value };
if (Array.isArray(value)) return { value_text: value.join(", ") };
if (typeof value === "object") return { value_text: JSON.stringify(value) };
return { value_text: String(value) };
}
};
type BaseRecordFields = Pick<
FeedbackRecordCreateParams,
"collected_at" | "source_type" | "submission_id" | "tenant_id" | "source_id" | "source_name"
> & {
language?: string;
user_id?: string;
};
const buildBaseFields = (
response: TResponse,
survey: Pick<TSurvey, "id" | "name">,
tenantId: string
): BaseRecordFields => ({
collected_at: getCollectedAt(response),
source_type: "formbricks_survey",
submission_id: response.id,
tenant_id: tenantId,
source_id: survey.id,
source_name: survey.name,
...(response.language && response.language !== "default" ? { language: response.language } : {}),
...(response.contact?.userId ? { user_id: response.contact.userId } : {}),
});
const expandMatrixToRecords = (
element: TSurveyMatrixElement,
mapping: TConnectorFormbricksMapping,
value: TResponseDataValue,
baseFields: BaseRecordFields
): FeedbackRecordCreateParams[] => {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
const language = baseFields.language ?? "default";
const groupLabel = mapping.customFieldLabel || getHeadlineFromElement(element);
const records: FeedbackRecordCreateParams[] = [];
for (const [rowLabel, columnLabel] of Object.entries(value)) {
if (columnLabel === undefined || columnLabel === null || columnLabel === "") continue;
const row = findChoiceByLabel<TSurveyMatrixElementChoice>(element.rows, rowLabel, language);
if (!row) continue;
const valueFields = convertValueToHubFields(columnLabel as TResponseDataValue, mapping.hubFieldType);
records.push({
...baseFields,
field_id: `${element.id}__${row.id}`,
field_type: mapping.hubFieldType,
field_label: getChoiceLabel(row, "default"),
field_group_id: element.id,
field_group_label: groupLabel,
metadata: { question_type: "matrix" },
...valueFields,
});
}
return records;
};
const expandRankingToRecords = (
element: TSurveyRankingElement,
mapping: TConnectorFormbricksMapping,
value: TResponseDataValue,
baseFields: BaseRecordFields
): FeedbackRecordCreateParams[] => {
if (!Array.isArray(value) || value.length === 0) return [];
const language = baseFields.language ?? "default";
const groupLabel = mapping.customFieldLabel || getHeadlineFromElement(element);
const records: FeedbackRecordCreateParams[] = [];
value.forEach((itemLabel, index) => {
if (typeof itemLabel !== "string" || itemLabel === "") return;
const choice = findChoiceByLabel<TSurveyElementChoice>(element.choices, itemLabel, language);
if (!choice) return;
records.push({
...baseFields,
field_id: `${element.id}__${choice.id}`,
field_type: "number",
field_label: getChoiceLabel(choice, "default"),
field_group_id: element.id,
field_group_label: groupLabel,
metadata: { question_type: "ranking", total_items: value.length },
value_number: index + 1,
});
});
return records;
};
/**
* Transform a Formbricks survey response into FeedbackRecord payloads.
* Called from the pipeline handler when a response is created/finished.
*
* Matrix and ranking questions expand into one record per row/item, sharing a
* field_group_id so Hub analytics can aggregate across them.
*/
export function transformResponseToFeedbackRecords(
response: TResponse,
@@ -106,31 +223,35 @@ export function transformResponseToFeedbackRecords(
const surveyMappings = mappings.filter((m) => m.surveyId === survey.id);
const elements = getElementsFromBlocks(survey.blocks);
const elementMap = new Map(elements.map((el) => [el.id, el]));
const baseFields = buildBaseFields(response, survey, tenantId);
const feedbackRecords: FeedbackRecordCreateParams[] = [];
for (const mapping of surveyMappings) {
const value = extractResponseValue(responseData, mapping.elementId);
if (value === undefined || value === null || value === "") continue;
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(elementMap.get(mapping.elementId));
const element = elementMap.get(mapping.elementId);
if (element?.type === TSurveyElementTypeEnum.Matrix) {
feedbackRecords.push(...expandMatrixToRecords(element, mapping, value, baseFields));
continue;
}
if (element?.type === TSurveyElementTypeEnum.Ranking) {
feedbackRecords.push(...expandRankingToRecords(element, mapping, value, baseFields));
continue;
}
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(element);
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
const feedbackRecord = {
collected_at: getCollectedAt(response),
source_type: "formbricks_survey",
submission_id: response.id,
tenant_id: tenantId,
feedbackRecords.push({
...baseFields,
field_id: mapping.elementId,
field_type: mapping.hubFieldType,
source_id: survey.id,
source_name: survey.name,
field_label: fieldLabel,
...(response.language && response.language !== "default" ? { language: response.language } : {}),
...(response.contact?.userId ? { user_id: response.contact.userId } : {}),
...valueFields,
};
feedbackRecords.push(feedbackRecord as FeedbackRecordCreateParams);
});
}
return feedbackRecords;
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "Gib einen Namen für diese Quelle ein",
"enter_value": "Wert eingeben...",
"enum": "Aufzählung",
"error_connector_field_mapping_duplicate": "Doppelte Feldzuordnung für diese Quelle",
"error_connector_formbricks_mapping_duplicate": "Doppelte Fragenzuordnung für diese Quelle",
"error_connector_name_duplicate": "Eine Quelle mit diesem Namen existiert bereits",
"error_connector_name_required": "Quellenname ist erforderlich",
"error_connector_questions_required": "Wähle mindestens eine Frage aus",
"error_connector_survey_required": "Wähle eine Umfrage aus",
"failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden",
"feedback_date": "Aktuelles Datum",
"feedback_directory": "Feedback-Verzeichnis",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "Enter a name for this source",
"enter_value": "Enter value...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Duplicate field mapping for this source",
"error_connector_formbricks_mapping_duplicate": "Duplicate question mapping for this source",
"error_connector_name_duplicate": "A source with this name already exists",
"error_connector_name_required": "Source name is required",
"error_connector_questions_required": "Select at least one question",
"error_connector_survey_required": "Select a survey",
"failed_to_load_feedback_records": "Failed to load feedback records",
"feedback_date": "Current date",
"feedback_directory": "Feedback Directory",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "Introduce un nombre para este origen",
"enter_value": "Introduce un valor...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mapeo de campo duplicado para esta fuente",
"error_connector_formbricks_mapping_duplicate": "Mapeo de pregunta duplicado para esta fuente",
"error_connector_name_duplicate": "Ya existe una fuente con este nombre",
"error_connector_name_required": "El nombre de origen es obligatorio",
"error_connector_questions_required": "Selecciona al menos una pregunta",
"error_connector_survey_required": "Selecciona una encuesta",
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
"feedback_date": "Fecha actual",
"feedback_directory": "Directorio de feedback",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "Entrez un nom pour cette source",
"enter_value": "Saisir une valeur...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mappage de champ en double pour cette source",
"error_connector_formbricks_mapping_duplicate": "Mappage de question en double pour cette source",
"error_connector_name_duplicate": "Une source avec ce nom existe déjà",
"error_connector_name_required": "Le nom de la source est requis",
"error_connector_questions_required": "Sélectionnez au moins une question",
"error_connector_survey_required": "Sélectionnez une enquête",
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
"feedback_date": "Date actuelle",
"feedback_directory": "Répertoire de retours",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "Adj nevet ennek a forrásnak",
"enter_value": "Érték megadása...",
"enum": "felsorolás",
"error_connector_field_mapping_duplicate": "Duplikált mezőleképezés ehhez a forráshoz",
"error_connector_formbricks_mapping_duplicate": "Duplikált kérdésleképezés ehhez a forráshoz",
"error_connector_name_duplicate": "Már létezik forrás ezzel a névvel",
"error_connector_name_required": "A forrás neve kötelező",
"error_connector_questions_required": "Válasszon ki legalább egy kérdést",
"error_connector_survey_required": "Válasszon ki egy felmérést",
"failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat",
"feedback_date": "Aktuális dátum",
"feedback_directory": "Visszajelzési könyvtár",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "このソースの名前を入力",
"enter_value": "値を入力...",
"enum": "列挙型",
"error_connector_field_mapping_duplicate": "このソースのフィールドマッピングが重複しています",
"error_connector_formbricks_mapping_duplicate": "このソースの質問マッピングが重複しています",
"error_connector_name_duplicate": "この名前のソースは既に存在します",
"error_connector_name_required": "ソース名は必須です",
"error_connector_questions_required": "少なくとも1つの質問を選択してください",
"error_connector_survey_required": "アンケートを選択してください",
"failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました",
"feedback_date": "現在の日付",
"feedback_directory": "フィードバックディレクトリ",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "Voer een naam in voor deze bron",
"enter_value": "Voer waarde in...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Dubbele veldkoppeling voor deze bron",
"error_connector_formbricks_mapping_duplicate": "Dubbele vraagkoppeling voor deze bron",
"error_connector_name_duplicate": "Er bestaat al een bron met deze naam",
"error_connector_name_required": "Bronnaam is verplicht",
"error_connector_questions_required": "Selecteer minimaal één vraag",
"error_connector_survey_required": "Selecteer een enquête",
"failed_to_load_feedback_records": "Kan feedbackrecords niet laden",
"feedback_date": "Huidige datum",
"feedback_directory": "Feedbackmap",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "Digite um nome para esta origem",
"enter_value": "Digite o valor...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
"error_connector_name_duplicate": "Uma fonte com este nome já existe",
"error_connector_name_required": "O nome da fonte é obrigatório",
"error_connector_questions_required": "Selecione pelo menos uma pergunta",
"error_connector_survey_required": "Selecione uma pesquisa",
"failed_to_load_feedback_records": "Falha ao carregar registros de feedback",
"feedback_date": "Data atual",
"feedback_directory": "Diretório de Feedback",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "Introduz um nome para esta origem",
"enter_value": "Introduzir valor...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
"error_connector_name_duplicate": "Já existe uma origem com este nome",
"error_connector_name_required": "O nome da origem é obrigatório",
"error_connector_questions_required": "Seleciona pelo menos uma pergunta",
"error_connector_survey_required": "Seleciona um inquérito",
"failed_to_load_feedback_records": "Falha ao carregar registos de feedback",
"feedback_date": "Data atual",
"feedback_directory": "Diretório de Feedback",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "Introdu un nume pentru această sursă",
"enter_value": "Introdu valoarea...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mapare duplicată a câmpului pentru această sursă",
"error_connector_formbricks_mapping_duplicate": "Mapare duplicată a întrebării pentru această sursă",
"error_connector_name_duplicate": "Există deja o sursă cu acest nume",
"error_connector_name_required": "Numele sursei este obligatoriu",
"error_connector_questions_required": "Selectează cel puțin o întrebare",
"error_connector_survey_required": "Selectează un sondaj",
"failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback",
"feedback_date": "Data curentă",
"feedback_directory": "Director de Feedback",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "Введи имя для этого источника",
"enter_value": "Введите значение...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Дублирующееся сопоставление полей для этого источника",
"error_connector_formbricks_mapping_duplicate": "Дублирующееся сопоставление вопросов для этого источника",
"error_connector_name_duplicate": "Источник с таким именем уже существует",
"error_connector_name_required": "Необходимо указать название источника",
"error_connector_questions_required": "Выберите хотя бы один вопрос",
"error_connector_survey_required": "Выберите опрос",
"failed_to_load_feedback_records": "Не удалось загрузить отзывы",
"feedback_date": "Текущая дата",
"feedback_directory": "Директория обратной связи",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "Ange ett namn för denna källa",
"enter_value": "Ange värde...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Duplicerad fältmappning för denna källa",
"error_connector_formbricks_mapping_duplicate": "Duplicerad frågemappning för denna källa",
"error_connector_name_duplicate": "En källa med det här namnet finns redan",
"error_connector_name_required": "Källnamn krävs",
"error_connector_questions_required": "Välj minst en fråga",
"error_connector_survey_required": "Välj en undersökning",
"failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter",
"feedback_date": "Aktuellt datum",
"feedback_directory": "Feedback-katalog",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "Bu kaynak için bir ad girin",
"enter_value": "Değer girin...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Bu kaynak için yinelenen alan eşlemesi",
"error_connector_formbricks_mapping_duplicate": "Bu kaynak için yinelenen soru eşlemesi",
"error_connector_name_duplicate": "Bu isimde bir kaynak zaten mevcut",
"error_connector_name_required": "Kaynak adı gereklidir",
"error_connector_questions_required": "En az bir soru seçin",
"error_connector_survey_required": "Bir anket seçin",
"failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi",
"feedback_date": "Geçerli tarih",
"feedback_directory": "Geri Bildirim Dizini",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "为此来源输入名称",
"enter_value": "请输入值...",
"enum": "枚举",
"error_connector_field_mapping_duplicate": "此来源存在重复的字段映射",
"error_connector_formbricks_mapping_duplicate": "此来源存在重复的问题映射",
"error_connector_name_duplicate": "该名称的数据源已存在",
"error_connector_name_required": "数据源名称为必填项",
"error_connector_questions_required": "请至少选择一个问题",
"error_connector_survey_required": "请选择一个调查问卷",
"failed_to_load_feedback_records": "加载反馈记录失败",
"feedback_date": "当前日期",
"feedback_directory": "反馈目录",
+6
View File
@@ -3689,6 +3689,12 @@
"enter_name_for_source": "請輸入此來源的名稱",
"enter_value": "請輸入值……",
"enum": "enum",
"error_connector_field_mapping_duplicate": "此來源的欄位對應重複",
"error_connector_formbricks_mapping_duplicate": "此來源的問題對應重複",
"error_connector_name_duplicate": "已存在使用此名稱的來源",
"error_connector_name_required": "來源名稱為必填項目",
"error_connector_questions_required": "請至少選擇一個問題",
"error_connector_survey_required": "請選擇一個調查問卷",
"failed_to_load_feedback_records": "載入回饋紀錄失敗",
"feedback_date": "目前日期",
"feedback_directory": "意見回饋目錄",
@@ -6,7 +6,8 @@ import { Button } from "@/modules/ui/components/button";
import { DialogFooter } from "@/modules/ui/components/dialog";
interface ChartDialogFooterProps {
onSaveClick: () => void;
onSaveClick?: () => void;
formId?: string;
onAddToDashboardClick?: () => void;
isSaving: boolean;
saveLabel?: string;
@@ -15,6 +16,7 @@ interface ChartDialogFooterProps {
export function ChartDialogFooter({
onSaveClick,
formId,
onAddToDashboardClick,
isSaving,
saveLabel,
@@ -24,12 +26,16 @@ export function ChartDialogFooter({
return (
<DialogFooter>
{showAddToDashboard && onAddToDashboardClick && (
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
<Button variant="outline" type="button" 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}>
<Button
type={formId ? "submit" : "button"}
form={formId}
onClick={formId ? undefined : onSaveClick}
disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
</Button>
@@ -7,6 +7,7 @@ import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesia
import {
CHART_BRAND_DARK,
CHART_MEASURE_COLORS,
formatCellValue,
formatXAxisTick,
preparePieData,
} from "@/modules/ee/analysis/charts/lib/chart-utils";
@@ -20,6 +21,19 @@ const formatPieLabel = ({ name, percent }: { name: string; percent?: number }):
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
};
const PieTooltipRow = ({ value, name }: Readonly<{ value: unknown; name: string }>) => {
const { t } = useTranslation();
return (
<>
{formatCellValue(value)} {formatCubeColumnHeader(name, t)}
</>
);
};
const pieTooltipFormatter = (value: unknown, name: string | number) => (
<PieTooltipRow value={value} name={String(name)} />
);
interface ChartRendererProps {
chartType: TChartType;
data: TChartDataRow[];
@@ -165,13 +179,7 @@ export function ChartRenderer({ chartType, data, query }: Readonly<ChartRenderer
return <Cell key={uniqueKey} fill={colors[index] || CHART_BRAND_DARK} />;
})}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => [String(value), formatCubeColumnHeader(String(name), t)]}
/>
}
/>
<ChartTooltip content={<ChartTooltipContent formatter={pieTooltipFormatter} />} />
</PieChart>
</ChartContainer>
</div>
@@ -1,8 +1,9 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
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";
@@ -79,6 +80,8 @@ export function CreateChartView({
});
const chartPreviewRef = useRef<HTMLDivElement>(null);
const CREATE_CHART_FORM_ID = "create-chart-form";
const [chartNameError, setChartNameError] = useState<string | null>(null);
useEffect(() => {
if (chartData) {
@@ -136,17 +139,38 @@ export function CreateChartView({
<div className="grid gap-4">
{hasSelectedDirectory ? (
<>
<div className="space-y-2">
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
<form
id={CREATE_CHART_FORM_ID}
onSubmit={(event) => {
event.preventDefault();
setChartNameError(null);
return handleSaveChart();
}}
className="space-y-2">
<Label htmlFor="create-chart-name" className={cn(chartNameError && "text-red-500")}>
{t("workspace.analysis.charts.chart_name")}
</Label>
<Input
id="create-chart-name"
value={chartName}
onChange={(event) => setChartName(event.target.value)}
onChange={(event) => {
if (chartNameError) setChartNameError(null);
setChartName(event.target.value);
}}
onInvalid={(event) => {
// Suppress the browser tooltip and render our inline message instead.
event.preventDefault();
event.currentTarget.scrollIntoView({ behavior: "smooth", block: "center" });
event.currentTarget.focus();
setChartNameError(t("workspace.analysis.charts.please_enter_chart_name"));
}}
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
maxLength={255}
required
isInvalid={!!chartNameError}
/>
</div>
{chartNameError && <p className="text-sm text-red-500">{chartNameError}</p>}
</form>
{!isEditing && (
<>
@@ -212,7 +236,7 @@ export function CreateChartView({
{chartData && (
<ChartDialogFooter
onSaveClick={handleSaveChart}
formId={CREATE_CHART_FORM_ID}
isSaving={isSaving}
showAddToDashboard={false}
saveLabel={
@@ -64,10 +64,13 @@ export function buildCubeQuery(config: ChartBuilderState): TChartQuery {
timeDim.dateRange = config.timeDimension.dateRange;
} else if (Array.isArray(config.timeDimension.dateRange)) {
const [startDate, endDate] = config.timeDimension.dateRange;
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const formatDate = (date: Date | string) => {
// dateRange round-trips through JSON (saved chart → parseQueryToState), so the array
// elements may already be ISO strings — coerce before formatting.
const d = date instanceof Date ? date : new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
@@ -137,7 +140,12 @@ export function parseQueryToState(query: TChartQuery): Partial<ChartBuilderState
config.granularity = timeDim.granularity;
}
if (timeDim.dateRange) {
config.dateRange = timeDim.dateRange as TimeDimensionConfig["dateRange"];
if (typeof timeDim.dateRange === "string") {
config.dateRange = timeDim.dateRange;
} else if (Array.isArray(timeDim.dateRange) && timeDim.dateRange.length === 2) {
// Stored as [isoString, isoString]; lift back into Date objects for the date-picker UI.
config.dateRange = [new Date(timeDim.dateRange[0]), new Date(timeDim.dateRange[1])];
}
}
state.timeDimension = config;
}
@@ -289,6 +289,47 @@ describe("FeedbackDirectory Service", () => {
).rejects.toThrow(new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG"));
});
test("throws InvalidInputError when a workspace is already assigned to another active directory", async () => {
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce({
workspaceId: mockWorkspaceId1,
} as any);
await expect(
createFeedbackDirectory(mockOrganizationId, "Conflicting", [mockWorkspaceId1])
).rejects.toThrow(new InvalidInputError("WORKSPACE_ALREADY_ASSIGNED_TO_DIFFERENT_DIRECTORY"));
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
where: {
workspaceId: { in: [mockWorkspaceId1] },
feedbackDirectory: { isArchived: false },
},
select: { workspaceId: true },
});
expect(prisma.feedbackDirectory.create).not.toHaveBeenCalled();
});
test("allows creation when workspace is only assigned to archived directory", async () => {
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.feedbackDirectory.create).mockResolvedValueOnce({
id: mockDirectoryId,
} as any);
const result = await createFeedbackDirectory(mockOrganizationId, "ArchivedOnly", [mockWorkspaceId1]);
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
where: {
workspaceId: { in: [mockWorkspaceId1] },
feedbackDirectory: { isArchived: false },
},
select: { workspaceId: true },
});
expect(prisma.feedbackDirectory.create).toHaveBeenCalled();
expect(result).toBe(mockDirectoryId);
});
test("throws InvalidInputError on duplicate name (unique constraint violation)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
@@ -279,6 +279,7 @@ export const createFeedbackDirectory = async (
if (count !== workspaceIds.length) {
throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG");
}
await assertWorkspacesNotAssignedElsewhere(undefined, workspaceIds);
}
const directory = await prisma.feedbackDirectory.create({
@@ -440,9 +441,12 @@ const pauseConnectorsInWorkspaces = async (
* Enforces the single-active-FRD-per-workspace invariant. The client UI prevents
* assigning a workspace to multiple active directories, but the server must also
* reject such payloads to keep this guarantee under direct API access.
*
* Pass `directoryId` when updating an existing directory to exclude it from the
* conflict check. Omit it on create every active directory is a conflict.
*/
const assertWorkspacesNotAssignedElsewhere = async (
directoryId: string,
directoryId: string | undefined,
workspaceIds: string[]
): Promise<void> => {
if (workspaceIds.length === 0) return;
@@ -450,7 +454,7 @@ const assertWorkspacesNotAssignedElsewhere = async (
const conflicting = await prisma.feedbackDirectoryWorkspace.findFirst({
where: {
workspaceId: { in: workspaceIds },
feedbackDirectoryId: { not: directoryId },
...(directoryId === undefined ? {} : { feedbackDirectoryId: { not: directoryId } }),
feedbackDirectory: { isArchived: false },
},
select: { workspaceId: true },
@@ -17,7 +17,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { TFieldMapping, TUnifySurvey } from "../types";
import { TFieldMapping, TUnifySurvey, getTranslatedConnectorError } from "../types";
import { ConnectorsTable } from "./connectors-table";
import { CreateConnectorModal } from "./create-connector-modal";
import { CsvImportModal } from "./csv-import-modal";
@@ -28,6 +28,7 @@ interface ConnectorsSectionProps {
initialConnectors: TConnectorWithMappings[];
initialSurveys: TUnifySurvey[];
directories: { id: string; name: string }[];
isReadOnly: boolean;
}
export function ConnectorsSection({
@@ -35,6 +36,7 @@ export function ConnectorsSection({
initialConnectors,
initialSurveys,
directories,
isReadOnly,
}: Readonly<ConnectorsSectionProps>) {
const { t } = useTranslation();
const router = useRouter();
@@ -78,7 +80,7 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return undefined;
}
@@ -93,7 +95,7 @@ export function ConnectorsSection({
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => {
}): Promise<boolean> => {
const result = await updateConnectorWithMappingsAction({
connectorId: data.connectorId,
workspaceId: workspaceId,
@@ -111,19 +113,20 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return false;
}
toast.success(t("workspace.unify.connector_updated_successfully"));
router.refresh();
return true;
};
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
const result = await deleteConnectorAction({ connectorId, workspaceId: workspaceId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return;
}
@@ -138,7 +141,7 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return;
}
@@ -155,7 +158,7 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return;
}
@@ -170,11 +173,15 @@ export function ConnectorsSection({
<SettingsCard
title={t("workspace.unify.feedback_sources")}
description={t("workspace.unify.feedback_sources_settings_description")}
buttonInfo={{
text: t("workspace.unify.add_source"),
onClick: () => setIsCreateModalOpen(true),
variant: "default",
}}>
buttonInfo={
isReadOnly
? undefined
: {
text: t("workspace.unify.add_source"),
onClick: () => setIsCreateModalOpen(true),
variant: "default",
}
}>
<ConnectorsTable
connectors={initialConnectors}
onConnectorClick={setEditingConnector}
@@ -183,15 +190,18 @@ export function ConnectorsSection({
onToggleStatus={handleToggleStatus}
onDelete={handleDeleteConnector}
isLoading={false}
isReadOnly={isReadOnly}
/>
{directories.length > 0 && (
<Alert size="small" className="mt-4">
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
<AlertButton asChild>
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
{t("workspace.unify.manage_directories")}
</Link>
</AlertButton>
{!isReadOnly && (
<AlertButton asChild>
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
{t("workspace.unify.manage_directories")}
</Link>
</AlertButton>
)}
</Alert>
)}
</SettingsCard>
@@ -208,6 +218,7 @@ export function ConnectorsSection({
<EditConnectorModal
connector={editingConnector}
isReadOnly={isReadOnly}
open={editingConnector !== null}
onOpenChange={(open) => !open && setEditingConnector(null)}
onUpdateConnector={handleUpdateConnector}
@@ -37,6 +37,7 @@ interface ConnectorsTableDataRowProps {
onDuplicate: () => Promise<void>;
onToggleStatus: () => Promise<void>;
onDelete: () => Promise<void>;
isReadOnly?: boolean;
}
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
@@ -52,10 +53,11 @@ export function ConnectorsTableDataRow({
onDuplicate,
onToggleStatus,
onDelete,
isReadOnly = false,
}: Readonly<ConnectorsTableDataRowProps>) {
const { t, i18n } = useTranslation();
const handleRowClick = () => {
if (connector.type === "csv" && onCsvImport) {
if (!isReadOnly && connector.type === "csv" && onCsvImport) {
onCsvImport();
return;
}
@@ -93,10 +95,10 @@ export function ConnectorsTableDataRow({
title={t(getConnectorTypeLabelKey(connector.type))}>
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
</div>
<div className="col-span-5 flex items-center">
<div className="col-span-4 flex items-center">
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
</div>
<div className="col-span-1 hidden items-center justify-center sm:flex">
<div className="col-span-2 hidden items-center justify-center sm:flex">
<Badge
text={getStatusLabel(connector.status, connector.type)}
type={STATUS_BADGE_TYPE[connector.status]}
@@ -110,14 +112,16 @@ export function ConnectorsTableDataRow({
<span className="truncate">{connector.creatorName ?? "—"}</span>
</div>
<div className="col-span-1 flex items-center justify-end pr-2">
<ConnectorRowDropdown
connector={connector}
onEdit={onEdit}
onCsvImport={onCsvImport}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
{!isReadOnly && (
<ConnectorRowDropdown
connector={connector}
onEdit={onEdit}
onCsvImport={onCsvImport}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
)}
</div>
</div>
);
@@ -9,6 +9,7 @@ interface ConnectorsTableRowsContainerProps {
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
onDelete: (connectorId: string) => Promise<void>;
isReadOnly?: boolean;
}
export const ConnectorsTableRowsContainer = ({
@@ -18,6 +19,7 @@ export const ConnectorsTableRowsContainer = ({
onDuplicate,
onToggleStatus,
onDelete,
isReadOnly = false,
}: ConnectorsTableRowsContainerProps) => {
const { t } = useTranslation();
@@ -40,6 +42,7 @@ export const ConnectorsTableRowsContainer = ({
onDuplicate={() => onDuplicate(connector)}
onToggleStatus={() => onToggleStatus(connector)}
onDelete={() => onDelete(connector.id)}
isReadOnly={isReadOnly}
/>
))}
</div>
@@ -13,6 +13,7 @@ interface ConnectorsTableProps {
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
onDelete: (connectorId: string) => Promise<void>;
isLoading?: boolean;
isReadOnly?: boolean;
}
export function ConnectorsTable({
@@ -23,6 +24,7 @@ export function ConnectorsTable({
onToggleStatus,
onDelete,
isLoading = false,
isReadOnly = false,
}: Readonly<ConnectorsTableProps>) {
const { t } = useTranslation();
@@ -30,8 +32,8 @@ export function ConnectorsTable({
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">{t("common.type")}</div>
<div className="col-span-5">{t("common.name")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
<div className="col-span-4">{t("common.name")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.status")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
<div className="col-span-1" />
@@ -48,6 +50,7 @@ export function ConnectorsTable({
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
isReadOnly={isReadOnly}
/>
)}
</div>
@@ -49,6 +49,7 @@ import {
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
getTranslatedConnectorError,
} from "../types";
import {
TConnectorOptionId,
@@ -339,7 +340,12 @@ export const CreateConnectorModal = ({
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
});
if (connectorId && values.importHistorical) {
if (!connectorId) {
setIsCreating(false);
return;
}
if (values.importHistorical) {
await handleHistoricalImport(connectorId, values.surveyId);
}
@@ -368,7 +374,12 @@ export const CreateConnectorModal = ({
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
if (connectorId && csvParsedData.length > 0) {
if (!connectorId) {
setIsCreating(false);
return;
}
if (csvParsedData.length > 0) {
await handleCsvImport(connectorId);
}
@@ -426,11 +437,13 @@ export const CreateConnectorModal = ({
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<form
className="space-y-4"
onSubmit={formbricksForm.handleSubmit(handleCreateFormbricksConnector)}>
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
@@ -440,7 +453,9 @@ export const CreateConnectorModal = ({
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -450,7 +465,7 @@ export const CreateConnectorModal = ({
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
@@ -467,7 +482,9 @@ export const CreateConnectorModal = ({
</SelectContent>
</Select>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -475,7 +492,7 @@ export const CreateConnectorModal = ({
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
render={({ fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
@@ -487,7 +504,9 @@ export const CreateConnectorModal = ({
/>
</div>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -38,6 +38,7 @@ import {
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
getTranslatedConnectorError,
} from "../types";
import {
areAllRequiredFieldsMapped,
@@ -59,9 +60,10 @@ interface EditConnectorModalProps {
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<void>;
}) => Promise<boolean>;
surveys: TUnifySurvey[];
onOpenCsvImport?: () => void;
isReadOnly?: boolean;
}
export const EditConnectorModal = ({
@@ -71,6 +73,7 @@ export const EditConnectorModal = ({
onUpdateConnector,
surveys,
onOpenCsvImport,
isReadOnly = false,
}: EditConnectorModalProps) => {
const { t } = useTranslation();
const [csvConnectorName, setCsvConnectorName] = useState("");
@@ -174,7 +177,7 @@ export const EditConnectorModal = ({
const handleUpdateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
if (connector?.type !== "formbricks_survey") return;
setIsUpdating(true);
await onUpdateConnector({
const success = await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: values.sourceName.trim(),
@@ -182,13 +185,15 @@ export const EditConnectorModal = ({
fieldMappings: undefined,
});
setIsUpdating(false);
handleOpenChange(false);
if (success) {
handleOpenChange(false);
}
};
const handleUpdateCsvConnector = async () => {
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
setIsUpdating(true);
await onUpdateConnector({
const success = await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: csvConnectorName.trim(),
@@ -196,7 +201,9 @@ export const EditConnectorModal = ({
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
setIsUpdating(false);
handleOpenChange(false);
if (success) {
handleOpenChange(false);
}
};
const handleFormbricksQuestionToggle = (questionId: string) => {
@@ -239,11 +246,13 @@ export const EditConnectorModal = ({
<div className="space-y-4 py-4">
{connector.type === "formbricks_survey" ? (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<form
className="space-y-4"
onSubmit={formbricksForm.handleSubmit(handleUpdateFormbricksConnector)}>
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
@@ -251,9 +260,12 @@ export const EditConnectorModal = ({
value={field.value}
onChange={field.onChange}
placeholder={t("workspace.unify.enter_name_for_source")}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -261,7 +273,7 @@ export const EditConnectorModal = ({
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
@@ -281,7 +293,9 @@ export const EditConnectorModal = ({
</SelectContent>
</Select>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -289,19 +303,21 @@ export const EditConnectorModal = ({
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
render={({ fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
<div>
<fieldset className={isReadOnly ? "opacity-70" : undefined} disabled={isReadOnly}>
<FormbricksQuestionList
survey={selectedSurvey}
selectedQuestionIds={selectedQuestionIds}
onQuestionToggle={handleFormbricksQuestionToggle}
/>
</div>
</fieldset>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -328,41 +344,54 @@ export const EditConnectorModal = ({
value={csvConnectorName}
onChange={(event) => setCsvConnectorName(event.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
disabled={isReadOnly}
/>
</div>
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
<fieldset
disabled={isReadOnly}
className={`max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4 ${
isReadOnly ? "opacity-70" : ""
}`}>
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
connectorType={connector.type}
/>
</div>
</fieldset>
</>
)}
</div>
<DialogFooter>
{connector.type === "csv" && (
<Button
variant="secondary"
onClick={() => {
handleOpenChange(false);
onOpenCsvImport?.();
}}>
{t("workspace.unify.import_feedback")}
{isReadOnly ? (
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
{t("common.close")}
</Button>
) : (
<>
{connector.type === "csv" && (
<Button
variant="secondary"
onClick={() => {
handleOpenChange(false);
onOpenCsvImport?.();
}}>
{t("workspace.unify.import_feedback")}
</Button>
)}
<Button
onClick={
connector.type === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
: handleUpdateCsvConnector
}
disabled={saveChangesDisabled}>
{t("workspace.unify.save_changes")}
</Button>
</>
)}
<Button
onClick={
connector.type === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
: handleUpdateCsvConnector
}
disabled={saveChangesDisabled}>
{t("workspace.unify.save_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -17,8 +17,16 @@ export const WorkspaceFeedbackSourcesPage = async (
const t = await getTranslate();
const params = await props.params;
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session, organization } =
await getWorkspaceAuth(params.workspaceId);
const {
isOwner,
isManager,
hasReadAccess,
hasReadWriteAccess,
hasManageAccess,
isReadOnly,
session,
organization,
} = await getWorkspaceAuth(params.workspaceId);
if (!session) {
throw new Error(t("common.session_not_found"));
@@ -72,6 +80,7 @@ export const WorkspaceFeedbackSourcesPage = async (
initialConnectors={connectors}
initialSurveys={unifySurveys}
directories={directories}
isReadOnly={isReadOnly}
/>
);
};
@@ -220,10 +220,29 @@ export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSc
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),
sourceName: z.string().trim().min(1, "CONNECTOR_NAME_REQUIRED"),
surveyId: z.string().min(1, "CONNECTOR_SURVEY_REQUIRED"),
selectedQuestionIds: z.array(z.string()).min(1, "CONNECTOR_QUESTIONS_REQUIRED"),
importHistorical: z.boolean(),
});
export type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
export const getTranslatedConnectorError = (errorCode: string, t: TFunction): string => {
switch (errorCode) {
case "CONNECTOR_NAME_DUPLICATE":
return t("workspace.unify.error_connector_name_duplicate");
case "CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE":
return t("workspace.unify.error_connector_formbricks_mapping_duplicate");
case "CONNECTOR_FIELD_MAPPING_DUPLICATE":
return t("workspace.unify.error_connector_field_mapping_duplicate");
case "CONNECTOR_NAME_REQUIRED":
return t("workspace.unify.error_connector_name_required");
case "CONNECTOR_SURVEY_REQUIRED":
return t("workspace.unify.error_connector_survey_required");
case "CONNECTOR_QUESTIONS_REQUIRED":
return t("workspace.unify.error_connector_questions_required");
default:
return errorCode;
}
};
@@ -126,7 +126,8 @@ const FormError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const errorMessage = error?.message || error?.root?.message;
const body = error ? String(errorMessage) : children;
// Explicit children win — they're typically a translated/formatted version of the raw error.
const body = children ?? (error ? String(errorMessage) : null);
if (!body) {
return null;
@@ -1,7 +1,7 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { ChevronDown } from "lucide-react";
import { ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/cn";
@@ -28,6 +28,32 @@ const SelectTrigger = React.forwardRef<
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
@@ -39,18 +65,16 @@ const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = R
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white text-slate-700 shadow-md animate-in fade-in-80 dark:bg-slate-700 dark:text-slate-300",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
"max-h-[var(--radix-select-content-available-height)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}>
className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
@@ -98,6 +122,8 @@ export {
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
+1 -3
View File
@@ -782,9 +782,7 @@ hub:
# When empty, the chart renders TEI args from model, servedModelName, port,
# revision, and persistence.mountPath. Set this to fully override args.
args: []
extraArgs:
- --dtype
- float16
extraArgs: []
env: {}
port: 8080
+4 -4
View File
@@ -61,7 +61,7 @@ cube(`FeedbackRecords`, {
},
sentiment: {
sql: `sentiment`,
sql: `${CUBE}.metadata->>'sentiment'`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
@@ -97,9 +97,9 @@ cube(`FeedbackRecords`, {
},
responseId: {
sql: `response_id`,
sql: `submission_id`,
type: `string`,
description: `Unique identifier linking related feedback records`,
description: `Unique identifier linking related feedback records (submission_id in Hub)`,
},
userId: {
@@ -109,7 +109,7 @@ cube(`FeedbackRecords`, {
},
emotion: {
sql: `emotion`,
sql: `${CUBE}.metadata->>'emotion'`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},