Compare commits

..

30 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
50 changed files with 1233 additions and 454 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);
}
+26 -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",
+26 -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",
+26 -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",
+26 -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",
+26 -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",
+26 -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": "フィードバックディレクトリ",
+26 -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",
+26 -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",
+26 -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",
+26 -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",
+26 -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": "Директория обратной связи",
+26 -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",
+26 -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",
+26 -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": "反馈目录",
+26 -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": "意見回饋目錄",
@@ -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 },
@@ -17,7 +17,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { TFieldMapping, TUnifySurvey } from "../types";
import { TFieldMapping, TUnifySurvey, getTranslatedConnectorError } from "../types";
import { ConnectorsTable } from "./connectors-table";
import { CreateConnectorModal } from "./create-connector-modal";
import { CsvImportModal } from "./csv-import-modal";
@@ -28,6 +28,7 @@ interface ConnectorsSectionProps {
initialConnectors: TConnectorWithMappings[];
initialSurveys: TUnifySurvey[];
directories: { id: string; name: string }[];
isReadOnly: boolean;
}
export function ConnectorsSection({
@@ -35,6 +36,7 @@ export function ConnectorsSection({
initialConnectors,
initialSurveys,
directories,
isReadOnly,
}: Readonly<ConnectorsSectionProps>) {
const { t } = useTranslation();
const router = useRouter();
@@ -78,7 +80,7 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return undefined;
}
@@ -93,7 +95,7 @@ export function ConnectorsSection({
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => {
}): Promise<boolean> => {
const result = await updateConnectorWithMappingsAction({
connectorId: data.connectorId,
workspaceId: workspaceId,
@@ -111,19 +113,20 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return false;
}
toast.success(t("workspace.unify.connector_updated_successfully"));
router.refresh();
return true;
};
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
const result = await deleteConnectorAction({ connectorId, workspaceId: workspaceId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return;
}
@@ -138,7 +141,7 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return;
}
@@ -155,7 +158,7 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return;
}
@@ -170,11 +173,15 @@ export function ConnectorsSection({
<SettingsCard
title={t("workspace.unify.feedback_sources")}
description={t("workspace.unify.feedback_sources_settings_description")}
buttonInfo={{
text: t("workspace.unify.add_source"),
onClick: () => setIsCreateModalOpen(true),
variant: "default",
}}>
buttonInfo={
isReadOnly
? undefined
: {
text: t("workspace.unify.add_source"),
onClick: () => setIsCreateModalOpen(true),
variant: "default",
}
}>
<ConnectorsTable
connectors={initialConnectors}
onConnectorClick={setEditingConnector}
@@ -183,15 +190,18 @@ export function ConnectorsSection({
onToggleStatus={handleToggleStatus}
onDelete={handleDeleteConnector}
isLoading={false}
isReadOnly={isReadOnly}
/>
{directories.length > 0 && (
<Alert size="small" className="mt-4">
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
<AlertButton asChild>
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
{t("workspace.unify.manage_directories")}
</Link>
</AlertButton>
{!isReadOnly && (
<AlertButton asChild>
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
{t("workspace.unify.manage_directories")}
</Link>
</AlertButton>
)}
</Alert>
)}
</SettingsCard>
@@ -208,6 +218,7 @@ export function ConnectorsSection({
<EditConnectorModal
connector={editingConnector}
isReadOnly={isReadOnly}
open={editingConnector !== null}
onOpenChange={(open) => !open && setEditingConnector(null)}
onUpdateConnector={handleUpdateConnector}
@@ -37,6 +37,7 @@ interface ConnectorsTableDataRowProps {
onDuplicate: () => Promise<void>;
onToggleStatus: () => Promise<void>;
onDelete: () => Promise<void>;
isReadOnly?: boolean;
}
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
@@ -52,10 +53,11 @@ export function ConnectorsTableDataRow({
onDuplicate,
onToggleStatus,
onDelete,
isReadOnly = false,
}: Readonly<ConnectorsTableDataRowProps>) {
const { t, i18n } = useTranslation();
const handleRowClick = () => {
if (connector.type === "csv" && onCsvImport) {
if (!isReadOnly && connector.type === "csv" && onCsvImport) {
onCsvImport();
return;
}
@@ -93,10 +95,10 @@ export function ConnectorsTableDataRow({
title={t(getConnectorTypeLabelKey(connector.type))}>
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
</div>
<div className="col-span-5 flex items-center">
<div className="col-span-4 flex items-center">
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
</div>
<div className="col-span-1 hidden items-center justify-center sm:flex">
<div className="col-span-2 hidden items-center justify-center sm:flex">
<Badge
text={getStatusLabel(connector.status, connector.type)}
type={STATUS_BADGE_TYPE[connector.status]}
@@ -110,14 +112,16 @@ export function ConnectorsTableDataRow({
<span className="truncate">{connector.creatorName ?? "—"}</span>
</div>
<div className="col-span-1 flex items-center justify-end pr-2">
<ConnectorRowDropdown
connector={connector}
onEdit={onEdit}
onCsvImport={onCsvImport}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
{!isReadOnly && (
<ConnectorRowDropdown
connector={connector}
onEdit={onEdit}
onCsvImport={onCsvImport}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
)}
</div>
</div>
);
@@ -9,6 +9,7 @@ interface ConnectorsTableRowsContainerProps {
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
onDelete: (connectorId: string) => Promise<void>;
isReadOnly?: boolean;
}
export const ConnectorsTableRowsContainer = ({
@@ -18,6 +19,7 @@ export const ConnectorsTableRowsContainer = ({
onDuplicate,
onToggleStatus,
onDelete,
isReadOnly = false,
}: ConnectorsTableRowsContainerProps) => {
const { t } = useTranslation();
@@ -40,6 +42,7 @@ export const ConnectorsTableRowsContainer = ({
onDuplicate={() => onDuplicate(connector)}
onToggleStatus={() => onToggleStatus(connector)}
onDelete={() => onDelete(connector.id)}
isReadOnly={isReadOnly}
/>
))}
</div>
@@ -13,6 +13,7 @@ interface ConnectorsTableProps {
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
onDelete: (connectorId: string) => Promise<void>;
isLoading?: boolean;
isReadOnly?: boolean;
}
export function ConnectorsTable({
@@ -23,6 +24,7 @@ export function ConnectorsTable({
onToggleStatus,
onDelete,
isLoading = false,
isReadOnly = false,
}: Readonly<ConnectorsTableProps>) {
const { t } = useTranslation();
@@ -30,8 +32,8 @@ export function ConnectorsTable({
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">{t("common.type")}</div>
<div className="col-span-5">{t("common.name")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
<div className="col-span-4">{t("common.name")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.status")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
<div className="col-span-1" />
@@ -48,6 +50,7 @@ export function ConnectorsTable({
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
isReadOnly={isReadOnly}
/>
)}
</div>
@@ -49,6 +49,7 @@ import {
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
getTranslatedConnectorError,
} from "../types";
import {
TConnectorOptionId,
@@ -339,7 +340,12 @@ export const CreateConnectorModal = ({
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
});
if (connectorId && values.importHistorical) {
if (!connectorId) {
setIsCreating(false);
return;
}
if (values.importHistorical) {
await handleHistoricalImport(connectorId, values.surveyId);
}
@@ -368,7 +374,12 @@ export const CreateConnectorModal = ({
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
if (connectorId && csvParsedData.length > 0) {
if (!connectorId) {
setIsCreating(false);
return;
}
if (csvParsedData.length > 0) {
await handleCsvImport(connectorId);
}
@@ -426,11 +437,13 @@ export const CreateConnectorModal = ({
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<form
className="space-y-4"
onSubmit={formbricksForm.handleSubmit(handleCreateFormbricksConnector)}>
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
@@ -440,7 +453,9 @@ export const CreateConnectorModal = ({
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -450,7 +465,7 @@ export const CreateConnectorModal = ({
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
@@ -467,7 +482,9 @@ export const CreateConnectorModal = ({
</SelectContent>
</Select>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -475,7 +492,7 @@ export const CreateConnectorModal = ({
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
render={({ fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
@@ -487,7 +504,9 @@ export const CreateConnectorModal = ({
/>
</div>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -38,6 +38,7 @@ import {
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
getTranslatedConnectorError,
} from "../types";
import {
areAllRequiredFieldsMapped,
@@ -59,9 +60,10 @@ interface EditConnectorModalProps {
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<void>;
}) => Promise<boolean>;
surveys: TUnifySurvey[];
onOpenCsvImport?: () => void;
isReadOnly?: boolean;
}
export const EditConnectorModal = ({
@@ -71,6 +73,7 @@ export const EditConnectorModal = ({
onUpdateConnector,
surveys,
onOpenCsvImport,
isReadOnly = false,
}: EditConnectorModalProps) => {
const { t } = useTranslation();
const [csvConnectorName, setCsvConnectorName] = useState("");
@@ -174,7 +177,7 @@ export const EditConnectorModal = ({
const handleUpdateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
if (connector?.type !== "formbricks_survey") return;
setIsUpdating(true);
await onUpdateConnector({
const success = await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: values.sourceName.trim(),
@@ -182,13 +185,15 @@ export const EditConnectorModal = ({
fieldMappings: undefined,
});
setIsUpdating(false);
handleOpenChange(false);
if (success) {
handleOpenChange(false);
}
};
const handleUpdateCsvConnector = async () => {
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
setIsUpdating(true);
await onUpdateConnector({
const success = await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: csvConnectorName.trim(),
@@ -196,7 +201,9 @@ export const EditConnectorModal = ({
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
setIsUpdating(false);
handleOpenChange(false);
if (success) {
handleOpenChange(false);
}
};
const handleFormbricksQuestionToggle = (questionId: string) => {
@@ -239,11 +246,13 @@ export const EditConnectorModal = ({
<div className="space-y-4 py-4">
{connector.type === "formbricks_survey" ? (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<form
className="space-y-4"
onSubmit={formbricksForm.handleSubmit(handleUpdateFormbricksConnector)}>
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
@@ -251,9 +260,12 @@ export const EditConnectorModal = ({
value={field.value}
onChange={field.onChange}
placeholder={t("workspace.unify.enter_name_for_source")}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -261,7 +273,7 @@ export const EditConnectorModal = ({
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
@@ -281,7 +293,9 @@ export const EditConnectorModal = ({
</SelectContent>
</Select>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -289,19 +303,21 @@ export const EditConnectorModal = ({
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
render={({ fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
<div>
<fieldset className={isReadOnly ? "opacity-70" : undefined} disabled={isReadOnly}>
<FormbricksQuestionList
survey={selectedSurvey}
selectedQuestionIds={selectedQuestionIds}
onQuestionToggle={handleFormbricksQuestionToggle}
/>
</div>
</fieldset>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -328,41 +344,54 @@ export const EditConnectorModal = ({
value={csvConnectorName}
onChange={(event) => setCsvConnectorName(event.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
disabled={isReadOnly}
/>
</div>
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
<fieldset
disabled={isReadOnly}
className={`max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4 ${
isReadOnly ? "opacity-70" : ""
}`}>
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
connectorType={connector.type}
/>
</div>
</fieldset>
</>
)}
</div>
<DialogFooter>
{connector.type === "csv" && (
<Button
variant="secondary"
onClick={() => {
handleOpenChange(false);
onOpenCsvImport?.();
}}>
{t("workspace.unify.import_feedback")}
{isReadOnly ? (
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
{t("common.close")}
</Button>
) : (
<>
{connector.type === "csv" && (
<Button
variant="secondary"
onClick={() => {
handleOpenChange(false);
onOpenCsvImport?.();
}}>
{t("workspace.unify.import_feedback")}
</Button>
)}
<Button
onClick={
connector.type === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
: handleUpdateCsvConnector
}
disabled={saveChangesDisabled}>
{t("workspace.unify.save_changes")}
</Button>
</>
)}
<Button
onClick={
connector.type === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
: handleUpdateCsvConnector
}
disabled={saveChangesDisabled}>
{t("workspace.unify.save_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -17,8 +17,16 @@ export const WorkspaceFeedbackSourcesPage = async (
const t = await getTranslate();
const params = await props.params;
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session, organization } =
await getWorkspaceAuth(params.workspaceId);
const {
isOwner,
isManager,
hasReadAccess,
hasReadWriteAccess,
hasManageAccess,
isReadOnly,
session,
organization,
} = await getWorkspaceAuth(params.workspaceId);
if (!session) {
throw new Error(t("common.session_not_found"));
@@ -72,6 +80,7 @@ export const WorkspaceFeedbackSourcesPage = async (
initialConnectors={connectors}
initialSurveys={unifySurveys}
directories={directories}
isReadOnly={isReadOnly}
/>
);
};
@@ -220,10 +220,29 @@ export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSc
export type TCreateConnectorStep = "selectType" | "mapping";
export const ZFormbricksConnectorForm = z.object({
sourceName: z.string().trim().min(1),
surveyId: z.string().min(1),
selectedQuestionIds: z.array(z.string()).min(1),
sourceName: z.string().trim().min(1, "CONNECTOR_NAME_REQUIRED"),
surveyId: z.string().min(1, "CONNECTOR_SURVEY_REQUIRED"),
selectedQuestionIds: z.array(z.string()).min(1, "CONNECTOR_QUESTIONS_REQUIRED"),
importHistorical: z.boolean(),
});
export type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
export const getTranslatedConnectorError = (errorCode: string, t: TFunction): string => {
switch (errorCode) {
case "CONNECTOR_NAME_DUPLICATE":
return t("workspace.unify.error_connector_name_duplicate");
case "CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE":
return t("workspace.unify.error_connector_formbricks_mapping_duplicate");
case "CONNECTOR_FIELD_MAPPING_DUPLICATE":
return t("workspace.unify.error_connector_field_mapping_duplicate");
case "CONNECTOR_NAME_REQUIRED":
return t("workspace.unify.error_connector_name_required");
case "CONNECTOR_SURVEY_REQUIRED":
return t("workspace.unify.error_connector_survey_required");
case "CONNECTOR_QUESTIONS_REQUIRED":
return t("workspace.unify.error_connector_questions_required");
default:
return errorCode;
}
};
@@ -126,7 +126,8 @@ const FormError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const errorMessage = error?.message || error?.root?.message;
const body = error ? String(errorMessage) : children;
// Explicit children win — they're typically a translated/formatted version of the raw error.
const body = children ?? (error ? String(errorMessage) : null);
if (!body) {
return null;
@@ -1,7 +1,7 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { ChevronDown } from "lucide-react";
import { ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/cn";
@@ -28,6 +28,32 @@ const SelectTrigger = React.forwardRef<
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
@@ -39,18 +65,16 @@ const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = R
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white text-slate-700 shadow-md animate-in fade-in-80 dark:bg-slate-700 dark:text-slate-300",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
"max-h-[var(--radix-select-content-available-height)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}>
className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
@@ -98,6 +122,8 @@ export {
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
+1 -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`,
},
},
});
+1 -3
View File
@@ -782,9 +782,7 @@ hub:
# When empty, the chart renders TEI args from model, servedModelName, port,
# revision, and persistence.mountPath. Set this to fully override args.
args: []
extraArgs:
- --dtype
- float16
extraArgs: []
env: {}
port: 8080
+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`,
},
},
});