Compare commits

...

38 Commits

Author SHA1 Message Date
Dhruwang 7d77ed04de refactor(analytics): drop unused dims, broaden CSAT and group-by surface
Remove sentiment, emotion, and the TopicsUnnested join from the Cube
schema, web registry, AI prompt, audit-allowlist, and i18n keys — these
required metadata enrichment that is no longer planned. Tests swap the
removed string dimensions for sourceType to keep coverage intact.

Add csatDissatisfiedCount and csatNeutralCount so dashboards can render
the standard satisfied/neutral/dissatisfied distribution alongside the
existing top-2-box metric.

Expose field_label, field_group_label, language, value_boolean,
value_date, created_at, and updated_at as Cube dimensions. fieldLabel
and fieldGroupLabel unlock "group by question" and the matrix/ranking
aggregations enabled by the recent composite-question PR; the others
round out coverage of the underlying feedback_records columns. Extend
FieldDefinition with a boolean type and matching filter operators.
2026-05-12 18:45:10 +05:30
Dhruwang 602ffd5bba feat(analytics): augment Cube measures with CSAT/CES + bug fixes (ENG-804)
Adds CSAT and CES measures, plus a couple of universal cross-type measures,
to the FeedbackRecords Cube. Also fixes correctness bugs in the existing NPS
measures and constrains the AI chart-query output to known measure / dimension
ids.

Measures added
- csatScore: % of answered CSAT responses rated 4 or 5 (top-2-box on 1-5).
- csatAverage: AVG of answered CSAT ratings.
- csatSatisfiedCount: count where field_type='csat' AND value_number >= 4.
- csatCount: count of answered CSAT responses.
- cesAverage: AVG of answered CES ratings (1-5 or 1-7 depending on the question).
- cesCount: count of answered CES responses.
- uniqueRespondents: countDistinct(user_id).
- uniqueResponses: countDistinct(submission_id).
- npsAverage: AVG of answered NPS ratings (replaces the old `averageScore`
  measure, which silently averaged every numeric value across all field types).

Bug fixes (existing NPS measures)
- promoterCount / passiveCount / detractorCount / npsScore now filter
  field_type = 'nps'. Before, any numeric value in the band was counted
  (so a CSAT 5/5 was lumped in as a detractor).
- npsScore now divides by *answered* NPS responses only (value_number IS NOT
  NULL); dismissed/abandoned NPS records no longer drag the score toward 0.
- npsScore returns NULL when no answered NPS responses exist instead of the
  literal 0, so empty-data days render as gaps rather than a misleading
  flat-zero line.
- Same NULL-safe denominator fix applied to csatScore.
- csatCount / cesCount now exclude dismissed (value_number IS NULL) responses
  so they match the score denominators.

Dimensions
- Renamed `npsValue` -> `valueNumber` (the underlying value_number column
  stores values for NPS, CSAT, CES, rating, and number field types).
- New `valueText` dimension for grouping by categorical / open-text answers.

AI chart generation
- ZGenerateAIQueryResponse now enum-constrains `measures`, `dimensions`,
  `timeDimensions.dimension`, and `filters.member` against the known ids
  derived from FEEDBACK_FIELDS. Vercel AI SDK + Gemini structured outputs
  enforce these at decoding time, so the model can no longer return invalid
  or hallucinated measure names.
- generateText now pins temperature: 0. Same prompt produces the same query.

Breaking changes
- Charts that referenced `FeedbackRecords.averageScore` need to switch to
  `FeedbackRecords.npsAverage`.
- Charts that referenced `FeedbackRecords.npsValue` need to switch to
  `FeedbackRecords.valueNumber`.

Pre-GA on epic/v5; no production charts are expected to be impacted yet.
2026-05-12 16:05:38 +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
Dhruwang Jariwala 72f4e93432 fix: support Redis Cluster for BullMQ jobs (#7960) 2026-05-08 15:28:10 +05:30
Dhruwang Jariwala 9007502804 feat(feedback-sources): add a Create Survey CTA if there are none (#7943) 2026-05-08 15:10:28 +05:30
Tiago Farto d84589452c Merge branch 'epic/v5' of https://github.com/formbricks/formbricks into fix/investigate-bullmq-issue
# Conflicts:
#	docs/development/technical-handbook/background-job-processing.mdx
2026-05-08 09:38:53 +00:00
Tiago Farto 43aaed3923 fix: support Redis Cluster for BullMQ jobs 2026-05-08 09:19:49 +00:00
Bhagya Amarasinghe 550bfc6a6c fix: update Hub runtime defaults for v5 staging (#7959) 2026-05-07 19:25:20 +02:00
Javi Aguilar d192fbf839 add CR changes 2026-05-07 10:12:06 +02:00
Javi Aguilar c5d52df9b7 use i18n interpolation properly 2026-05-07 10:12:06 +02:00
Javi Aguilar 550e859a2d feat(unify): add CTA to create a survey before using it as feedback source if there are none 2026-05-07 10:12:06 +02:00
55 changed files with 1331 additions and 491 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"),
+27 -5
View File
@@ -1665,22 +1665,37 @@ checksums:
workspace/analysis/charts/failed_to_load_dashboards: 876c54d9cc69ceda6f808231e2557eb2
workspace/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
workspace/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
workspace/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
workspace/analysis/charts/field_label_ces_average: 3bf598396ea490f3a2bdccf0c94b6aa0
workspace/analysis/charts/field_label_ces_count: 4dc90d50a8e05dd9ba4a9e356926e0cb
workspace/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa
workspace/analysis/charts/field_label_count: 9c5848662eb8024ddf360f7e4001a968
workspace/analysis/charts/field_label_created_at: 9ce495d7fc74e1a2ae86c07206a3e531
workspace/analysis/charts/field_label_csat_average: f7c43dac56267f832fbebd6d18efdef1
workspace/analysis/charts/field_label_csat_count: 30c1ab12748b503ffee399ed326e0562
workspace/analysis/charts/field_label_csat_dissatisfied_count: 7f68b2c8302bde5cd93ba86d2163f86d
workspace/analysis/charts/field_label_csat_neutral_count: 19edae275784e8d53dd45003a2e8971a
workspace/analysis/charts/field_label_csat_satisfied_count: 78fcabc88da4b22171be149b1be509bd
workspace/analysis/charts/field_label_csat_score: 89a87f1069641bba10607d5b407cb0aa
workspace/analysis/charts/field_label_detractor_count: eedb15bc383eb0f14d43043e6666c62a
workspace/analysis/charts/field_label_emotion: eb3a31ead51b5c8a8d365d5f904e9206
workspace/analysis/charts/field_label_field_type: 2581066dc304c853a4a817c20996fa08
workspace/analysis/charts/field_label_language: 277fd1a41cc237a437cd1d5e4a80463b
workspace/analysis/charts/field_label_nps_average: 4a61877a06cb8e64a0a8375dd058a548
workspace/analysis/charts/field_label_nps_score: 9c8d0b0b460f9689bd66e81d45e0a2df
workspace/analysis/charts/field_label_nps_value: cb7404025044400e3d7d5600f3133e4f
workspace/analysis/charts/field_label_passive_count: ceb71da8d1382eb2097089dc3ecf76da
workspace/analysis/charts/field_label_promoter_count: c393131a4bd3a25bf6b297beed20e34f
workspace/analysis/charts/field_label_question: 0576462ce60d4263d7c482463fcc9547
workspace/analysis/charts/field_label_question_group: b007e2cfd1262272de3260f8d14d5833
workspace/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0
workspace/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6
workspace/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61
workspace/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570
workspace/analysis/charts/field_label_topic: 7f542b783cd528f00f4f485e35b48dc1
workspace/analysis/charts/field_label_unique_respondents: e340f09af176927f1ed16719ee304274
workspace/analysis/charts/field_label_unique_responses: d9ffcc58f72b9fdb143027703371f22b
workspace/analysis/charts/field_label_updated_at: a3730393cce5adfd9e50123d96640fd6
workspace/analysis/charts/field_label_user_identifier: b0174469c95038766744fb7e64005aec
workspace/analysis/charts/field_label_value_boolean: bbdcd3f46954b6304b9069e94e1371ab
workspace/analysis/charts/field_label_value_date: c8d705d1975affc01c002324725fec3f
workspace/analysis/charts/field_label_value_number: 1f14da79d14bd7b1c2324141f4470675
workspace/analysis/charts/field_label_value_text: e097a597cc507c716401ad18255de578
workspace/analysis/charts/filter_data: 05cc68ed2896feef60bbe3829cd9063d
workspace/analysis/charts/filters: acf5accc113ff3c1992688058576732c
workspace/analysis/charts/filters_toggle_description: ea18bdb212a6a85620125cab89a4b1c1
@@ -3529,6 +3544,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 +3597,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);
}
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Diagramm konnte nicht gespeichert werden",
"field": "Feld",
"field_label_average_score": "Durchschnittliche Bewertung",
"field_label_ces_average": "CES-Durchschnitt",
"field_label_ces_count": "CES-Anzahl",
"field_label_collected_at": "Erfasst am",
"field_label_count": "Anzahl",
"field_label_created_at": "Erstellt am",
"field_label_csat_average": "CSAT-Durchschnitt",
"field_label_csat_count": "CSAT-Anzahl",
"field_label_csat_dissatisfied_count": "CSAT Unzufriedene Anzahl",
"field_label_csat_neutral_count": "CSAT Neutrale Anzahl",
"field_label_csat_satisfied_count": "CSAT-Zufriedene Anzahl",
"field_label_csat_score": "CSAT-Score",
"field_label_detractor_count": "Anzahl Kritiker",
"field_label_emotion": "Emotion",
"field_label_field_type": "Feldtyp",
"field_label_language": "Sprache",
"field_label_nps_average": "NPS-Durchschnitt",
"field_label_nps_score": "NPS-Score",
"field_label_nps_value": "NPS-Wert",
"field_label_passive_count": "Anzahl Passive",
"field_label_promoter_count": "Anzahl Promoter",
"field_label_question": "Frage",
"field_label_question_group": "Fragengruppe",
"field_label_response_id": "Antwort-ID",
"field_label_sentiment": "Stimmung",
"field_label_source_name": "Quellenname",
"field_label_source_type": "Quellentyp",
"field_label_topic": "Thema",
"field_label_unique_respondents": "Eindeutige Teilnehmer",
"field_label_unique_responses": "Eindeutige Antworten",
"field_label_updated_at": "Aktualisiert am",
"field_label_user_identifier": "Benutzerkennung",
"field_label_value_boolean": "Wert (Boolean)",
"field_label_value_date": "Wert (Datum)",
"field_label_value_number": "Wert (Zahl)",
"field_label_value_text": "Wert (Text)",
"filter_data": "Daten filtern",
"filters": "Filter",
"filters_toggle_description": "Nur Daten einbeziehen, die die folgenden Bedingungen erfüllen.",
@@ -3689,6 +3704,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",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "Für diesen Workspace muss ein Feedback-Verzeichnis eingerichtet werden, bevor diese Funktion verfügbar ist. Bitte einen Organisationsinhaber oder Manager, eins zuzuweisen.",
"no_feedback_directory_linked_title": "Kein Feedback-Verzeichnis verknüpft",
"no_feedback_records": "Noch keine Feedback-Einträge vorhanden. Einträge erscheinen hier, sobald deine Konnektoren Daten senden.",
"no_formbricks_surveys_available_description": "In diesem Workspace gibt es noch keine Umfragen. <surveyLink>Erstelle eine neue Umfrage</surveyLink>, um eine als Feedback-Quelle zu verwenden.",
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
"optional": "Optional",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Failed to save chart",
"field": "Field",
"field_label_average_score": "Average Score",
"field_label_ces_average": "CES Average",
"field_label_ces_count": "CES Count",
"field_label_collected_at": "Collected At",
"field_label_count": "Count",
"field_label_created_at": "Created At",
"field_label_csat_average": "CSAT Average",
"field_label_csat_count": "CSAT Count",
"field_label_csat_dissatisfied_count": "CSAT Dissatisfied Count",
"field_label_csat_neutral_count": "CSAT Neutral Count",
"field_label_csat_satisfied_count": "CSAT Satisfied Count",
"field_label_csat_score": "CSAT Score",
"field_label_detractor_count": "Detractor Count",
"field_label_emotion": "Emotion",
"field_label_field_type": "Field Type",
"field_label_language": "Language",
"field_label_nps_average": "NPS Average",
"field_label_nps_score": "NPS Score",
"field_label_nps_value": "NPS Value",
"field_label_passive_count": "Passive Count",
"field_label_promoter_count": "Promoter Count",
"field_label_question": "Question",
"field_label_question_group": "Question Group",
"field_label_response_id": "Response ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Source Name",
"field_label_source_type": "Source Type",
"field_label_topic": "Topic",
"field_label_unique_respondents": "Unique Respondents",
"field_label_unique_responses": "Unique Responses",
"field_label_updated_at": "Updated At",
"field_label_user_identifier": "User Identifier",
"field_label_value_boolean": "Value (Boolean)",
"field_label_value_date": "Value (Date)",
"field_label_value_number": "Value (Number)",
"field_label_value_text": "Value (Text)",
"filter_data": "Filter data",
"filters": "Filters",
"filters_toggle_description": "Only include data that meets the following conditions.",
@@ -3689,6 +3704,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",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "A feedback directory needs to be set up for this workspace before this functionality is available. Ask an organization owner or manager to assign one.",
"no_feedback_directory_linked_title": "No feedback directory linked",
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
"no_formbricks_surveys_available_description": "There are no surveys in this workspace yet. <surveyLink>Create a new survey</surveyLink> to use it as a feedback source.",
"no_source_fields_loaded": "No source fields loaded yet",
"no_sources_connected": "No sources connected yet. Add a source to get started.",
"optional": "Optional",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Error al guardar el gráfico",
"field": "Campo",
"field_label_average_score": "Puntuación media",
"field_label_ces_average": "Promedio CES",
"field_label_ces_count": "Recuento CES",
"field_label_collected_at": "Recopilado el",
"field_label_count": "Recuento",
"field_label_created_at": "Fecha de creación",
"field_label_csat_average": "Promedio CSAT",
"field_label_csat_count": "Recuento CSAT",
"field_label_csat_dissatisfied_count": "Recuento de insatisfechos CSAT",
"field_label_csat_neutral_count": "Recuento de neutros CSAT",
"field_label_csat_satisfied_count": "Recuento de Satisfechos CSAT",
"field_label_csat_score": "Puntuación CSAT",
"field_label_detractor_count": "Recuento de detractores",
"field_label_emotion": "Emoción",
"field_label_field_type": "Tipo de campo",
"field_label_language": "Idioma",
"field_label_nps_average": "Promedio NPS",
"field_label_nps_score": "Puntuación NPS",
"field_label_nps_value": "Valor NPS",
"field_label_passive_count": "Recuento de pasivos",
"field_label_promoter_count": "Recuento de promotores",
"field_label_question": "Pregunta",
"field_label_question_group": "Grupo de preguntas",
"field_label_response_id": "ID de respuesta",
"field_label_sentiment": "Sentimiento",
"field_label_source_name": "Nombre de origen",
"field_label_source_type": "Tipo de origen",
"field_label_topic": "Tema",
"field_label_unique_respondents": "Encuestados Únicos",
"field_label_unique_responses": "Respuestas Únicas",
"field_label_updated_at": "Fecha de actualización",
"field_label_user_identifier": "Identificador de usuario",
"field_label_value_boolean": "Valor (Booleano)",
"field_label_value_date": "Valor (Fecha)",
"field_label_value_number": "Valor (Número)",
"field_label_value_text": "Valor (Texto)",
"filter_data": "Filtrar datos",
"filters": "Filtros",
"filters_toggle_description": "Incluye solo los datos que cumplan las siguientes condiciones.",
@@ -3689,6 +3704,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",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "Es necesario configurar un directorio de feedback para este espacio de trabajo antes de que esta funcionalidad esté disponible. Solicita a un propietario o gestor de la organización que asigne uno.",
"no_feedback_directory_linked_title": "No hay ningún directorio de feedback vinculado",
"no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.",
"no_formbricks_surveys_available_description": "Todavía no hay encuestas en este espacio de trabajo. <surveyLink>Crea una nueva encuesta</surveyLink> para usar una como fuente de feedback.",
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
"optional": "Opcional",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Échec de l'enregistrement du graphique",
"field": "Champ",
"field_label_average_score": "Score moyen",
"field_label_ces_average": "Moyenne CES",
"field_label_ces_count": "Nombre CES",
"field_label_collected_at": "Collecté le",
"field_label_count": "Nombre",
"field_label_created_at": "Créé le",
"field_label_csat_average": "Moyenne CSAT",
"field_label_csat_count": "Nombre CSAT",
"field_label_csat_dissatisfied_count": "Nombre d'insatisfaits CSAT",
"field_label_csat_neutral_count": "Nombre de neutres CSAT",
"field_label_csat_satisfied_count": "Nombre de clients satisfaits CSAT",
"field_label_csat_score": "Score CSAT",
"field_label_detractor_count": "Nombre de détracteurs",
"field_label_emotion": "Émotion",
"field_label_field_type": "Type de champ",
"field_label_language": "Langue",
"field_label_nps_average": "Moyenne NPS",
"field_label_nps_score": "Score NPS",
"field_label_nps_value": "Valeur NPS",
"field_label_passive_count": "Nombre de passifs",
"field_label_promoter_count": "Nombre de promoteurs",
"field_label_question": "Question",
"field_label_question_group": "Groupe de questions",
"field_label_response_id": "ID de réponse",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Nom de la source",
"field_label_source_type": "Type de source",
"field_label_topic": "Sujet",
"field_label_unique_respondents": "Répondants uniques",
"field_label_unique_responses": "Réponses uniques",
"field_label_updated_at": "Mis à jour le",
"field_label_user_identifier": "Identifiant utilisateur",
"field_label_value_boolean": "Valeur (booléenne)",
"field_label_value_date": "Valeur (date)",
"field_label_value_number": "Valeur (Nombre)",
"field_label_value_text": "Valeur (Texte)",
"filter_data": "Filtrer les données",
"filters": "Filtres",
"filters_toggle_description": "Inclure uniquement les données qui répondent aux conditions suivantes.",
@@ -3689,6 +3704,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",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "Un répertoire de feedback doit être configuré pour cet espace de travail avant que cette fonctionnalité ne soit disponible. Demande à un propriétaire ou un gestionnaire de l'organisation d'en attribuer un.",
"no_feedback_directory_linked_title": "Aucun répertoire de feedback lié",
"no_feedback_records": "Aucun enregistrement de feedback pour le moment. Les enregistrements apparaîtront ici une fois que vos connecteurs commenceront à envoyer des données.",
"no_formbricks_surveys_available_description": "Il ny a pas encore de sondages dans cet espace de travail. <surveyLink>Créez une nouvelle enquête</surveyLink> pour en utiliser une comme source de feedback.",
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
"optional": "Facultatif",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "A diagram mentése sikertelen",
"field": "Mező",
"field_label_average_score": "Átlagos pontszám",
"field_label_ces_average": "CES átlag",
"field_label_ces_count": "CES darabszám",
"field_label_collected_at": "Gyűjtve",
"field_label_count": "Darabszám",
"field_label_created_at": "Létrehozás dátuma",
"field_label_csat_average": "CSAT átlag",
"field_label_csat_count": "CSAT darabszám",
"field_label_csat_dissatisfied_count": "CSAT elégedetlen válaszok száma",
"field_label_csat_neutral_count": "CSAT semleges válaszok száma",
"field_label_csat_satisfied_count": "CSAT elégedett válaszadók száma",
"field_label_csat_score": "CSAT pontszám",
"field_label_detractor_count": "Kritikusok száma",
"field_label_emotion": "Érzelem",
"field_label_field_type": "Mező típusa",
"field_label_language": "Nyelv",
"field_label_nps_average": "NPS átlag",
"field_label_nps_score": "NPS pontszám",
"field_label_nps_value": "NPS érték",
"field_label_passive_count": "Passzívak száma",
"field_label_promoter_count": "Támogatók száma",
"field_label_question": "Kérdés",
"field_label_question_group": "Kérdéscsoport",
"field_label_response_id": "Válaszazonosító",
"field_label_sentiment": "Hangulat",
"field_label_source_name": "Forrás neve",
"field_label_source_type": "Forrás típusa",
"field_label_topic": "Téma",
"field_label_unique_respondents": "Egyedi válaszadók",
"field_label_unique_responses": "Egyedi válaszok",
"field_label_updated_at": "Frissítés dátuma",
"field_label_user_identifier": "Felhasználóazonosító",
"field_label_value_boolean": "Érték (logikai)",
"field_label_value_date": "Érték (dátum)",
"field_label_value_number": "Érték (szám)",
"field_label_value_text": "Érték (szöveg)",
"filter_data": "Adatok szűrése",
"filters": "Szűrők",
"filters_toggle_description": "Csak azokat az adatokat tartalmazza, amelyek megfelelnek a következő feltételeknek.",
@@ -3689,6 +3704,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",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "Ehhez a munkaterülethez be kell állítani egy visszajelzési könyvtárat, mielőtt ez a funkció elérhetővé válna. Kérje meg a szervezet tulajdonosát vagy vezetőjét, hogy rendeljen hozzá egyet.",
"no_feedback_directory_linked_title": "Nincs visszajelzési könyvtár kapcsolva",
"no_feedback_records": "Még nincsenek visszajelzési rekordok. A rekordok itt fognak megjelenni, amint a csatlakozók elkezdik küldeni az adatokat.",
"no_formbricks_surveys_available_description": "Ebben a munkaterületen még nincsenek kérdőívek. <surveyLink>Hozz létre egy új kérdőívet</surveyLink>, hogy visszajelzési forrásként használhass egyet.",
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
"optional": "Elhagyható",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "チャートの保存に失敗しました",
"field": "フィールド",
"field_label_average_score": "平均スコア",
"field_label_ces_average": "CES平均",
"field_label_ces_count": "CES件数",
"field_label_collected_at": "収集日時",
"field_label_count": "カウント",
"field_label_created_at": "作成日時",
"field_label_csat_average": "CSAT平均",
"field_label_csat_count": "CSAT件数",
"field_label_csat_dissatisfied_count": "CSAT 不満足数",
"field_label_csat_neutral_count": "CSAT 中立数",
"field_label_csat_satisfied_count": "CSAT満足件数",
"field_label_csat_score": "CSATスコア",
"field_label_detractor_count": "批判者数",
"field_label_emotion": "感情",
"field_label_field_type": "フィールドタイプ",
"field_label_language": "言語",
"field_label_nps_average": "NPS平均",
"field_label_nps_score": "NPSスコア",
"field_label_nps_value": "NPS値",
"field_label_passive_count": "中立者数",
"field_label_promoter_count": "推奨者数",
"field_label_question": "質問",
"field_label_question_group": "質問グループ",
"field_label_response_id": "回答ID",
"field_label_sentiment": "感情分析",
"field_label_source_name": "ソース名",
"field_label_source_type": "ソースタイプ",
"field_label_topic": "トピック",
"field_label_unique_respondents": "ユニーク回答者数",
"field_label_unique_responses": "ユニーク回答数",
"field_label_updated_at": "更新日時",
"field_label_user_identifier": "ユーザー識別子",
"field_label_value_boolean": "値(ブール値)",
"field_label_value_date": "値(日付)",
"field_label_value_number": "値(数値)",
"field_label_value_text": "値(テキスト)",
"filter_data": "データをフィルター",
"filters": "フィルター",
"filters_toggle_description": "以下の条件を満たすデータのみを含めます。",
@@ -3689,6 +3704,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": "フィードバックディレクトリ",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "この機能を利用するには、このワークスペースにフィードバックディレクトリを設定する必要があります。組織のオーナーまたはマネージャーに割り当てを依頼してください。",
"no_feedback_directory_linked_title": "フィードバックディレクトリが未リンク",
"no_feedback_records": "フィードバックレコードはまだありません。コネクタがデータの送信を開始すると、ここにレコードが表示されます。",
"no_formbricks_surveys_available_description": "このワークスペースにはまだフォームがありません。フィードバックソースとして使用するには<surveyLink>新しいフォームを作成</surveyLink>してください。",
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
"optional": "任意",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Opslaan van diagram mislukt",
"field": "Veld",
"field_label_average_score": "Gemiddelde score",
"field_label_ces_average": "CES Gemiddelde",
"field_label_ces_count": "CES Aantal",
"field_label_collected_at": "Verzameld op",
"field_label_count": "Aantal",
"field_label_created_at": "Aangemaakt op",
"field_label_csat_average": "CSAT Gemiddelde",
"field_label_csat_count": "CSAT Aantal",
"field_label_csat_dissatisfied_count": "CSAT Aantal ontevreden",
"field_label_csat_neutral_count": "CSAT Aantal neutraal",
"field_label_csat_satisfied_count": "CSAT Tevreden Aantal",
"field_label_csat_score": "CSAT Score",
"field_label_detractor_count": "Aantal detractors",
"field_label_emotion": "Emotie",
"field_label_field_type": "Veldtype",
"field_label_language": "Taal",
"field_label_nps_average": "NPS Gemiddelde",
"field_label_nps_score": "NPS-score",
"field_label_nps_value": "NPS-waarde",
"field_label_passive_count": "Aantal passieven",
"field_label_promoter_count": "Aantal promoters",
"field_label_question": "Vraag",
"field_label_question_group": "Vraaggroep",
"field_label_response_id": "Antwoord-ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Bronnaam",
"field_label_source_type": "Brontype",
"field_label_topic": "Onderwerp",
"field_label_unique_respondents": "Unieke Respondenten",
"field_label_unique_responses": "Unieke Antwoorden",
"field_label_updated_at": "Bijgewerkt op",
"field_label_user_identifier": "Gebruikersidentificatie",
"field_label_value_boolean": "Waarde (Boolean)",
"field_label_value_date": "Waarde (Datum)",
"field_label_value_number": "Waarde (Getal)",
"field_label_value_text": "Waarde (Tekst)",
"filter_data": "Data filteren",
"filters": "Filters",
"filters_toggle_description": "Neem alleen gegevens op die aan de volgende voorwaarden voldoen.",
@@ -3689,6 +3704,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",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "Er moet eerst een feedbackmap worden ingesteld voor deze werkruimte voordat deze functionaliteit beschikbaar is. Vraag een organisatie-eigenaar of manager om er een toe te wijzen.",
"no_feedback_directory_linked_title": "Geen feedbackmap gekoppeld",
"no_feedback_records": "Nog geen feedbackrecords. Records verschijnen hier zodra je connectoren gegevens beginnen te verzenden.",
"no_formbricks_surveys_available_description": "Er zijn nog geen enquêtes in deze werkruimte. <surveyLink>Maak een nieuwe enquête</surveyLink> om er een als feedbackbron te gebruiken.",
"no_source_fields_loaded": "Nog geen bronvelden geladen",
"no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.",
"optional": "Optioneel",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Falha ao salvar gráfico",
"field": "Campo",
"field_label_average_score": "Pontuação média",
"field_label_ces_average": "Média CES",
"field_label_ces_count": "Contagem CES",
"field_label_collected_at": "Coletado em",
"field_label_count": "Contagem",
"field_label_created_at": "Criado em",
"field_label_csat_average": "Média CSAT",
"field_label_csat_count": "Contagem CSAT",
"field_label_csat_dissatisfied_count": "Contagem de Insatisfeitos CSAT",
"field_label_csat_neutral_count": "Contagem de Neutros CSAT",
"field_label_csat_satisfied_count": "Contagem de Satisfeitos CSAT",
"field_label_csat_score": "Pontuação CSAT",
"field_label_detractor_count": "Contagem de detratores",
"field_label_emotion": "Emoção",
"field_label_field_type": "Tipo de campo",
"field_label_language": "Idioma",
"field_label_nps_average": "Média NPS",
"field_label_nps_score": "Pontuação de NPS",
"field_label_nps_value": "Valor de NPS",
"field_label_passive_count": "Contagem de passivos",
"field_label_promoter_count": "Contagem de promotores",
"field_label_question": "Pergunta",
"field_label_question_group": "Grupo de Perguntas",
"field_label_response_id": "ID da resposta",
"field_label_sentiment": "Sentimento",
"field_label_source_name": "Nome da fonte",
"field_label_source_type": "Tipo de fonte",
"field_label_topic": "Tópico",
"field_label_unique_respondents": "Respondentes Únicos",
"field_label_unique_responses": "Respostas Únicas",
"field_label_updated_at": "Atualizado em",
"field_label_user_identifier": "Identificador do usuário",
"field_label_value_boolean": "Valor (Booleano)",
"field_label_value_date": "Valor (Data)",
"field_label_value_number": "Valor (Número)",
"field_label_value_text": "Valor (Texto)",
"filter_data": "Filtrar dados",
"filters": "Filtros",
"filters_toggle_description": "Incluir apenas dados que atendam às seguintes condições.",
@@ -3689,6 +3704,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",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "Um diretório de feedback precisa ser configurado para este workspace antes que esta funcionalidade esteja disponível. Peça a um proprietário ou gerente da organização para configurar um.",
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
"no_feedback_records": "Nenhum registro de feedback ainda. Os registros aparecerão aqui assim que seus conectores começarem a enviar dados.",
"no_formbricks_surveys_available_description": "Ainda não há pesquisas neste workspace. <surveyLink>Crie uma nova pesquisa</surveyLink> para usar uma como fonte de feedback.",
"no_source_fields_loaded": "Nenhum campo de origem carregado ainda",
"no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.",
"optional": "Opcional",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Falha ao guardar gráfico",
"field": "Campo",
"field_label_average_score": "Pontuação média",
"field_label_ces_average": "Média CES",
"field_label_ces_count": "Contagem CES",
"field_label_collected_at": "Recolhido em",
"field_label_count": "Contagem",
"field_label_created_at": "Criado em",
"field_label_csat_average": "Média CSAT",
"field_label_csat_count": "Contagem CSAT",
"field_label_csat_dissatisfied_count": "Contagem de CSAT Insatisfeito",
"field_label_csat_neutral_count": "Contagem de CSAT Neutro",
"field_label_csat_satisfied_count": "Contagem de Satisfeitos CSAT",
"field_label_csat_score": "Pontuação CSAT",
"field_label_detractor_count": "Contagem de detratores",
"field_label_emotion": "Emoção",
"field_label_field_type": "Tipo de campo",
"field_label_language": "Idioma",
"field_label_nps_average": "Média NPS",
"field_label_nps_score": "Pontuação NPS",
"field_label_nps_value": "Valor NPS",
"field_label_passive_count": "Contagem de passivos",
"field_label_promoter_count": "Contagem de promotores",
"field_label_question": "Pergunta",
"field_label_question_group": "Grupo de Perguntas",
"field_label_response_id": "ID de resposta",
"field_label_sentiment": "Sentimento",
"field_label_source_name": "Nome da origem",
"field_label_source_type": "Tipo de origem",
"field_label_topic": "Tópico",
"field_label_unique_respondents": "Inquiridos Únicos",
"field_label_unique_responses": "Respostas Únicas",
"field_label_updated_at": "Atualizado em",
"field_label_user_identifier": "Identificador de utilizador",
"field_label_value_boolean": "Valor (Booleano)",
"field_label_value_date": "Valor (Data)",
"field_label_value_number": "Valor (Número)",
"field_label_value_text": "Valor (Texto)",
"filter_data": "Filtrar dados",
"filters": "Filtros",
"filters_toggle_description": "Incluir apenas dados que cumpram as seguintes condições.",
@@ -3689,6 +3704,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",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "É necessário configurar um diretório de feedback para este workspace antes de esta funcionalidade estar disponível. Pede a um proprietário ou gestor da organização para atribuir um.",
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
"no_feedback_records": "Ainda não há registos de feedback. Os registos aparecerão aqui assim que os teus conectores começarem a enviar dados.",
"no_formbricks_surveys_available_description": "Ainda não há inquéritos neste workspace. <surveyLink>Cria um novo inquérito</surveyLink> para usar um como fonte de feedback.",
"no_source_fields_loaded": "Ainda não foram carregados campos de origem",
"no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.",
"optional": "Opcional",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Nu s-a putut salva graficul",
"field": "Câmp",
"field_label_average_score": "Scor mediu",
"field_label_ces_average": "Media CES",
"field_label_ces_count": "Număr CES",
"field_label_collected_at": "Colectat la",
"field_label_count": "Număr",
"field_label_created_at": "Creat la",
"field_label_csat_average": "Media CSAT",
"field_label_csat_count": "Număr CSAT",
"field_label_csat_dissatisfied_count": "Număr CSAT Nemulțumiți",
"field_label_csat_neutral_count": "Număr CSAT Neutri",
"field_label_csat_satisfied_count": "Număr clienți mulțumiți CSAT",
"field_label_csat_score": "Scor CSAT",
"field_label_detractor_count": "Număr de detractori",
"field_label_emotion": "Emoție",
"field_label_field_type": "Tip câmp",
"field_label_language": "Limbă",
"field_label_nps_average": "Media NPS",
"field_label_nps_score": "Scor NPS",
"field_label_nps_value": "Valoare NPS",
"field_label_passive_count": "Număr de pasivi",
"field_label_promoter_count": "Număr de promotori",
"field_label_question": "Întrebare",
"field_label_question_group": "Grup de întrebări",
"field_label_response_id": "ID răspuns",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Nume sursă",
"field_label_source_type": "Tip sursă",
"field_label_topic": "Subiect",
"field_label_unique_respondents": "Respondenți unici",
"field_label_unique_responses": "Răspunsuri unice",
"field_label_updated_at": "Actualizat la",
"field_label_user_identifier": "Identificator utilizator",
"field_label_value_boolean": "Valoare (Boolean)",
"field_label_value_date": "Valoare (Dată)",
"field_label_value_number": "Valoare (Număr)",
"field_label_value_text": "Valoare (Text)",
"filter_data": "Filtrează datele",
"filters": "Filtre",
"filters_toggle_description": "Include doar datele care îndeplinesc următoarele condiții.",
@@ -3689,6 +3704,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",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "Trebuie configurat un director de feedback pentru acest spațiu de lucru înainte ca această funcționalitate să fie disponibilă. Solicită unui proprietar sau manager al organizației să atribuie unul.",
"no_feedback_directory_linked_title": "Niciun director de feedback conectat",
"no_feedback_records": "Nu există încă înregistrări de feedback. Înregistrările vor apărea aici după ce conectorii tăi vor începe să trimită date.",
"no_formbricks_surveys_available_description": "Nu există încă chestionare în acest spațiu de lucru. <surveyLink>Creează un chestionar nou</surveyLink> pentru a folosi unul ca sursă de feedback.",
"no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă",
"no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.",
"optional": "Opțional",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Не удалось сохранить график",
"field": "Поле",
"field_label_average_score": "Средний балл",
"field_label_ces_average": "Средний CES",
"field_label_ces_count": "Количество CES",
"field_label_collected_at": "Дата сбора",
"field_label_count": "Количество",
"field_label_created_at": "Дата создания",
"field_label_csat_average": "Средний CSAT",
"field_label_csat_count": "Количество CSAT",
"field_label_csat_dissatisfied_count": "Количество недовольных (CSAT)",
"field_label_csat_neutral_count": "Количество нейтральных (CSAT)",
"field_label_csat_satisfied_count": "Количество удовлетворённых (CSAT)",
"field_label_csat_score": "Оценка CSAT",
"field_label_detractor_count": "Количество критиков",
"field_label_emotion": "Эмоция",
"field_label_field_type": "Тип поля",
"field_label_language": "Язык",
"field_label_nps_average": "Средний NPS",
"field_label_nps_score": "Оценка NPS",
"field_label_nps_value": "Значение NPS",
"field_label_passive_count": "Количество пассивных",
"field_label_promoter_count": "Количество промоутеров",
"field_label_question": "Вопрос",
"field_label_question_group": "Группа вопросов",
"field_label_response_id": "ID ответа",
"field_label_sentiment": "Тональность",
"field_label_source_name": "Название источника",
"field_label_source_type": "Тип источника",
"field_label_topic": "Тема",
"field_label_unique_respondents": "Уникальные респонденты",
"field_label_unique_responses": "Уникальные ответы",
"field_label_updated_at": "Дата обновления",
"field_label_user_identifier": "Идентификатор пользователя",
"field_label_value_boolean": "Значение (логическое)",
"field_label_value_date": "Значение (дата)",
"field_label_value_number": "Значение (число)",
"field_label_value_text": "Значение (текст)",
"filter_data": "Фильтровать данные",
"filters": "Фильтры",
"filters_toggle_description": "Включай только те данные, которые соответствуют следующим условиям.",
@@ -3689,6 +3704,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": "Директория обратной связи",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "Для этого рабочего пространства нужно настроить директорию обратной связи, прежде чем эта функция станет доступна. Попроси владельца или менеджера организации назначить её.",
"no_feedback_directory_linked_title": "Директория обратной связи не привязана",
"no_feedback_records": "Пока нет записей отзывов. Они появятся здесь, когда коннекторы начнут отправлять данные.",
"no_formbricks_surveys_available_description": "В этом рабочем пространстве пока нет опросов. <surveyLink>Создайте новый опрос</surveyLink>, чтобы использовать один как источник обратной связи.",
"no_source_fields_loaded": "Поля источника ещё не загружены",
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
"optional": "Необязательно",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Det gick inte att spara diagrammet",
"field": "Fält",
"field_label_average_score": "Genomsnittligt betyg",
"field_label_ces_average": "CES-medelvärde",
"field_label_ces_count": "CES-antal",
"field_label_collected_at": "Insamlad",
"field_label_count": "Antal",
"field_label_created_at": "Skapad",
"field_label_csat_average": "CSAT-medelvärde",
"field_label_csat_count": "CSAT-antal",
"field_label_csat_dissatisfied_count": "CSAT-antal missnöjda",
"field_label_csat_neutral_count": "CSAT-antal neutrala",
"field_label_csat_satisfied_count": "CSAT antal nöjda",
"field_label_csat_score": "CSAT-poäng",
"field_label_detractor_count": "Antal kritiker",
"field_label_emotion": "Känsla",
"field_label_field_type": "Fälttyp",
"field_label_language": "Språk",
"field_label_nps_average": "NPS-medelvärde",
"field_label_nps_score": "NPS-poäng",
"field_label_nps_value": "NPS-värde",
"field_label_passive_count": "Antal passiva",
"field_label_promoter_count": "Antal förespråkare",
"field_label_question": "Fråga",
"field_label_question_group": "Frågegrupp",
"field_label_response_id": "Svar-ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Källnamn",
"field_label_source_type": "Källtyp",
"field_label_topic": "Ämne",
"field_label_unique_respondents": "Unika respondenter",
"field_label_unique_responses": "Unika svar",
"field_label_updated_at": "Uppdaterad",
"field_label_user_identifier": "Användar-ID",
"field_label_value_boolean": "Värde (Boolean)",
"field_label_value_date": "Värde (Datum)",
"field_label_value_number": "Värde (Nummer)",
"field_label_value_text": "Värde (Text)",
"filter_data": "Filtrera data",
"filters": "Filter",
"filters_toggle_description": "Inkludera bara data som uppfyller följande villkor.",
@@ -3689,6 +3704,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",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "En feedbackkatalog måste konfigureras för denna arbetsyta innan den här funktionen blir tillgänglig. Be en organisationsägare eller chef att tilldela en.",
"no_feedback_directory_linked_title": "Ingen feedbackkatalog länkad",
"no_feedback_records": "Inga feedbackposter ännu. Poster visas här när dina connectors börjar skicka data.",
"no_formbricks_surveys_available_description": "Det finns inga enkäter i denna arbetsyta ännu. <surveyLink>Skapa en ny enkät</surveyLink> för att använda en som feedbackkälla.",
"no_source_fields_loaded": "Inga källfält har laddats än",
"no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.",
"optional": "Valfritt",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Grafik kaydedilemedi",
"field": "Alan",
"field_label_average_score": "Ortalama Puan",
"field_label_ces_average": "CES Ortalaması",
"field_label_ces_count": "CES Sayısı",
"field_label_collected_at": "Toplandığı Tarih",
"field_label_count": "Sayı",
"field_label_created_at": "Oluşturulma Tarihi",
"field_label_csat_average": "CSAT Ortalaması",
"field_label_csat_count": "CSAT Sayısı",
"field_label_csat_dissatisfied_count": "CSAT Memnuniyetsiz Sayısı",
"field_label_csat_neutral_count": "CSAT Nötr Sayısı",
"field_label_csat_satisfied_count": "CSAT Memnun Sayısı",
"field_label_csat_score": "CSAT Puanı",
"field_label_detractor_count": "Eleştirmen Sayısı",
"field_label_emotion": "Duygu",
"field_label_field_type": "Alan Türü",
"field_label_language": "Dil",
"field_label_nps_average": "NPS Ortalaması",
"field_label_nps_score": "NPS Puanı",
"field_label_nps_value": "NPS Değeri",
"field_label_passive_count": "Pasif Sayısı",
"field_label_promoter_count": "Tavsiye Eden Sayısı",
"field_label_question": "Soru",
"field_label_question_group": "Soru Grubu",
"field_label_response_id": "Yanıt Kimliği",
"field_label_sentiment": "Duygu Durumu",
"field_label_source_name": "Kaynak Adı",
"field_label_source_type": "Kaynak Türü",
"field_label_topic": "Konu",
"field_label_unique_respondents": "Benzersiz Katılımcılar",
"field_label_unique_responses": "Benzersiz Yanıtlar",
"field_label_updated_at": "Güncellenme Tarihi",
"field_label_user_identifier": "Kullanıcı Tanımlayıcısı",
"field_label_value_boolean": "Değer (Boolean)",
"field_label_value_date": "Değer (Tarih)",
"field_label_value_number": "Değer (Sayı)",
"field_label_value_text": "Değer (Metin)",
"filter_data": "Verileri filtrele",
"filters": "Filtreler",
"filters_toggle_description": "Yalnızca aşağıdaki koşulları karşılayan verileri dahil et.",
@@ -3689,6 +3704,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",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "Bu işlevin kullanılabilmesi için önce bu çalışma alanı için bir geri bildirim dizini kurulması gerekiyor. Bir organizasyon sahibinden veya yöneticisinden bir tane atamasını iste.",
"no_feedback_directory_linked_title": "Bağlı geri bildirim dizini yok",
"no_feedback_records": "Henüz geri bildirim kaydı yok. Bağlayıcıların veri göndermeye başlamasıyla kayıtlar burada görünecek.",
"no_formbricks_surveys_available_description": "Bu çalışma alanında henüz anket yok. Geri bildirim kaynağı olarak kullanmak için <surveyLink>Yeni bir anket oluştur</surveyLink>.",
"no_source_fields_loaded": "Henüz kaynak alan yüklenmedi",
"no_sources_connected": "Henüz bağlı kaynak yok. Başlamak için bir kaynak ekle.",
"optional": "İsteğe bağlı",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "图表保存失败",
"field": "字段",
"field_label_average_score": "平均",
"field_label_ces_average": "CES 平均",
"field_label_ces_count": "CES 数量",
"field_label_collected_at": "收集时间",
"field_label_count": "数量",
"field_label_created_at": "创建时间",
"field_label_csat_average": "CSAT 平均值",
"field_label_csat_count": "CSAT 数量",
"field_label_csat_dissatisfied_count": "CSAT 不满意数量",
"field_label_csat_neutral_count": "CSAT 中立数量",
"field_label_csat_satisfied_count": "CSAT 满意数量",
"field_label_csat_score": "CSAT 得分",
"field_label_detractor_count": "贬损者数量",
"field_label_emotion": "情感",
"field_label_field_type": "字段类型",
"field_label_language": "语言",
"field_label_nps_average": "NPS 平均值",
"field_label_nps_score": "NPS 得分",
"field_label_nps_value": "NPS 值",
"field_label_passive_count": "中立者数量",
"field_label_promoter_count": "推荐者数量",
"field_label_question": "问题",
"field_label_question_group": "问题组",
"field_label_response_id": "响应 ID",
"field_label_sentiment": "情绪",
"field_label_source_name": "来源名称",
"field_label_source_type": "来源类型",
"field_label_topic": "主题",
"field_label_unique_respondents": "独立受访者",
"field_label_unique_responses": "独立回复",
"field_label_updated_at": "更新时间",
"field_label_user_identifier": "用户标识",
"field_label_value_boolean": "值(布尔型)",
"field_label_value_date": "值(日期)",
"field_label_value_number": "数值",
"field_label_value_text": "文本值",
"filter_data": "筛选数据",
"filters": "筛选条件",
"filters_toggle_description": "仅包含符合以下条件的数据。",
@@ -3689,6 +3704,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": "反馈目录",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "在该功能可用前,需要先为此工作区设置反馈目录。请联系组织所有者或管理员进行分配。",
"no_feedback_directory_linked_title": "未关联反馈目录",
"no_feedback_records": "暂无反馈记录。当你的连接器开始发送数据后,记录会显示在这里。",
"no_formbricks_surveys_available_description": "此工作区还没有调查。<surveyLink>创建新调查</surveyLink>,以将其用作反馈来源。",
"no_source_fields_loaded": "尚未加载源字段",
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
"optional": "可选",
+27 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "儲存圖表失敗",
"field": "欄位",
"field_label_average_score": "平均分數",
"field_label_ces_average": "CES 平均值",
"field_label_ces_count": "CES 數量",
"field_label_collected_at": "收集時間",
"field_label_count": "數量",
"field_label_created_at": "建立時間",
"field_label_csat_average": "CSAT 平均值",
"field_label_csat_count": "CSAT 數量",
"field_label_csat_dissatisfied_count": "CSAT 不滿意數量",
"field_label_csat_neutral_count": "CSAT 中立數量",
"field_label_csat_satisfied_count": "CSAT 滿意數量",
"field_label_csat_score": "CSAT 分數",
"field_label_detractor_count": "批評者數量",
"field_label_emotion": "情緒",
"field_label_field_type": "欄位類型",
"field_label_language": "語言",
"field_label_nps_average": "NPS 平均值",
"field_label_nps_score": "NPS 分數",
"field_label_nps_value": "NPS 值",
"field_label_passive_count": "中立者數量",
"field_label_promoter_count": "推廣者數量",
"field_label_question": "問題",
"field_label_question_group": "問題群組",
"field_label_response_id": "回應 ID",
"field_label_sentiment": "情感",
"field_label_source_name": "來源名稱",
"field_label_source_type": "來源類型",
"field_label_topic": "主題",
"field_label_unique_respondents": "不重複受訪者",
"field_label_unique_responses": "不重複回應",
"field_label_updated_at": "更新時間",
"field_label_user_identifier": "使用者識別碼",
"field_label_value_boolean": "值(布林)",
"field_label_value_date": "值(日期)",
"field_label_value_number": "數值",
"field_label_value_text": "文字值",
"filter_data": "篩選資料",
"filters": "篩選條件",
"filters_toggle_description": "只包含符合下列條件的資料。",
@@ -3689,6 +3704,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": "意見回饋目錄",
@@ -3736,6 +3757,7 @@
"no_feedback_directory_linked_member_description": "此工作區需要先設定意見回饋目錄,才能使用此功能。請請組織擁有者或管理員指定一個目錄。",
"no_feedback_directory_linked_title": "未連結意見回饋目錄",
"no_feedback_records": "目前尚無回饋紀錄。當你的連接器開始傳送資料時,紀錄會顯示在這裡。",
"no_formbricks_surveys_available_description": "此工作區尚無問卷。<surveyLink>建立新問卷</surveyLink>,以將其用作回饋來源。",
"no_source_fields_loaded": "尚未載入來源欄位",
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
"optional": "選填",
@@ -187,7 +187,7 @@ describe("executeTenantScopedQuery", () => {
...scopedInput,
query: {
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["secret-value"] }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
},
});
@@ -197,7 +197,7 @@ describe("executeTenantScopedQuery", () => {
targetType: "cubeQuery",
newObject: expect.objectContaining({
query: expect.objectContaining({
filterMembers: ["FeedbackRecords.sentiment"],
filterMembers: ["FeedbackRecords.sourceType"],
filterCount: 1,
}),
}),
@@ -107,18 +107,6 @@ describe("cube queryRewrite", () => {
).toThrow(/tenant filters are enforced by Cube/);
});
test("rejects caller-supplied TopicsUnnested tenant filters", () => {
expect(() =>
queryRewrite(
{
measures: ["TopicsUnnested.count"],
filters: [{ member: "TopicsUnnested.tenantId", operator: "equals", values: ["workspace-2"] }],
},
{ securityContext }
)
).toThrow(/tenant filters are enforced by Cube/);
});
test("logs sanitized failure audit metadata for rejected tenant filters", () => {
expect(() =>
queryRewrite(
@@ -169,7 +157,7 @@ describe("cube queryRewrite", () => {
filters: [
{
or: [
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["workspace-2"] },
],
},
@@ -207,36 +195,23 @@ describe("cube queryRewrite", () => {
test("appends the mandatory tenant filter from security context", () => {
const query = {
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] }],
};
const rewrittenQuery = queryRewrite(query, { securityContext });
expect(rewrittenQuery.filters).toEqual([
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["frd-1"] },
]);
expect(query.filters).toHaveLength(1);
});
test("appends only the TopicsUnnested tenant filter for TopicsUnnested queries", () => {
const query = {
measures: ["TopicsUnnested.count"],
dimensions: ["TopicsUnnested.topic"],
};
const rewrittenQuery = queryRewrite(query, { securityContext });
expect(rewrittenQuery.filters).toEqual([
{ member: "TopicsUnnested.tenantId", operator: "equals", values: ["frd-1"] },
]);
});
test("logs sanitized Cube audit metadata without raw filter values", () => {
queryRewrite(
{
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["secret-value"] }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
},
{ securityContext }
);
@@ -256,7 +231,6 @@ describe("cube queryRewrite", () => {
source: "charts.executeQueryAction",
});
expect(parsed.members).toContain("FeedbackRecords.tenantId");
expect(parsed.members).not.toContain("TopicsUnnested.tenantId");
expect(logPayload).not.toContain("secret-value");
});
});
@@ -10,7 +10,7 @@ describe("cube-query", () => {
expect(() =>
validateCubeQueryMembers({
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt" }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
order: { "FeedbackRecords.collectedAt": "desc" },
@@ -18,10 +18,6 @@ describe("cube-query", () => {
).not.toThrow();
});
test("allows TopicsUnnested dimensions from joined cube", () => {
expect(() => validateCubeQueryMembers({ dimensions: ["TopicsUnnested.topic"] })).not.toThrow();
});
test("throws for invalid members across query sections", () => {
expect(() =>
validateCubeQueryMembers({
@@ -57,7 +53,7 @@ describe("cube-query", () => {
filters: [
{
or: [
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
{
and: [{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["workspace-2"] }],
},
@@ -90,7 +86,7 @@ describe("cube-query", () => {
expect(() =>
validateCubeQueryMembers({
measures: ["FeedbackRecords.count", null],
dimensions: [{ member: "FeedbackRecords.sentiment" }],
dimensions: [{ member: "FeedbackRecords.sourceType" }],
segments: [0],
timeDimensions: [null, { dimension: null }],
filters: [null, { member: { name: "FeedbackRecords.sourceType" } }, { and: [0] }, { or: "bad" }],
@@ -103,7 +99,7 @@ describe("cube-query", () => {
test("summarizes query members without raw filter values", () => {
const summary = getCubeQueryAuditSummary({
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
order: [["FeedbackRecords.collectedAt", "desc"]],
limit: 50,
@@ -111,7 +107,7 @@ describe("cube-query", () => {
expect(summary).toEqual({
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
segments: [],
timeDimensions: [],
filterMembers: ["FeedbackRecords.sourceType"],
@@ -125,12 +121,12 @@ describe("cube-query", () => {
test("summarizes only valid member names from malformed query shapes", () => {
const summary = getCubeQueryAuditSummary({
measures: ["FeedbackRecords.count", null],
dimensions: [{ member: "FeedbackRecords.sentiment" }],
dimensions: [{ member: "FeedbackRecords.sourceType" }],
timeDimensions: [null, { dimension: "FeedbackRecords.collectedAt" }],
filters: [
null,
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] },
{ and: [0, { member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }] },
{ and: [0, { member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] }] },
],
order: [
[null, "asc"],
@@ -143,7 +139,7 @@ describe("cube-query", () => {
dimensions: [],
segments: [],
timeDimensions: ["FeedbackRecords.collectedAt"],
filterMembers: ["FeedbackRecords.sentiment", "FeedbackRecords.sourceType"],
filterMembers: ["FeedbackRecords.sourceType", "FeedbackRecords.sourceType"],
filterCount: 2,
orderMembers: ["FeedbackRecords.collectedAt"],
});
@@ -3,7 +3,7 @@ import type { TChartQuery } from "@formbricks/types/analysis";
export const TENANT_MEMBER = "FeedbackRecords.tenantId";
const ALLOWED_CUBE_PREFIXES = ["FeedbackRecords.", "TopicsUnnested."];
const ALLOWED_CUBE_PREFIXES = ["FeedbackRecords."];
const INVALID_MEMBER_REFERENCE = "invalid member reference";
type TQueryAuditSummary = {
@@ -310,9 +310,9 @@ export const validateCubeQueryMembers = (query: TChartQuery): void => {
if (result.invalidMembers.length > 0) {
throw new Error(
`Invalid query members (must start with FeedbackRecords. or TopicsUnnested.): ${uniqueSorted(
result.invalidMembers
).join(", ")}`
`Invalid query members (must start with FeedbackRecords.): ${uniqueSorted(result.invalidMembers).join(
", "
)}`
);
}
};
@@ -175,7 +175,7 @@ describe("chart Cube actions", () => {
mocks.generateText.mockResolvedValue({
output: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
timeDimensions: null,
chartType: "bar",
filters: null,
@@ -198,7 +198,7 @@ describe("chart Cube actions", () => {
expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith({
query: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
},
feedbackDirectoryId: "frd-1",
workspaceId: "workspace-1",
+22 -4
View File
@@ -21,6 +21,11 @@ import {
} from "@/modules/ee/analysis/charts/lib/charts";
import { checkFeedbackDirectoryAccess, checkWorkspaceAccess } from "@/modules/ee/analysis/lib/access";
import { generateSchemaContext } from "@/modules/ee/analysis/lib/ai-schema-context";
import {
FEEDBACK_DIMENSION_IDS,
FEEDBACK_MEASURE_IDS,
FEEDBACK_TIME_DIMENSION_IDS,
} from "@/modules/ee/analysis/lib/schema-definition";
import { ZChartCreateInput, ZChartType, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsDashboardsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -287,13 +292,25 @@ export const executeQueryAction = authenticatedActionClient
const CUBE_NAME = "FeedbackRecords";
const toEnumTuple = (values: readonly string[]): [string, ...string[]] => {
if (values.length === 0) {
throw new Error("AI query schema requires at least one allowed id");
}
return [values[0], ...values.slice(1)];
};
const ZMeasureId = z.enum(toEnumTuple(FEEDBACK_MEASURE_IDS));
const ZDimensionId = z.enum(toEnumTuple(FEEDBACK_DIMENSION_IDS));
const ZTimeDimensionId = z.enum(toEnumTuple(FEEDBACK_TIME_DIMENSION_IDS));
const ZFilterMemberId = z.enum(toEnumTuple([...FEEDBACK_MEASURE_IDS, ...FEEDBACK_DIMENSION_IDS]));
const ZGenerateAIQueryResponse = z.object({
measures: z.array(z.string()),
dimensions: z.array(z.string()).nullable(),
measures: z.array(ZMeasureId),
dimensions: z.array(ZDimensionId).nullable(),
timeDimensions: z
.array(
z.object({
dimension: z.string(),
dimension: ZTimeDimensionId,
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).nullable(),
dateRange: z.string().nullable(),
})
@@ -303,7 +320,7 @@ const ZGenerateAIQueryResponse = z.object({
filters: z
.array(
z.object({
member: z.string(),
member: ZFilterMemberId,
operator: z.enum([
"equals",
"notEquals",
@@ -364,6 +381,7 @@ export const generateAIChartAction = authenticatedActionClient
output: Output.object({ schema: ZGenerateAIQueryResponse }),
system: schemaContext,
prompt: `User request: "${parsedInput.prompt}"`,
temperature: 0,
});
const measures = output.measures.length > 0 ? output.measures : [`${CUBE_NAME}.count`];
@@ -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={
@@ -52,7 +52,7 @@ ${operatorsText}
## Guidelines
- Always include at least one measure. If unspecified, default to \`${CUBE_NAME}.count\`.
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sentiment\`, \`FeedbackRecords.collectedAt\`).
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sourceType\`, \`FeedbackRecords.collectedAt\`).
- For time-based filtering (date range only, no time grouping): add a timeDimension with dimension \`${CUBE_NAME}.collectedAt\` and dateRange. Do NOT include granularity (default is None / filter only).
- For time-series or trend questions (e.g. "over time", "by day", "weekly", "monthly"): add a timeDimension with dimension, granularity (hour/day/week/month/quarter/year), and dateRange.
- Choose the most appropriate chart type: bar, line, area, pie, or big_number (for single-number queries).
@@ -22,13 +22,13 @@ describe("query-builder", () => {
test("adds dimensions when present", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: ["FeedbackRecords.sentiment"],
selectedDimensions: ["FeedbackRecords.userId"],
filters: [],
filterLogic: "and",
timeDimension: null,
};
const query = buildCubeQuery(config);
expect(query.dimensions).toEqual(["FeedbackRecords.sentiment"]);
expect(query.dimensions).toEqual(["FeedbackRecords.userId"]);
});
test("adds time dimension with string dateRange", () => {
@@ -93,7 +93,7 @@ describe("query-builder", () => {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: [],
filters: [
{ id: "f1", field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ id: "f1", field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
{ id: "f2", field: "FeedbackRecords.sourceType", operator: "set", values: null },
],
filterLogic: "and",
@@ -101,7 +101,7 @@ describe("query-builder", () => {
};
const query = buildCubeQuery(config);
expect(query.filters).toEqual([
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "set" },
]);
});
@@ -110,14 +110,14 @@ describe("query-builder", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: [],
filters: [{ id: "f1", field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
filters: [{ id: "f1", field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
filterLogic: "or",
timeDimension: null,
};
const query = buildCubeQuery(config);
expect(query.filters).toEqual([
{
or: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
or: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
},
]);
});
@@ -136,12 +136,12 @@ describe("query-builder", () => {
test("parses AND member filters", () => {
const query = {
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
filters: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
};
const state = parseQueryToState(query);
expect(state.filterLogic).toBe("and");
expect(state.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual([
{ field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
]);
});
@@ -150,14 +150,14 @@ describe("query-builder", () => {
measures: ["FeedbackRecords.count"],
filters: [
{
or: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
or: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
},
],
};
const state = parseQueryToState(query);
expect(state.filterLogic).toBe("or");
expect(state.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual([
{ field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
]);
});
@@ -202,7 +202,7 @@ describe("query-builder", () => {
test("buildCubeQuery then parseQueryToState restores state", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: ["FeedbackRecords.sentiment"],
selectedDimensions: ["FeedbackRecords.userId"],
filters: [{ id: "f1", field: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
filterLogic: "and",
timeDimension: {
@@ -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;
}
@@ -32,9 +32,9 @@ describe("schema-definition", () => {
describe("getFieldById", () => {
test("returns dimension by id", () => {
const field = getFieldById("FeedbackRecords.sentiment");
const field = getFieldById("FeedbackRecords.sourceType");
expect(field).toBeDefined();
expect(field?.label).toBe("Sentiment");
expect(field?.label).toBe("Source Type");
expect(field?.type).toBe("string");
});
@@ -56,7 +56,7 @@ describe("schema-definition", () => {
});
test("returns field label for known dimension/measure", () => {
expect(formatCubeColumnHeader("FeedbackRecords.sentiment")).toBe("Sentiment");
expect(formatCubeColumnHeader("FeedbackRecords.sourceType")).toBe("Source Type");
expect(formatCubeColumnHeader("FeedbackRecords.count")).toBe("Count");
});
@@ -74,5 +74,25 @@ describe("schema-definition", () => {
expect(FEEDBACK_FIELDS.dimensions.length).toBeGreaterThan(0);
expect(FEEDBACK_FIELDS.measures.length).toBeGreaterThan(0);
});
test("exposes CSAT, CES, NPS and universal measures", () => {
const ids = FEEDBACK_FIELDS.measures.map((m) => m.id);
expect(ids).toEqual(
expect.arrayContaining([
"FeedbackRecords.count",
"FeedbackRecords.uniqueRespondents",
"FeedbackRecords.uniqueResponses",
"FeedbackRecords.npsScore",
"FeedbackRecords.npsAverage",
"FeedbackRecords.csatScore",
"FeedbackRecords.csatAverage",
"FeedbackRecords.csatSatisfiedCount",
"FeedbackRecords.csatCount",
"FeedbackRecords.cesAverage",
"FeedbackRecords.cesCount",
])
);
expect(ids).not.toContain("FeedbackRecords.averageScore");
});
});
});
@@ -7,7 +7,7 @@ import type { TFunction } from "i18next";
export interface FieldDefinition {
id: string;
label: string;
type: "string" | "number" | "time";
type: "string" | "number" | "time" | "boolean";
description?: string;
}
@@ -20,12 +20,6 @@ export interface MeasureDefinition {
export const FEEDBACK_FIELDS = {
dimensions: [
{
id: "FeedbackRecords.sentiment",
label: "Sentiment",
type: "string",
description: "Sentiment extracted from feedback",
},
{
id: "FeedbackRecords.sourceType",
label: "Source Type",
@@ -45,10 +39,22 @@ export const FEEDBACK_FIELDS = {
description: "Type of feedback field (e.g., nps, text, rating)",
},
{
id: "FeedbackRecords.emotion",
label: "Emotion",
id: "FeedbackRecords.fieldLabel",
label: "Question",
type: "string",
description: "Emotion extracted from metadata JSONB field",
description: "Human-readable label of the question/field",
},
{
id: "FeedbackRecords.fieldGroupLabel",
label: "Question Group",
type: "string",
description: "Label of the parent composite question for matrix/ranking rows",
},
{
id: "FeedbackRecords.language",
label: "Language",
type: "string",
description: 'Response language code (e.g., "en", "de")',
},
{
id: "FeedbackRecords.userId",
@@ -63,10 +69,30 @@ export const FEEDBACK_FIELDS = {
description: "Unique identifier linking related feedback records",
},
{
id: "FeedbackRecords.npsValue",
label: "NPS Value",
id: "FeedbackRecords.valueNumber",
label: "Value (Number)",
type: "number",
description: "Raw NPS score value (0-10)",
description:
"Numeric answer value (NPS 0-10, CSAT 1-5, CES 1-5 or 1-7, rating, number). Pair with a fieldType filter to keep scales consistent.",
},
{
id: "FeedbackRecords.valueText",
label: "Value (Text)",
type: "string",
description:
"Text answer value (open text, or the label of a multiple-choice/categorical answer). Pair with a fieldType filter to keep types consistent.",
},
{
id: "FeedbackRecords.valueBoolean",
label: "Value (Boolean)",
type: "boolean",
description: "Boolean answer value (yes/no). Pair with a fieldType filter.",
},
{
id: "FeedbackRecords.valueDate",
label: "Value (Date)",
type: "time",
description: "Date answer value. Pair with a fieldType filter.",
},
{
id: "FeedbackRecords.collectedAt",
@@ -75,10 +101,16 @@ export const FEEDBACK_FIELDS = {
description: "Timestamp when the feedback was collected",
},
{
id: "TopicsUnnested.topic",
label: "Topic",
type: "string",
description: "Individual topic from the topics array",
id: "FeedbackRecords.createdAt",
label: "Created At",
type: "time",
description: "Timestamp when the feedback record was created in Hub",
},
{
id: "FeedbackRecords.updatedAt",
label: "Updated At",
type: "time",
description: "Timestamp when the feedback record was last updated in Hub",
},
] as FieldDefinition[],
measures: [
@@ -89,38 +121,106 @@ export const FEEDBACK_FIELDS = {
description: "Total number of feedback responses",
},
{
id: "FeedbackRecords.promoterCount",
label: "Promoter Count",
type: "count",
description: "Number of promoters (NPS score 9-10)",
id: "FeedbackRecords.uniqueRespondents",
label: "Unique Respondents",
type: "number",
description: "Number of unique users who provided feedback",
},
{
id: "FeedbackRecords.detractorCount",
label: "Detractor Count",
type: "count",
description: "Number of detractors (NPS score 0-6)",
},
{
id: "FeedbackRecords.passiveCount",
label: "Passive Count",
type: "count",
description: "Number of passives (NPS score 7-8)",
id: "FeedbackRecords.uniqueResponses",
label: "Unique Responses",
type: "number",
description: "Number of unique survey submissions",
},
{
id: "FeedbackRecords.npsScore",
label: "NPS Score",
type: "number",
description: "Net Promoter Score: ((Promoters - Detractors) / Total) * 100",
description: "Net Promoter Score: ((Promoters - Detractors) / Total NPS responses) * 100",
},
{
id: "FeedbackRecords.averageScore",
label: "Average Score",
id: "FeedbackRecords.npsAverage",
label: "NPS Average",
type: "number",
description: "Average NPS score",
description: "Average NPS rating (0-10)",
},
{
id: "FeedbackRecords.promoterCount",
label: "Promoter Count",
type: "count",
description: "Number of NPS promoters (score 9-10)",
},
{
id: "FeedbackRecords.passiveCount",
label: "Passive Count",
type: "count",
description: "Number of NPS passives (score 7-8)",
},
{
id: "FeedbackRecords.detractorCount",
label: "Detractor Count",
type: "count",
description: "Number of NPS detractors (score 0-6)",
},
{
id: "FeedbackRecords.csatScore",
label: "CSAT Score",
type: "number",
description: "CSAT Score: % of CSAT responses rated 4 or 5 (top-2-box on the 1-5 scale)",
},
{
id: "FeedbackRecords.csatAverage",
label: "CSAT Average",
type: "number",
description: "Average CSAT rating (1-5)",
},
{
id: "FeedbackRecords.csatSatisfiedCount",
label: "CSAT Satisfied Count",
type: "count",
description: "Number of satisfied CSAT responses (top-2-box on the 1-5 scale)",
},
{
id: "FeedbackRecords.csatDissatisfiedCount",
label: "CSAT Dissatisfied Count",
type: "count",
description: "Number of dissatisfied CSAT responses (bottom-2-box on the 1-5 scale)",
},
{
id: "FeedbackRecords.csatNeutralCount",
label: "CSAT Neutral Count",
type: "count",
description: "Number of neutral CSAT responses (middle box on the 1-5 scale)",
},
{
id: "FeedbackRecords.csatCount",
label: "CSAT Count",
type: "count",
description: "Number of CSAT responses",
},
{
id: "FeedbackRecords.cesAverage",
label: "CES Average",
type: "number",
description: "Average CES rating (scale is 1-5 or 1-7 depending on the question)",
},
{
id: "FeedbackRecords.cesCount",
label: "CES Count",
type: "count",
description: "Number of CES responses",
},
] as MeasureDefinition[],
};
export const FEEDBACK_MEASURE_IDS: string[] = FEEDBACK_FIELDS.measures.map((m) => m.id);
export const FEEDBACK_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions.map((d) => d.id);
export const FEEDBACK_TIME_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions
.filter((d) => d.type === "time")
.map((d) => d.id);
export type FilterOperator =
| "equals"
| "notEquals"
@@ -137,6 +237,7 @@ export const FILTER_OPERATORS: Record<string, FilterOperator[]> = {
string: ["equals", "notEquals", "contains", "notContains", "set", "notSet"],
number: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
time: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
boolean: ["equals", "notEquals", "set", "notSet"],
};
export const TIME_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"] as const;
@@ -166,7 +267,7 @@ export const DATE_PRESETS = [
/**
* Get filter operators for a given field type.
*/
export function getFilterOperatorsForType(type: "string" | "number" | "time"): FilterOperator[] {
export function getFilterOperatorsForType(type: "string" | "number" | "time" | "boolean"): FilterOperator[] {
return FILTER_OPERATORS[type] || FILTER_OPERATORS.string;
}
@@ -184,22 +285,39 @@ export function getFieldById(id: string): FieldDefinition | MeasureDefinition |
*/
export function getTranslatedFieldLabel(id: string, t: TFunction): string {
const labels: Record<string, string> = {
"FeedbackRecords.sentiment": t("workspace.analysis.charts.field_label_sentiment"),
"FeedbackRecords.sourceType": t("workspace.analysis.charts.field_label_source_type"),
"FeedbackRecords.sourceName": t("workspace.analysis.charts.field_label_source_name"),
"FeedbackRecords.fieldType": t("workspace.analysis.charts.field_label_field_type"),
"FeedbackRecords.emotion": t("workspace.analysis.charts.field_label_emotion"),
"FeedbackRecords.fieldLabel": t("workspace.analysis.charts.field_label_question"),
"FeedbackRecords.fieldGroupLabel": t("workspace.analysis.charts.field_label_question_group"),
"FeedbackRecords.language": t("workspace.analysis.charts.field_label_language"),
"FeedbackRecords.userId": t("workspace.analysis.charts.field_label_user_identifier"),
"FeedbackRecords.responseId": t("workspace.analysis.charts.field_label_response_id"),
"FeedbackRecords.npsValue": t("workspace.analysis.charts.field_label_nps_value"),
"FeedbackRecords.valueNumber": t("workspace.analysis.charts.field_label_value_number"),
"FeedbackRecords.valueText": t("workspace.analysis.charts.field_label_value_text"),
"FeedbackRecords.valueBoolean": t("workspace.analysis.charts.field_label_value_boolean"),
"FeedbackRecords.valueDate": t("workspace.analysis.charts.field_label_value_date"),
"FeedbackRecords.collectedAt": t("workspace.analysis.charts.field_label_collected_at"),
"TopicsUnnested.topic": t("workspace.analysis.charts.field_label_topic"),
"FeedbackRecords.createdAt": t("workspace.analysis.charts.field_label_created_at"),
"FeedbackRecords.updatedAt": t("workspace.analysis.charts.field_label_updated_at"),
"FeedbackRecords.count": t("workspace.analysis.charts.field_label_count"),
"FeedbackRecords.promoterCount": t("workspace.analysis.charts.field_label_promoter_count"),
"FeedbackRecords.detractorCount": t("workspace.analysis.charts.field_label_detractor_count"),
"FeedbackRecords.passiveCount": t("workspace.analysis.charts.field_label_passive_count"),
"FeedbackRecords.uniqueRespondents": t("workspace.analysis.charts.field_label_unique_respondents"),
"FeedbackRecords.uniqueResponses": t("workspace.analysis.charts.field_label_unique_responses"),
"FeedbackRecords.npsScore": t("workspace.analysis.charts.field_label_nps_score"),
"FeedbackRecords.averageScore": t("workspace.analysis.charts.field_label_average_score"),
"FeedbackRecords.npsAverage": t("workspace.analysis.charts.field_label_nps_average"),
"FeedbackRecords.promoterCount": t("workspace.analysis.charts.field_label_promoter_count"),
"FeedbackRecords.passiveCount": t("workspace.analysis.charts.field_label_passive_count"),
"FeedbackRecords.detractorCount": t("workspace.analysis.charts.field_label_detractor_count"),
"FeedbackRecords.csatScore": t("workspace.analysis.charts.field_label_csat_score"),
"FeedbackRecords.csatAverage": t("workspace.analysis.charts.field_label_csat_average"),
"FeedbackRecords.csatSatisfiedCount": t("workspace.analysis.charts.field_label_csat_satisfied_count"),
"FeedbackRecords.csatDissatisfiedCount": t(
"workspace.analysis.charts.field_label_csat_dissatisfied_count"
),
"FeedbackRecords.csatNeutralCount": t("workspace.analysis.charts.field_label_csat_neutral_count"),
"FeedbackRecords.csatCount": t("workspace.analysis.charts.field_label_csat_count"),
"FeedbackRecords.cesAverage": t("workspace.analysis.charts.field_label_ces_average"),
"FeedbackRecords.cesCount": t("workspace.analysis.charts.field_label_ces_count"),
};
return labels[id] ?? getFieldById(id)?.label ?? id;
}
@@ -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 },
@@ -1,14 +1,16 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Trans, useTranslation } from "react-i18next";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { TConnectorOptionId, getConnectorOptions } from "../utils";
interface ConnectorTypeSelectorProps {
selectedType: TConnectorOptionId | null;
onSelectType: (type: TConnectorOptionId) => void;
workspaceId: string;
surveyCount: number;
}
const getOptionClassName = (
@@ -27,43 +29,54 @@ const getOptionClassName = (
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
};
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
export function ConnectorTypeSelector({
selectedType,
onSelectType,
workspaceId,
surveyCount,
}: Readonly<ConnectorTypeSelectorProps>) {
const { t } = useTranslation();
const connectorOptions = getConnectorOptions(t);
return (
<div className="space-y-3">
<div className="space-y-2">
{connectorOptions.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
selectedType,
option.id,
option.disabled
)}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
</div>
<div
className={`ml-3 h-4 w-4 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-white" />
{connectorOptions.map((option) => {
const showNoSurveysAlert =
surveyCount === 0 && option.id === "formbricks_survey" && selectedType === "formbricks_survey";
return (
<div key={option.id} className="space-y-2">
<button
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
selectedType,
option.id,
option.disabled
)}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
</div>
)}
<div
className={`ml-3 h-4 w-4 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-white" />
</div>
)}
</div>
</button>
{showNoSurveysAlert && <NoFormbricksSurveysAlert workspaceId={workspaceId} />}
</div>
</button>
))}
);
})}
</div>
<Alert variant="outbound" size="small">
<AlertTitle>{t("workspace.unify.missing_feedback_source_title")}</AlertTitle>
@@ -80,3 +93,20 @@ export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<C
</div>
);
}
const NoFormbricksSurveysAlert = ({ workspaceId }: Readonly<{ workspaceId: string }>) => {
return (
<Alert variant="info" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
<Trans
i18nKey="workspace.unify.no_formbricks_surveys_available_description"
components={{
surveyLink: (
<Link href={`/workspaces/${workspaceId}/surveys/templates`} className="font-medium underline" />
),
}}
/>
</AlertDescription>
</Alert>
);
};
@@ -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);
}
@@ -416,16 +427,23 @@ export const CreateConnectorModal = ({
<div className="py-4">
{currentStep === "selectType" && (
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
<ConnectorTypeSelector
selectedType={selectedType}
onSelectType={setSelectedType}
surveyCount={surveys.length}
workspaceId={workspaceId}
/>
)}
{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>
@@ -435,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>
)}
/>
@@ -445,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>
@@ -462,7 +482,9 @@ export const CreateConnectorModal = ({
</SelectContent>
</Select>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -470,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>
@@ -482,7 +504,9 @@ export const CreateConnectorModal = ({
/>
</div>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -578,7 +602,9 @@ export const CreateConnectorModal = ({
</Button>
)}
{currentStep === "selectType" ? (
<Button onClick={handleNextStep} disabled={!selectedType}>
<Button
onClick={handleNextStep}
disabled={!selectedType || (selectedType === "formbricks_survey" && surveys.length === 0)}>
{getNextStepButtonLabel(selectedType, t)}
</Button>
) : (
@@ -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 -1
View File
@@ -1,6 +1,6 @@
/* eslint-env es2022 */
const TENANT_MEMBERS = ["FeedbackRecords.tenantId", "TopicsUnnested.tenantId"];
const TENANT_MEMBERS = ["FeedbackRecords.tenantId"];
const REQUIRED_SCOPE = "xm:cube:query";
function assertRequiredEnvironmentVariable(name) {
@@ -1,6 +1,5 @@
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
// If the Hub changes column names, types, or the metadata JSONB shape (e.g. the `topics` array),
// this schema must be updated to match.
// If the Hub changes column names or types, this schema must be updated to match.
cube(`FeedbackRecords`, {
sql: `SELECT * FROM feedback_records`,
@@ -60,12 +59,6 @@ cube(`FeedbackRecords`, {
primaryKey: true,
},
sentiment: {
sql: `sentiment`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
sourceType: {
sql: `source_type`,
type: `string`,
@@ -108,65 +101,10 @@ cube(`FeedbackRecords`, {
description: `Identifier of the user who provided feedback`,
},
emotion: {
sql: `emotion`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID linking to FeedbackDirectory`,
},
},
joins: {
TopicsUnnested: {
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
relationship: `hasMany`,
},
},
});
cube(`TopicsUnnested`, {
sql: `
SELECT
fr.id as feedback_record_id,
fr.tenant_id,
topic_elem.topic
FROM feedback_records fr
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
`,
measures: {
count: {
type: `count`,
},
},
dimensions: {
id: {
sql: `md5(feedback_record_id || '::' || topic)`,
type: `string`,
primaryKey: true,
},
feedbackRecordId: {
sql: `feedback_record_id`,
type: `string`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID for row-level security scoping`,
},
topic: {
sql: `topic`,
type: `string`,
description: `Individual topic from the topics array`,
},
},
});
@@ -39,6 +39,7 @@ spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
command:
- sh
- -c
@@ -55,6 +56,7 @@ spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
ports:
- name: http
containerPort: 8080
@@ -54,6 +54,7 @@ spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
command:
- sh
- -c
@@ -79,6 +80,7 @@ spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
envFrom:
- secretRef:
name: {{ include "formbricks.hubSecretName" . }}
+5 -6
View File
@@ -594,6 +594,7 @@ cube:
containerSecurityContext:
readOnlyRootFilesystem: false
runAsNonRoot: true
runAsUser: 1000
livenessProbe:
httpGet:
@@ -639,10 +640,10 @@ hub:
# Pinned by digest for immutable, reproducible deployments. When digest is set it takes
# precedence over tag, and deployment, init container, and migration job all resolve to the
# same immutable image. Update on each Hub release.
# Current digest corresponds to ghcr.io/formbricks/hub:0.2.0.
digest: "sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"
# Current digest corresponds to ghcr.io/formbricks/hub:0.3.0.
digest: "sha256:6c39b1143527137e881df785a5b668625a1fe3edb05485bb5ded19f813c8de88"
# Tag is a fallback for dev/non-prod when digest is cleared; keep aligned with the digest above.
tag: "0.2.0"
tag: "0.3.0"
pullPolicy: IfNotPresent
# Optional override for the secret Hub reads from.
@@ -781,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
+1 -1
View File
@@ -1,6 +1,6 @@
/* eslint-env es2022 */
const TENANT_MEMBERS = ["FeedbackRecords.tenantId", "TopicsUnnested.tenantId"];
const TENANT_MEMBERS = ["FeedbackRecords.tenantId"];
const REQUIRED_SCOPE = "xm:cube:query";
function assertRequiredEnvironmentVariable(name) {
+140 -80
View File
@@ -1,6 +1,5 @@
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
// If the Hub changes column names, types, or the metadata JSONB shape (e.g. the `topics` array),
// this schema must be updated to match.
// If the Hub changes column names or types, this schema must be updated to match.
cube(`FeedbackRecords`, {
sql: `SELECT * FROM feedback_records`,
@@ -10,46 +9,120 @@ cube(`FeedbackRecords`, {
description: `Total number of feedback responses`,
},
uniqueRespondents: {
type: `countDistinct`,
sql: `${CUBE}.user_id`,
description: `Number of unique users who provided feedback`,
},
uniqueResponses: {
type: `countDistinct`,
sql: `${CUBE}.submission_id`,
description: `Number of unique survey submissions (a submission can produce multiple feedback records)`,
},
promoterCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 9` }],
description: `Number of promoters (NPS score 9-10)`,
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9` }],
description: `Number of NPS promoters (score 9-10)`,
},
detractorCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6` }],
description: `Number of detractors (NPS score 0-6)`,
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6` }],
description: `Number of NPS detractors (score 0-6)`,
},
passiveCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
description: `Number of passives (NPS score 7-8)`,
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 7 AND 8` }],
description: `Number of NPS passives (score 7-8)`,
},
npsScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(*) = 0 THEN 0
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
ELSE ROUND(
(
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
/ COUNT(*)::numeric
(COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6 THEN 1 END)::numeric)
/ COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
) * 100,
2
)
END
`,
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
description: `Net Promoter Score: ((Promoters - Detractors) / Answered NPS responses) * 100. NULL when there are no answered NPS responses.`,
},
averageScore: {
npsAverage: {
type: `avg`,
sql: `${CUBE}.value_number`,
description: `Average NPS score`,
filters: [{ sql: `${CUBE}.field_type = 'nps'` }],
description: `Average NPS rating (0-10)`,
},
csatCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL` }],
description: `Number of answered CSAT responses (dismissed responses excluded).`,
},
csatSatisfiedCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4` }],
description: `Number of satisfied CSAT responses (top-2-box on the 1-5 scale)`,
},
csatDissatisfiedCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number BETWEEN 1 AND 2` }],
description: `Number of dissatisfied CSAT responses (bottom-2-box on the 1-5 scale)`,
},
csatNeutralCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number = 3` }],
description: `Number of neutral CSAT responses (middle box on the 1-5 scale)`,
},
csatScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
ELSE ROUND(
(
COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4 THEN 1 END)::numeric
/ COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
) * 100,
2
)
END
`,
description: `CSAT Score: % of answered CSAT responses rated 4 or 5 (top-2-box on the 1-5 scale). NULL when there are no answered CSAT responses.`,
},
csatAverage: {
type: `avg`,
sql: `${CUBE}.value_number`,
filters: [{ sql: `${CUBE}.field_type = 'csat'` }],
description: `Average CSAT rating (1-5)`,
},
cesCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'ces' AND ${CUBE}.value_number IS NOT NULL` }],
description: `Number of answered CES responses (dismissed responses excluded).`,
},
cesAverage: {
type: `avg`,
sql: `${CUBE}.value_number`,
filters: [{ sql: `${CUBE}.field_type = 'ces'` }],
description: `Average CES rating (scale is 1-5 or 1-7 depending on the question)`,
},
},
@@ -60,12 +133,6 @@ cube(`FeedbackRecords`, {
primaryKey: true,
},
sentiment: {
sql: `sentiment`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
sourceType: {
sql: `source_type`,
type: `string`,
@@ -84,22 +151,70 @@ cube(`FeedbackRecords`, {
description: `Type of feedback field (e.g., nps, text, rating)`,
},
fieldLabel: {
sql: `field_label`,
type: `string`,
description: `Human-readable label of the question/field (e.g., "How satisfied are you with support?")`,
},
fieldGroupLabel: {
sql: `field_group_label`,
type: `string`,
description: `Label of the parent composite question for matrix/ranking rows`,
},
language: {
sql: `language`,
type: `string`,
description: `Response language code (e.g., "en", "de"). NULL when language is "default".`,
},
collectedAt: {
sql: `collected_at`,
type: `time`,
description: `Timestamp when the feedback was collected`,
},
npsValue: {
createdAt: {
sql: `created_at`,
type: `time`,
description: `Timestamp when the feedback record was created in Hub`,
},
updatedAt: {
sql: `updated_at`,
type: `time`,
description: `Timestamp when the feedback record was last updated in Hub`,
},
valueNumber: {
sql: `value_number`,
type: `number`,
description: `Raw NPS score value (0-10)`,
description: `Numeric answer value (NPS 0-10, CSAT 1-5, CES 1-5 or 1-7, rating, generic number). Pair with a fieldType filter to keep scales consistent.`,
},
valueText: {
sql: `value_text`,
type: `string`,
description: `Text answer value (open text, or the label of a multiple-choice / categorical answer). Pair with a fieldType filter to keep types consistent.`,
},
valueBoolean: {
sql: `value_boolean`,
type: `boolean`,
description: `Boolean answer value (yes/no questions). Pair with a fieldType filter.`,
},
valueDate: {
sql: `value_date`,
type: `time`,
description: `Date answer value (e.g., "preferred meeting date"). Pair with a fieldType filter.`,
},
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: {
@@ -108,65 +223,10 @@ cube(`FeedbackRecords`, {
description: `Identifier of the user who provided feedback`,
},
emotion: {
sql: `emotion`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID linking to FeedbackDirectory`,
},
},
joins: {
TopicsUnnested: {
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
relationship: `hasMany`,
},
},
});
cube(`TopicsUnnested`, {
sql: `
SELECT
fr.id as feedback_record_id,
fr.tenant_id,
topic_elem.topic
FROM feedback_records fr
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
`,
measures: {
count: {
type: `count`,
},
},
dimensions: {
id: {
sql: `md5(feedback_record_id || '::' || topic)`,
type: `string`,
primaryKey: true,
},
feedbackRecordId: {
sql: `feedback_record_id`,
type: `string`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID for row-level security scoping`,
},
topic: {
sql: `topic`,
type: `string`,
description: `Individual topic from the topics array`,
},
},
});
+1 -1
View File
@@ -1,7 +1,7 @@
import type { JobSchedulerTemplateOptions, JobsOptions } from "bullmq";
export const JOBS_QUEUE_NAME = "background-jobs";
export const JOBS_PREFIX = "formbricks:jobs";
export const JOBS_PREFIX = "{formbricks:jobs}";
export const JOB_NAMES = {
testLog: "system.test-log",
+4
View File
@@ -132,6 +132,10 @@ describe("@formbricks/jobs queue helpers", () => {
);
});
test("uses a Redis Cluster hash-tagged prefix for BullMQ keys", () => {
expect(JOBS_PREFIX).toBe("{formbricks:jobs}");
});
test("memoizes the producer queue", async () => {
const first = await getJobsQueue();
const second = await getJobsQueue();